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.
@@ -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"
@@ -0,0 +1,6 @@
1
+ """`python -m simple_harness` - the harness without installing it first."""
2
+
3
+ from simple_harness.app import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
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()