simple-harness 0.2.0__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.
- simple_harness/__init__.py +9 -0
- simple_harness/__main__.py +6 -0
- simple_harness/app.py +643 -0
- simple_harness/atomic.py +79 -0
- simple_harness/config.py +321 -0
- simple_harness/connect.py +163 -0
- simple_harness/context.py +246 -0
- simple_harness/deepthink.py +288 -0
- simple_harness/git_ops.py +265 -0
- simple_harness/llm_client.py +892 -0
- simple_harness/mcp_client.py +1305 -0
- simple_harness/paths.py +65 -0
- simple_harness/permissions.py +274 -0
- simple_harness/providers.py +832 -0
- simple_harness/renderer.py +341 -0
- simple_harness/session.py +268 -0
- simple_harness/shell_session.py +403 -0
- simple_harness/skills.py +317 -0
- simple_harness/sse.py +62 -0
- simple_harness/subagent.py +237 -0
- simple_harness/systemprompt.py +180 -0
- simple_harness/tools.py +1142 -0
- simple_harness/toolspec.py +370 -0
- simple_harness/tui.py +465 -0
- simple_harness/websearch.py +470 -0
- simple_harness-0.2.0.dist-info/METADATA +929 -0
- simple_harness-0.2.0.dist-info/RECORD +31 -0
- simple_harness-0.2.0.dist-info/WHEEL +5 -0
- simple_harness-0.2.0.dist-info/entry_points.txt +2 -0
- simple_harness-0.2.0.dist-info/licenses/LICENSE +202 -0
- simple_harness-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Simple Harness - a terminal AI assistant built for small local models.
|
|
2
|
+
|
|
3
|
+
Nothing is imported here on purpose. `config` builds the system prompt at import
|
|
4
|
+
time (ARCHITECTURE 5.2), so anything this file pulled in would be dragged into
|
|
5
|
+
every `from simple_harness import ...` in the package, and the import order that
|
|
6
|
+
invariant depends on would stop being obvious.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
simple_harness/app.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import datetime
|
|
7
|
+
from simple_harness import __version__
|
|
8
|
+
from simple_harness import config
|
|
9
|
+
from simple_harness import paths
|
|
10
|
+
from simple_harness import deepthink
|
|
11
|
+
from simple_harness import git_ops
|
|
12
|
+
from simple_harness import skills
|
|
13
|
+
from simple_harness import mcp_client
|
|
14
|
+
from simple_harness import permissions
|
|
15
|
+
from simple_harness import providers
|
|
16
|
+
from simple_harness import connect
|
|
17
|
+
from simple_harness.config import S
|
|
18
|
+
from simple_harness.systemprompt import systemprompt as _build_system_prompt
|
|
19
|
+
from simple_harness.tui import _welcome, _show_help, _show_skills, _show_mcp, _show_perms, _fmt_tool_call, _fmt_tool_result, display_usage_graph, _hr
|
|
20
|
+
from simple_harness.renderer import _render_full
|
|
21
|
+
from simple_harness.session import (save_session, load_session, list_sessions, find_sessions,
|
|
22
|
+
rename_session, generate_session_title, clean_title)
|
|
23
|
+
from simple_harness.context import manage_context
|
|
24
|
+
from simple_harness.llm_client import chat_turn, parse_tool_calls, strip_thinking
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _compose_system_prompt(summary: str = "") -> str:
|
|
28
|
+
base = config.SYSTEM_PROMPT
|
|
29
|
+
if config.CUSTOM_PERSONA:
|
|
30
|
+
base = config.CUSTOM_PERSONA + "\n\n" + base
|
|
31
|
+
return base + summary
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_summary(system_content: str) -> str:
|
|
35
|
+
m = re.search(r'\n\n<SUMMARY>(.*?)</SUMMARY>', system_content, re.DOTALL)
|
|
36
|
+
return f"\n\n<SUMMARY>{m.group(1)}</SUMMARY>" if m else ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _refresh_system_prompt(messages: list[dict]) -> None:
|
|
40
|
+
"""Rebuild the system message in place, keeping persona and summary intact."""
|
|
41
|
+
summary = _extract_summary(messages[0]["content"])
|
|
42
|
+
config.SYSTEM_PROMPT = _build_system_prompt()
|
|
43
|
+
messages[0]["content"] = _compose_system_prompt(summary)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _connect_mcp_servers() -> list:
|
|
47
|
+
"""Bring up the configured MCP servers. Returns the ones that failed.
|
|
48
|
+
|
|
49
|
+
This runs before the first system prompt is built, because the servers'
|
|
50
|
+
tool lists are part of it.
|
|
51
|
+
"""
|
|
52
|
+
if not config.MCP_ENABLED:
|
|
53
|
+
return []
|
|
54
|
+
pending = [s for s in mcp_client.load_servers().values() if s.state != "disabled"]
|
|
55
|
+
if not pending:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
plural = "s" if len(pending) != 1 else ""
|
|
59
|
+
sys.stdout.write(f" {S.MUTED}⟳ connecting {len(pending)} MCP server{plural}…{S.R}")
|
|
60
|
+
sys.stdout.flush()
|
|
61
|
+
try:
|
|
62
|
+
mcp_client.connect_all()
|
|
63
|
+
finally:
|
|
64
|
+
sys.stdout.write("\r\033[K")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
return [s for s in pending if s.state == "failed"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _report_mcp_problems(failed: list) -> None:
|
|
70
|
+
for problem in getattr(mcp_client.load_servers, "errors", []):
|
|
71
|
+
print(f" {S.ERR}✗ {problem}{S.R}")
|
|
72
|
+
for server in failed:
|
|
73
|
+
print(f" {S.WARN}⚠ MCP server '{server.name}' failed: {server.error[:200]}{S.R}")
|
|
74
|
+
if failed:
|
|
75
|
+
print(f" {S.MUTED} Run {S.ACCENT}/mcp{S.MUTED} for details, {S.ACCENT}/mcp reload{S.MUTED} to retry.{S.R}\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _report_strays() -> None:
|
|
79
|
+
"""Point out state an older version wrote into this directory.
|
|
80
|
+
|
|
81
|
+
Named, never touched. `sessions` and `memory.json` are ordinary enough
|
|
82
|
+
names that moving one on sight would eventually take somebody's real work
|
|
83
|
+
with it - so this says what it found and what to type, and stops there.
|
|
84
|
+
"""
|
|
85
|
+
strays = paths.strays_in_cwd()
|
|
86
|
+
if not strays:
|
|
87
|
+
return
|
|
88
|
+
print(f" {S.MUTED}\u25c6 {', '.join(strays)} here look like state from an "
|
|
89
|
+
f"older version.{S.R}")
|
|
90
|
+
print(f" {S.MUTED} It now lives in {paths.home()}. Nothing has been moved; "
|
|
91
|
+
f"to move it:{S.R}")
|
|
92
|
+
print(f" {S.GRAY} mv {' '.join(strays)} {paths.home()}/{S.R}\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def main() -> None:
|
|
96
|
+
|
|
97
|
+
if config.CURRENT_OS == "Windows":
|
|
98
|
+
os.system("")
|
|
99
|
+
|
|
100
|
+
print("\033[2J\033[H", end="")
|
|
101
|
+
providers.apply_startup()
|
|
102
|
+
failed_mcp = _connect_mcp_servers()
|
|
103
|
+
config.SYSTEM_PROMPT = _build_system_prompt()
|
|
104
|
+
messages: list[dict] = [{"role": "system", "content": _compose_system_prompt()}]
|
|
105
|
+
|
|
106
|
+
_welcome()
|
|
107
|
+
_report_mcp_problems(failed_mcp)
|
|
108
|
+
_report_strays()
|
|
109
|
+
|
|
110
|
+
current_session_id = None
|
|
111
|
+
|
|
112
|
+
if config.PROMPT_TOOLKIT_AVAILABLE:
|
|
113
|
+
paths.ensure_home() # FileHistory opens its file straight away
|
|
114
|
+
from simple_harness.config import SlashCommandCompleter, PromptSession, FileHistory, ANSI
|
|
115
|
+
completer = SlashCommandCompleter(['/help', '/clear', '/usage', '/model', '/models', '/exit', '/quit', '/sessions', '/load', '/title', '/autotitle', '/automode', '/fullcontent', '/record', '/export', '/system', '/planmode', '/skills', '/skill', '/mcp', '/perms', '/think', '/connect', '/undo', '/autocommit', '/deepthink'])
|
|
116
|
+
session_pt = PromptSession(
|
|
117
|
+
history=FileHistory(config.HISTORY_FILE),
|
|
118
|
+
completer=completer,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
if config.PROMPT_TOOLKIT_AVAILABLE:
|
|
124
|
+
user_input = await session_pt.prompt_async(ANSI(f" {S.USER_CLR}{S.BOLD}❯{S.R} "))
|
|
125
|
+
user_input = user_input.strip()
|
|
126
|
+
else:
|
|
127
|
+
user_input = input(f" {S.USER_CLR}{S.BOLD}❯{S.R} ").strip()
|
|
128
|
+
# A console that hands back surrogate escapes would otherwise poison
|
|
129
|
+
# the history: every later save and request would raise.
|
|
130
|
+
user_input = config.safe_text(user_input)
|
|
131
|
+
except (EOFError, KeyboardInterrupt):
|
|
132
|
+
mcp_client.shutdown()
|
|
133
|
+
print(f"\n\n {S.GRAY}Goodbye!{S.R}\n")
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
if not user_input:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
cmd = user_input.lower()
|
|
140
|
+
if cmd in ("/exit", "/quit"):
|
|
141
|
+
mcp_client.shutdown()
|
|
142
|
+
print(f"\n {S.GRAY}Goodbye!{S.R}\n")
|
|
143
|
+
break
|
|
144
|
+
if cmd == "/usage":
|
|
145
|
+
display_usage_graph(messages)
|
|
146
|
+
continue
|
|
147
|
+
if cmd == "/help":
|
|
148
|
+
_show_help()
|
|
149
|
+
continue
|
|
150
|
+
if cmd == "/clear":
|
|
151
|
+
config.SYSTEM_PROMPT = _build_system_prompt()
|
|
152
|
+
messages = [{"role": "system", "content": _compose_system_prompt()}]
|
|
153
|
+
current_session_id = None
|
|
154
|
+
config.SESSION_TITLE = ""
|
|
155
|
+
config.token_history.clear()
|
|
156
|
+
config.LOADED_SKILLS.clear()
|
|
157
|
+
print("\033[2J\033[H", end="")
|
|
158
|
+
_welcome()
|
|
159
|
+
print(f" {S.OK}✓ Conversation and usage cleared.{S.R}\n")
|
|
160
|
+
continue
|
|
161
|
+
if cmd == "/models":
|
|
162
|
+
provider = providers.current()
|
|
163
|
+
print(f"\n {S.BOLD}{S.ACCENT}{provider.label} Models{S.R}")
|
|
164
|
+
print(f" {_hr(width=50)}")
|
|
165
|
+
try:
|
|
166
|
+
available = provider.list_models()
|
|
167
|
+
if not available:
|
|
168
|
+
print(f" {S.WARN}\u26a0 None found.{S.R}")
|
|
169
|
+
for i, entry in enumerate(available, 1):
|
|
170
|
+
marker = f" {S.OK}\u25c0 current{S.R}" if entry["name"] == config.MODEL else ""
|
|
171
|
+
detail = f" {S.GRAY}({entry['detail']}){S.R}" if entry.get("detail") else ""
|
|
172
|
+
print(f" {S.ACCENT}{i:3}.{S.R} {S.WHITE}{entry['name']}{S.R}{detail}{marker}")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
print(f" {S.ERR}\u2717 Failed to list models: {e}{S.R}")
|
|
175
|
+
print()
|
|
176
|
+
continue
|
|
177
|
+
if cmd == "/model":
|
|
178
|
+
provider = providers.current()
|
|
179
|
+
print(f"\n {S.GRAY}provider{S.R} {S.WHITE}{provider.label}{S.R}")
|
|
180
|
+
print(f" {S.GRAY}model{S.R} {S.WHITE}{config.MODEL}{S.R}\n")
|
|
181
|
+
connect.run(provider.name)
|
|
182
|
+
continue
|
|
183
|
+
if cmd == "/connect" or cmd.startswith("/connect "):
|
|
184
|
+
connect.run(user_input.split(" ", 1)[1].strip() if " " in user_input else "")
|
|
185
|
+
_refresh_system_prompt(messages)
|
|
186
|
+
continue
|
|
187
|
+
if cmd == "/sessions":
|
|
188
|
+
sessions = list_sessions()
|
|
189
|
+
print(f"\n {S.BOLD}{S.ACCENT}Saved Sessions{S.R}")
|
|
190
|
+
if not sessions:
|
|
191
|
+
print(f" {S.GRAY} No saved sessions yet.{S.R}")
|
|
192
|
+
for sid, title, meta in sessions:
|
|
193
|
+
marker = f" {S.OK}◀ current{S.R}" if sid == current_session_id else ""
|
|
194
|
+
print(f" {S.GRAY}•{S.R} {S.WHITE}{title or S.GRAY + '(untitled)' + S.R}{S.R}{marker}")
|
|
195
|
+
print(f" {S.MUTED}{sid}{S.R} {S.GRAY}{meta}{S.R}")
|
|
196
|
+
print(f"\n {S.GRAY}Load one with {S.ACCENT}/load <id or title>{S.GRAY}.{S.R}\n")
|
|
197
|
+
continue
|
|
198
|
+
if cmd.startswith("/load"):
|
|
199
|
+
parts = user_input.split(" ", 1)
|
|
200
|
+
if len(parts) < 2 or not parts[1].strip():
|
|
201
|
+
print(f" {S.ERR}✗ Usage: /load <id or title>{S.R}\n")
|
|
202
|
+
continue
|
|
203
|
+
query = parts[1].strip()
|
|
204
|
+
matches = find_sessions(query)
|
|
205
|
+
if len(matches) > 1:
|
|
206
|
+
print(f" {S.WARN}⚠ '{query}' matches {len(matches)} sessions:{S.R}")
|
|
207
|
+
for m_sid, m_title, _ in matches[:10]:
|
|
208
|
+
print(f" {S.GRAY}•{S.R} {S.WHITE}{m_title or '(untitled)'}{S.R} {S.MUTED}{m_sid}{S.R}")
|
|
209
|
+
print(f" {S.GRAY}Re-run /load with one of the ids above.{S.R}\n")
|
|
210
|
+
continue
|
|
211
|
+
sid = matches[0][0] if matches else query
|
|
212
|
+
loaded = load_session(sid)
|
|
213
|
+
if loaded:
|
|
214
|
+
if isinstance(loaded, dict) and loaded.get("version") in (2, 3):
|
|
215
|
+
messages = loaded.get("messages", [])
|
|
216
|
+
config.token_history.clear()
|
|
217
|
+
config.token_history.extend(loaded.get("token_history", []))
|
|
218
|
+
config.MODEL = loaded.get("model", config.MODEL)
|
|
219
|
+
config.CUSTOM_PERSONA = loaded.get("persona", config.CUSTOM_PERSONA)
|
|
220
|
+
config.SESSION_TITLE = loaded.get("title", "")
|
|
221
|
+
else:
|
|
222
|
+
messages = loaded
|
|
223
|
+
config.token_history.clear()
|
|
224
|
+
config.SESSION_TITLE = ""
|
|
225
|
+
current_session_id = sid
|
|
226
|
+
config.LOADED_SKILLS[:] = skills.loaded_skill_names(messages)
|
|
227
|
+
label = config.SESSION_TITLE or sid
|
|
228
|
+
print(f" {S.OK}✓ Loaded session: {label} (Model: {config.MODEL}){S.R}\n")
|
|
229
|
+
for msg in messages:
|
|
230
|
+
if msg["role"] == "system":
|
|
231
|
+
continue
|
|
232
|
+
elif msg["role"] == "user":
|
|
233
|
+
if msg["content"].startswith("[Tool Result for '"):
|
|
234
|
+
m = re.match(r"\[Tool Result for '([^']+)'\]:\n(.*)", msg["content"], re.DOTALL)
|
|
235
|
+
if m:
|
|
236
|
+
_fmt_tool_result(m.group(1), m.group(2))
|
|
237
|
+
continue
|
|
238
|
+
print(f" {S.USER_CLR}{S.BOLD}❯{S.R} {msg['content']}")
|
|
239
|
+
elif msg["role"] == "assistant":
|
|
240
|
+
for name, arguments in parse_tool_calls(msg["content"], quiet=True):
|
|
241
|
+
_fmt_tool_call(name, arguments)
|
|
242
|
+
|
|
243
|
+
c = re.sub(r'<tool_call>.*?</tool_call>', '', msg["content"], flags=re.DOTALL)
|
|
244
|
+
c = strip_thinking(c)
|
|
245
|
+
if c:
|
|
246
|
+
print(_render_full(c))
|
|
247
|
+
print()
|
|
248
|
+
else:
|
|
249
|
+
print(f" {S.ERR}✗ Session not found: {sid}{S.R}\n")
|
|
250
|
+
continue
|
|
251
|
+
if cmd == "/title" or cmd.startswith("/title "):
|
|
252
|
+
parts = user_input.split(" ", 1)
|
|
253
|
+
new_title = parts[1].strip() if len(parts) > 1 else ""
|
|
254
|
+
if not new_title:
|
|
255
|
+
shown = config.SESSION_TITLE or f"{S.GRAY}(untitled){S.R}"
|
|
256
|
+
print(f"\n {S.GRAY}title{S.R} {S.WHITE}{shown}{S.R}")
|
|
257
|
+
print(f" {S.GRAY}id{S.R} {S.WHITE}{current_session_id or '(not saved yet)'}{S.R}")
|
|
258
|
+
print(f" {S.MUTED}Rename with /title <new title>{S.R}\n")
|
|
259
|
+
continue
|
|
260
|
+
if not clean_title(new_title):
|
|
261
|
+
print(f" {S.ERR}✗ That title is empty after cleanup.{S.R}\n")
|
|
262
|
+
continue
|
|
263
|
+
current_session_id = rename_session(current_session_id, new_title)
|
|
264
|
+
print(f" {S.OK}✓ Session titled: {config.SESSION_TITLE}{S.R} {S.MUTED}({current_session_id or 'saved on next message'}){S.R}\n")
|
|
265
|
+
continue
|
|
266
|
+
if cmd.startswith("/autotitle"):
|
|
267
|
+
parts = cmd.split(" ", 1)
|
|
268
|
+
if len(parts) < 2 or parts[1] not in ("on", "off"):
|
|
269
|
+
print(f" {S.ERR}✗ Usage: /autotitle <on/off> (currently {'on' if config.AUTO_TITLE else 'off'}){S.R}\n")
|
|
270
|
+
continue
|
|
271
|
+
config.AUTO_TITLE = parts[1] == "on"
|
|
272
|
+
print(f" {S.INFO}✓ Auto session titling is {'ON' if config.AUTO_TITLE else 'OFF'}.{S.R}")
|
|
273
|
+
continue
|
|
274
|
+
if cmd.startswith("/automode"):
|
|
275
|
+
parts = cmd.split(" ", 1)
|
|
276
|
+
if len(parts) < 2 or not parts[1] in ("on", "off"):
|
|
277
|
+
print(f" {S.ERR}✗ Usage: /automode <on/off>{S.R}\n")
|
|
278
|
+
continue
|
|
279
|
+
if parts[1] == "on":
|
|
280
|
+
config.AUTO_ALLOW = True
|
|
281
|
+
if parts[1] == "off":
|
|
282
|
+
config.AUTO_ALLOW = False
|
|
283
|
+
print(f" {S.INFO}✓ Automode has been on.{S.R}" if parts[1] == "on" else f" {S.INFO}✓ Automode has been off.{S.R}")
|
|
284
|
+
continue
|
|
285
|
+
if cmd.startswith("/fullcontent"):
|
|
286
|
+
parts = cmd.split(" ", 1)
|
|
287
|
+
if len(parts) < 2 or not parts[1] in ("on", "off"):
|
|
288
|
+
print(f" {S.ERR}✗ Usage: /fullcontent <on/off>{S.R}\n")
|
|
289
|
+
continue
|
|
290
|
+
if parts[1] == "on":
|
|
291
|
+
config.RETURN_ALL_FILE_CONTENT = True
|
|
292
|
+
if parts[1] == "off":
|
|
293
|
+
config.RETURN_ALL_FILE_CONTENT = False
|
|
294
|
+
print(f" {S.INFO}✓ Full content mode has been turned on.{S.R}" if parts[1] == "on" else f" {S.INFO}✓ Full content mode has been turned off.{S.R}")
|
|
295
|
+
continue
|
|
296
|
+
if cmd.startswith("/record"):
|
|
297
|
+
parts = cmd.split(" ", 1)
|
|
298
|
+
if len(parts) < 2 or not parts[1] in ("on", "off"):
|
|
299
|
+
print(f" {S.ERR}✗ Usage: /record <on/off>{S.R}\n")
|
|
300
|
+
continue
|
|
301
|
+
if parts[1] == "on":
|
|
302
|
+
config.SAVE_CHAT_HISTORY = True
|
|
303
|
+
if parts[1] == "off":
|
|
304
|
+
config.SAVE_CHAT_HISTORY = False
|
|
305
|
+
print(f" {S.INFO}✓ Chat history recording is ON.{S.R}" if parts[1] == "on" else f" {S.INFO}✓ Chat history recording is OFF.{S.R}")
|
|
306
|
+
continue
|
|
307
|
+
if cmd.startswith("/export"):
|
|
308
|
+
parts = cmd.split(" ", 1)
|
|
309
|
+
filename = parts[1].strip() if len(parts) > 1 else f"export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
|
|
310
|
+
try:
|
|
311
|
+
with open(filename, "w", encoding="utf-8") as f:
|
|
312
|
+
for m in messages:
|
|
313
|
+
if m["role"] == "system": continue
|
|
314
|
+
role_name = "User" if m["role"] == "user" else "Assistant"
|
|
315
|
+
c = re.sub(r'<tool_call>.*?</tool_call>', '', m["content"], flags=re.DOTALL)
|
|
316
|
+
c = strip_thinking(c)
|
|
317
|
+
if c: f.write(f"### {role_name}\n\n{c}\n\n")
|
|
318
|
+
print(f" {S.OK}✓ Conversation exported to {filename}{S.R}\n")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
print(f" {S.ERR}✗ Export failed: {e}{S.R}\n")
|
|
321
|
+
continue
|
|
322
|
+
if cmd.startswith("/system"):
|
|
323
|
+
parts = cmd.split(" ", 1)
|
|
324
|
+
if len(parts) < 2:
|
|
325
|
+
print(f" {S.ERR}✗ Usage: /system <new prompt> or /system reset{S.R}\n")
|
|
326
|
+
continue
|
|
327
|
+
new_prompt = parts[1].strip()
|
|
328
|
+
current_sys = messages[0]["content"]
|
|
329
|
+
summary_match = re.search(r'\n\n<SUMMARY>(.*?)</SUMMARY>', current_sys, re.DOTALL)
|
|
330
|
+
summary_text = f"\n\n<SUMMARY>{summary_match.group(1)}</SUMMARY>" if summary_match else ""
|
|
331
|
+
|
|
332
|
+
if new_prompt.lower() == "reset":
|
|
333
|
+
config.CUSTOM_PERSONA = ""
|
|
334
|
+
messages[0]["content"] = config.SYSTEM_PROMPT + summary_text
|
|
335
|
+
print(f" {S.INFO}✓ System prompt reset to default.{S.R}")
|
|
336
|
+
print(f" {S.WARN}⚠ If the persona context from the previous conversation remains, please clear the conversation history with /clear.{S.R}\n")
|
|
337
|
+
else:
|
|
338
|
+
config.CUSTOM_PERSONA = new_prompt
|
|
339
|
+
messages[0]["content"] = config.CUSTOM_PERSONA + "\n\n" + config.SYSTEM_PROMPT + summary_text
|
|
340
|
+
print(f" {S.INFO}✓ System prompt updated.{S.R}")
|
|
341
|
+
print(f" {S.WARN}⚠ To ensure the persona is applied correctly, please clear the previous conversation with /clear.{S.R}\n")
|
|
342
|
+
continue
|
|
343
|
+
if cmd.startswith("/planmode"):
|
|
344
|
+
parts = cmd.split(" ", 1)
|
|
345
|
+
if len(parts) < 2 or parts[1] not in ("on", "off"):
|
|
346
|
+
print(f" {S.ERR}✗ Usage: /planmode <on/off>{S.R}\n")
|
|
347
|
+
continue
|
|
348
|
+
config.PLANMODE = True if parts[1] == "on" else False
|
|
349
|
+
print(f" {S.INFO}✓ Plan mode is {'ON' if config.PLANMODE else 'OFF'}.{S.R}")
|
|
350
|
+
continue
|
|
351
|
+
if cmd == "/skills" or cmd.startswith("/skills "):
|
|
352
|
+
arg = user_input.split(" ", 1)[1].strip().lower() if " " in user_input else ""
|
|
353
|
+
if not arg:
|
|
354
|
+
_show_skills()
|
|
355
|
+
elif arg == "reload":
|
|
356
|
+
skills.discover_skills(force=True)
|
|
357
|
+
_refresh_system_prompt(messages)
|
|
358
|
+
print(f" {S.OK}✓ Skills reloaded: {len(skills.list_skills())} available.{S.R}\n")
|
|
359
|
+
else:
|
|
360
|
+
print(f" {S.ERR}✗ Usage: /skills [reload]{S.R}\n")
|
|
361
|
+
continue
|
|
362
|
+
if cmd.startswith("/skill"):
|
|
363
|
+
parts = user_input.split(" ", 1)
|
|
364
|
+
if len(parts) < 2 or not parts[1].strip():
|
|
365
|
+
print(f" {S.ERR}✗ Usage: /skill <name> (see /skills){S.R}\n")
|
|
366
|
+
continue
|
|
367
|
+
skill = skills.get_skill(parts[1].strip())
|
|
368
|
+
if skill is None:
|
|
369
|
+
print(f" {S.ERR}✗ Skill not found: {parts[1].strip()}{S.R}\n")
|
|
370
|
+
continue
|
|
371
|
+
_fmt_tool_call("use_skill", {"skill_name": skill["name"]})
|
|
372
|
+
result = skills.handle_use_skill(skill["name"])
|
|
373
|
+
_fmt_tool_result("use_skill", result)
|
|
374
|
+
messages.append({"role": "user", "content": f"[Tool Result for 'use_skill']:\n{result}"})
|
|
375
|
+
current_session_id = save_session(messages, current_session_id)
|
|
376
|
+
continue
|
|
377
|
+
if cmd == "/mcp" or cmd.startswith("/mcp "):
|
|
378
|
+
parts = user_input.split()
|
|
379
|
+
sub = parts[1].lower() if len(parts) > 1 else ""
|
|
380
|
+
args = parts[2:]
|
|
381
|
+
injected = ""
|
|
382
|
+
|
|
383
|
+
if not sub:
|
|
384
|
+
_show_mcp()
|
|
385
|
+
elif sub in ("tools", "prompts", "all"):
|
|
386
|
+
_show_mcp(sub, args[0] if args else "")
|
|
387
|
+
elif sub in ("reload", "refresh"):
|
|
388
|
+
sys.stdout.write(f" {S.MUTED}⟳ reloading MCP servers…{S.R}")
|
|
389
|
+
sys.stdout.flush()
|
|
390
|
+
mcp_client.reconnect()
|
|
391
|
+
sys.stdout.write("\r\033[K")
|
|
392
|
+
sys.stdout.flush()
|
|
393
|
+
_refresh_system_prompt(messages)
|
|
394
|
+
print(f" {S.OK}✓ MCP reloaded: {mcp_client.status_summary()}.{S.R}\n")
|
|
395
|
+
_report_mcp_problems([s for s in mcp_client.all_servers() if s.state == "failed"])
|
|
396
|
+
elif sub in ("connect", "reconnect"):
|
|
397
|
+
if not args:
|
|
398
|
+
print(f" {S.ERR}✗ Usage: /mcp connect <server name>{S.R}\n")
|
|
399
|
+
else:
|
|
400
|
+
touched = mcp_client.reconnect(args[0])
|
|
401
|
+
if not touched:
|
|
402
|
+
print(f" {S.ERR}✗ No MCP server named '{args[0]}'. See /mcp.{S.R}\n")
|
|
403
|
+
else:
|
|
404
|
+
server = touched[0]
|
|
405
|
+
_refresh_system_prompt(messages)
|
|
406
|
+
if server.state == "connected":
|
|
407
|
+
print(f" {S.OK}✓ '{server.name}' connected: {len(server.tools)} tool(s).{S.R}\n")
|
|
408
|
+
else:
|
|
409
|
+
print(f" {S.ERR}✗ '{server.name}' is {server.state}: {server.error[:200]}{S.R}\n")
|
|
410
|
+
elif sub == "resources":
|
|
411
|
+
print()
|
|
412
|
+
print(mcp_client.list_resources_text(args[0] if args else ""))
|
|
413
|
+
print()
|
|
414
|
+
elif sub == "prompt":
|
|
415
|
+
if len(args) < 2:
|
|
416
|
+
print(f" {S.ERR}✗ Usage: /mcp prompt <server> <prompt name> [key=value ...]{S.R}\n")
|
|
417
|
+
else:
|
|
418
|
+
server = mcp_client.get_server(args[0])
|
|
419
|
+
if server is None or server.state != "connected":
|
|
420
|
+
print(f" {S.ERR}✗ No connected MCP server named '{args[0]}'.{S.R}\n")
|
|
421
|
+
else:
|
|
422
|
+
prompt_args = {}
|
|
423
|
+
for token in args[2:]:
|
|
424
|
+
key, sep, value = token.partition("=")
|
|
425
|
+
if sep:
|
|
426
|
+
prompt_args[key] = value
|
|
427
|
+
try:
|
|
428
|
+
fetched = server.get_prompt(args[1], prompt_args)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
print(f" {S.ERR}✗ {server.name}: {e}{S.R}\n")
|
|
431
|
+
else:
|
|
432
|
+
injected = mcp_client.prompt_to_text(fetched)
|
|
433
|
+
print(f"\n {S.MUTED}─ prompt '{args[1]}' from {server.name}{S.R}")
|
|
434
|
+
print(_render_full(injected))
|
|
435
|
+
print()
|
|
436
|
+
elif sub in ("on", "off"):
|
|
437
|
+
config.MCP_ENABLED = sub == "on"
|
|
438
|
+
if config.MCP_ENABLED:
|
|
439
|
+
_report_mcp_problems(_connect_mcp_servers())
|
|
440
|
+
else:
|
|
441
|
+
mcp_client.shutdown()
|
|
442
|
+
_refresh_system_prompt(messages)
|
|
443
|
+
print(f" {S.INFO}✓ MCP is {'ON' if config.MCP_ENABLED else 'OFF'}.{S.R}\n")
|
|
444
|
+
else:
|
|
445
|
+
print(f" {S.ERR}✗ Usage: /mcp [tools|prompts|all|resources|reload|connect <name>|prompt <server> <name>|on|off]{S.R}\n")
|
|
446
|
+
|
|
447
|
+
if not injected:
|
|
448
|
+
continue
|
|
449
|
+
user_input = injected
|
|
450
|
+
if cmd == "/perms" or cmd.startswith("/perms "):
|
|
451
|
+
parts = user_input.split(" ", 2)
|
|
452
|
+
sub = parts[1].lower() if len(parts) > 1 else ""
|
|
453
|
+
argument = parts[2].strip() if len(parts) > 2 else ""
|
|
454
|
+
|
|
455
|
+
if not sub:
|
|
456
|
+
_show_perms()
|
|
457
|
+
elif sub in ("reload", "refresh"):
|
|
458
|
+
permissions.load_rules(force=True)
|
|
459
|
+
allowed = len(permissions.rules_for("allow"))
|
|
460
|
+
denied = len(permissions.rules_for("deny"))
|
|
461
|
+
print(f" {S.OK}✓ Permission rules reloaded: {allowed} allow, {denied} deny.{S.R}\n")
|
|
462
|
+
for problem in permissions.errors:
|
|
463
|
+
print(f" {S.ERR}✗ {problem}{S.R}")
|
|
464
|
+
elif sub in ("allow", "deny"):
|
|
465
|
+
if not argument:
|
|
466
|
+
print(f" {S.ERR}✗ Usage: /perms {sub} <rule> e.g. /perms {sub} run_cmd(git *){S.R}\n")
|
|
467
|
+
else:
|
|
468
|
+
saved, where = permissions.add_rule(argument, sub)
|
|
469
|
+
if saved:
|
|
470
|
+
print(f" {S.OK}✓ {sub}: {argument}{S.R} {S.MUTED}({where}){S.R}\n")
|
|
471
|
+
else:
|
|
472
|
+
print(f" {S.ERR}✗ Could not save the rule: {where}{S.R}\n")
|
|
473
|
+
else:
|
|
474
|
+
print(f" {S.ERR}✗ Usage: /perms [reload|allow <rule>|deny <rule>]{S.R}\n")
|
|
475
|
+
continue
|
|
476
|
+
if cmd == "/think" or cmd.startswith("/think "):
|
|
477
|
+
parts = cmd.split(" ", 1)
|
|
478
|
+
if len(parts) < 2 or parts[1].strip() not in ("on", "off"):
|
|
479
|
+
state = "shown" if config.SHOW_THINKING else "hidden"
|
|
480
|
+
print(f" {S.ERR}✗ Usage: /think <on/off> (a model's reasoning is currently {state}){S.R}\n")
|
|
481
|
+
continue
|
|
482
|
+
config.SHOW_THINKING = parts[1].strip() == "on"
|
|
483
|
+
print(f" {S.INFO}✓ Model reasoning is {'SHOWN' if config.SHOW_THINKING else 'HIDDEN'}."
|
|
484
|
+
f"{S.MUTED} It is never kept in the conversation history.{S.R}\n")
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
if cmd == "/undo":
|
|
488
|
+
ok, message = git_ops.undo_last()
|
|
489
|
+
colour = S.OK if ok else S.WARN
|
|
490
|
+
print(f" {colour}{'✓' if ok else '⚠'} {message}{S.R}\n")
|
|
491
|
+
continue
|
|
492
|
+
|
|
493
|
+
if cmd == "/autocommit" or cmd.startswith("/autocommit "):
|
|
494
|
+
parts = cmd.split(" ", 1)
|
|
495
|
+
setting = parts[1].strip() if len(parts) > 1 else ""
|
|
496
|
+
if setting not in ("on", "off"):
|
|
497
|
+
state = "ON" if config.GIT_AUTO_COMMIT else "OFF"
|
|
498
|
+
print(f" {S.INFO}Auto-commit is {S.BOLD}{state}{S.R}"
|
|
499
|
+
f"{S.MUTED} - each file an AI tool changes is committed on its own.{S.R}")
|
|
500
|
+
if not git_ops.repo_root():
|
|
501
|
+
print(f" {S.MUTED}This directory is not a git repository, so nothing "
|
|
502
|
+
f"is committed either way.{S.R}")
|
|
503
|
+
recent = git_ops.recent_ai_commits(5)
|
|
504
|
+
for commit in recent:
|
|
505
|
+
print(f" {S.MUTED}│{S.R} {S.GRAY}{commit['sha']}{S.R} "
|
|
506
|
+
f"{commit['subject']} {S.MUTED}({commit['when']}){S.R}")
|
|
507
|
+
if recent:
|
|
508
|
+
print(f" {S.MUTED}╰─ /undo takes the newest one back{S.R}")
|
|
509
|
+
print(f" {S.MUTED}Usage: /autocommit <on/off>{S.R}\n")
|
|
510
|
+
continue
|
|
511
|
+
config.GIT_AUTO_COMMIT = setting == "on"
|
|
512
|
+
print(f" {S.INFO}✓ Auto-commit is "
|
|
513
|
+
f"{'ON' if config.GIT_AUTO_COMMIT else 'OFF'}."
|
|
514
|
+
f"{S.MUTED} {'Each AI edit gets its own commit; /undo takes one back.' if config.GIT_AUTO_COMMIT else 'AI edits are no longer committed for you.'}{S.R}\n")
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
if cmd == "/deepthink" or cmd.startswith("/deepthink "):
|
|
518
|
+
parts = cmd.split(" ", 1)
|
|
519
|
+
setting = parts[1].strip() if len(parts) > 1 else ""
|
|
520
|
+
if setting not in ("on", "off"):
|
|
521
|
+
state = "ON" if config.DEEPTHINK else "OFF"
|
|
522
|
+
print(f" {S.INFO}Deepthink is {S.BOLD}{state}{S.R}"
|
|
523
|
+
f"{S.MUTED} - one request becomes five turns:{S.R}")
|
|
524
|
+
for i, stage in enumerate(deepthink.STAGES, 1):
|
|
525
|
+
print(f" {S.MUTED}│{S.R} {S.GRAY}{i}.{S.R} {stage.title}")
|
|
526
|
+
print(f" {S.MUTED}╰─ a request that needs no changes stops "
|
|
527
|
+
f"after the first.{S.R}")
|
|
528
|
+
print(f" {S.MUTED}Usage: /deepthink <on/off>{S.R}\n")
|
|
529
|
+
continue
|
|
530
|
+
config.DEEPTHINK = setting == "on"
|
|
531
|
+
if config.DEEPTHINK:
|
|
532
|
+
print(f" {S.INFO}✓ Deepthink is ON.{S.MUTED} Say what you want built "
|
|
533
|
+
f"and it will plan, argue with the plan, build it, review the "
|
|
534
|
+
f"diff, then run it.{S.R}\n")
|
|
535
|
+
else:
|
|
536
|
+
print(f" {S.INFO}✓ Deepthink is OFF.{S.MUTED} Back to one turn per "
|
|
537
|
+
f"request.{S.R}\n")
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
if config.PLANMODE and not config.DEEPTHINK:
|
|
541
|
+
if config.AUTO_ALLOW:
|
|
542
|
+
plan_prompt = (
|
|
543
|
+
"\n\n[System Note: PLAN MODE is ON. For complex tasks or file modifications, you MUST use the `submit_plan_for_approval` tool before executing changes. Since AUTOMODE is ON, it will auto-approve. Follow your blueprint and verify afterwards.]"
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
plan_prompt = (
|
|
547
|
+
"\n\n[System Note: PLAN MODE is ON. For complex tasks, system changes, or file modifications:\n"
|
|
548
|
+
"1. Explore the codebase using search/read tools.\n"
|
|
549
|
+
"2. You MUST call the `submit_plan_for_approval` tool to present your blueprint and wait for the tool's result.\n"
|
|
550
|
+
"3. DO NOT use edit/write/run_cmd tools until the plan is approved via the tool's return value.\n"
|
|
551
|
+
"For simple conversational queries, you may answer directly.]"
|
|
552
|
+
)
|
|
553
|
+
user_input += plan_prompt
|
|
554
|
+
|
|
555
|
+
messages.append({"role": "user", "content": user_input})
|
|
556
|
+
config.repair_messages(messages)
|
|
557
|
+
current_session_id = save_session(messages, current_session_id)
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
if config.DEEPTHINK:
|
|
561
|
+
# deepthink drives its own turns, and manages context between
|
|
562
|
+
# them - it is five passes over one request, not one.
|
|
563
|
+
result = await deepthink.run(messages)
|
|
564
|
+
else:
|
|
565
|
+
await manage_context(messages)
|
|
566
|
+
result = await chat_turn(messages)
|
|
567
|
+
|
|
568
|
+
current_session_id = save_session(messages, current_session_id)
|
|
569
|
+
|
|
570
|
+
# Name the session once, from the exchange that just finished.
|
|
571
|
+
if config.AUTO_TITLE and not config.SESSION_TITLE and current_session_id:
|
|
572
|
+
sys.stdout.write(f" {S.MUTED}✎ naming session…{S.R}")
|
|
573
|
+
sys.stdout.flush()
|
|
574
|
+
title = await generate_session_title(messages)
|
|
575
|
+
sys.stdout.write("\r\033[K")
|
|
576
|
+
sys.stdout.flush()
|
|
577
|
+
if title:
|
|
578
|
+
current_session_id = rename_session(current_session_id, title)
|
|
579
|
+
print(f" {S.MUTED}✎ session titled: {S.GRAY}{config.SESSION_TITLE}{S.R}\n")
|
|
580
|
+
|
|
581
|
+
except Exception as e:
|
|
582
|
+
print(f"\n {S.ERR}✗ Error: {e}{S.R}\n")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _use_utf8_output() -> None:
|
|
586
|
+
"""Make sure the harness can print its own interface.
|
|
587
|
+
|
|
588
|
+
The TUI is drawn with box characters - the tool call alone uses U+25B8 and
|
|
589
|
+
U+2570 - and an answer is routinely not ASCII either. A Windows console
|
|
590
|
+
handles those, but a *pipe* on Windows does not: Python falls back to the
|
|
591
|
+
locale code page there, cp1252 or cp949, and the first tool call raises
|
|
592
|
+
UnicodeEncodeError halfway through drawing itself. Redirecting the output
|
|
593
|
+
to a file should not crash the program.
|
|
594
|
+
|
|
595
|
+
`errors="replace"` rather than "strict" for the same reason: a character
|
|
596
|
+
the terminal genuinely cannot show is worth one replacement glyph, never a
|
|
597
|
+
traceback in the middle of an answer.
|
|
598
|
+
"""
|
|
599
|
+
for stream in (sys.stdout, sys.stderr):
|
|
600
|
+
try:
|
|
601
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
602
|
+
except Exception:
|
|
603
|
+
pass # not a real stream, or an encoding it will not take
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _parse_args(argv: list) -> None:
|
|
607
|
+
"""Answer `--help` and `--version`, and refuse anything else.
|
|
608
|
+
|
|
609
|
+
There are no options: the harness is driven by slash commands once it is
|
|
610
|
+
running. But it is installed as a command now, and the first thing anyone
|
|
611
|
+
types at an unfamiliar command is `--help`. Ignoring it and opening an
|
|
612
|
+
interactive session instead is the wrong answer to a reasonable question.
|
|
613
|
+
"""
|
|
614
|
+
parser = argparse.ArgumentParser(
|
|
615
|
+
prog="simple-harness",
|
|
616
|
+
description="A terminal AI assistant built for small local models.",
|
|
617
|
+
epilog="Run with no arguments to start a session. Everything else is a "
|
|
618
|
+
"slash command inside it - type /help there for the list.")
|
|
619
|
+
parser.add_argument("-V", "--version", action="version",
|
|
620
|
+
version=f"simple-harness {__version__}")
|
|
621
|
+
parser.parse_args(argv)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def cli() -> None:
|
|
625
|
+
"""The `simple-harness` command, and what `python -m simple_harness` runs.
|
|
626
|
+
|
|
627
|
+
`main()` is a coroutine, and a console-script entry point has to be an
|
|
628
|
+
ordinary function - so the event loop and the two exits that are not errors
|
|
629
|
+
are handled here rather than under `__main__`, where an installed copy
|
|
630
|
+
would never reach them.
|
|
631
|
+
"""
|
|
632
|
+
_use_utf8_output()
|
|
633
|
+
_parse_args(sys.argv[1:])
|
|
634
|
+
try:
|
|
635
|
+
asyncio.run(main())
|
|
636
|
+
except KeyboardInterrupt:
|
|
637
|
+
print(f"\n\n {S.GRAY}Goodbye!{S.R}\n")
|
|
638
|
+
except Exception as e:
|
|
639
|
+
print(f"\n {S.ERR}✗ Unexpected error: {e}{S.R}")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
if __name__ == "__main__":
|
|
643
|
+
cli()
|