closecode-ai 0.1.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.
main.py ADDED
@@ -0,0 +1,520 @@
1
+
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import os
6
+ import sys
7
+ import time
8
+ import warnings
9
+
10
+ warnings.filterwarnings(
11
+ "ignore",
12
+ message="Core Pydantic V1 functionality isn't compatible with Python 3.14",
13
+ category=UserWarning,
14
+ )
15
+
16
+ from dotenv import load_dotenv
17
+
18
+ load_dotenv()
19
+
20
+ from langchain_core.messages import HumanMessage, SystemMessage # noqa: E402
21
+
22
+ from langgraph.errors import GraphRecursionError # noqa: E402
23
+
24
+ import session # noqa: E402
25
+ import ui # noqa: E402
26
+ from agent import SYSTEM_PROMPT, build_graph # noqa: E402
27
+ from guardrails import check_user_input, redact_message # noqa: E402
28
+ from harness import Harness # noqa: E402
29
+ from llm import DEFAULT_MODEL, KNOWN_MODELS, fetch_openrouter_models, get_llm, resolve_model_arg # noqa: E402
30
+ from mcp_tools import get_git_repo_path, get_git_tools # noqa: E402
31
+ from modes import filter_tools_for_mode, mode_system_note # noqa: E402
32
+ from search import SEARCH_TOOLS, bind_search_root # noqa: E402
33
+ from todos import TODO_TOOLS, TodoStore, bind_todo_store # noqa: E402
34
+ from token_tracker import TokenTracker # noqa: E402
35
+ from tools import LOCAL_TOOLS, bind_harness # noqa: E402
36
+
37
+
38
+ # Module-global todo store, bound to the todo tools once at startup and
39
+ # cleared whenever the session changes (new session, /resume, /clear).
40
+ todo_store = TodoStore()
41
+
42
+
43
+ def system_message_for(mode: str) -> SystemMessage:
44
+ return SystemMessage(content=SYSTEM_PROMPT + mode_system_note(mode))
45
+
46
+
47
+ def set_system_message(messages: list, mode: str) -> None:
48
+ """Ensure a session's first message is the current system prompt, in the
49
+ right mode note, without clobbering a non-system first message."""
50
+ if messages and getattr(messages[0], "type", "") == "system":
51
+ messages[0] = system_message_for(mode)
52
+ else:
53
+ messages.insert(0, system_message_for(mode))
54
+
55
+
56
+ def _looks_like_tool_json(text: str) -> bool:
57
+ """True when a streamed buffer is a tool call the model is writing out as
58
+ JSON (qwen2.5-coder via Ollama does this instead of native tool_calls).
59
+ Such content is re-parsed into a real tool call in agent.py, so we keep
60
+ it out of the chat panel entirely."""
61
+ stripped = text.lstrip()
62
+ return stripped.startswith("{") and '"name"' in stripped and '"arguments"' in stripped
63
+
64
+
65
+ async def run_turn(graph, messages: list, token_tracker: TokenTracker) -> list:
66
+ """Streams one user turn via astream_events. Prints tool calls/results
67
+ as they happen, streams the final text response token-by-token into a
68
+ live-updating panel, and tracks token usage along the way.
69
+
70
+ Also races the event stream against an Esc keypress (watched on a
71
+ background thread by ui.EscListener) so the user can bail out of a turn
72
+ that's stuck, taking too long, or headed somewhere they don't want it
73
+ to go, without killing the whole process.
74
+ """
75
+ ui.print_thinking()
76
+
77
+ live = None
78
+ text_buffer = ""
79
+ final_messages = messages
80
+ turn_start = time.monotonic()
81
+ interrupted = False
82
+
83
+ loop = asyncio.get_running_loop()
84
+ stop_event = asyncio.Event()
85
+ esc = ui.EscListener(loop, stop_event)
86
+ esc.start()
87
+
88
+ agen = graph.astream_events({"messages": messages}, version="v2")
89
+
90
+ try:
91
+ while True:
92
+ next_task = asyncio.ensure_future(agen.__anext__())
93
+ stop_task = asyncio.ensure_future(stop_event.wait())
94
+ try:
95
+ done, _pending = await asyncio.wait(
96
+ {next_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
97
+ )
98
+ except asyncio.CancelledError:
99
+ next_task.cancel()
100
+ stop_task.cancel()
101
+ raise
102
+
103
+ if stop_task in done:
104
+ interrupted = True
105
+ next_task.cancel()
106
+ with contextlib.suppress(asyncio.CancelledError, Exception):
107
+ await next_task
108
+ with contextlib.suppress(Exception):
109
+ await agen.aclose()
110
+ break
111
+
112
+ stop_task.cancel()
113
+ with contextlib.suppress(asyncio.CancelledError):
114
+ await stop_task
115
+
116
+ try:
117
+ event = next_task.result()
118
+ except StopAsyncIteration:
119
+ break
120
+
121
+ kind = event["event"]
122
+
123
+ if kind == "on_chat_model_stream":
124
+ chunk = event["data"]["chunk"]
125
+ if getattr(chunk, "content", None):
126
+ text_buffer += chunk.content
127
+ if _looks_like_tool_json(text_buffer):
128
+ continue
129
+ if live is None:
130
+ live = ui.stream_start()
131
+ ui.stream_update(live, text_buffer)
132
+
133
+ elif kind == "on_chat_model_end":
134
+ output = event["data"].get("output")
135
+ if output is not None:
136
+ token_tracker.add_from_message(output)
137
+ _blocked, reason = redact_message(output)
138
+ if reason:
139
+ ui.print_notice(
140
+ f"Guardrail \u2014 blocked {reason}. The flagged content was "
141
+ "removed from conversation history.",
142
+ style="bold red",
143
+ )
144
+ if live is not None:
145
+ ui.stream_stop(live)
146
+ live = None
147
+ text_buffer = ""
148
+
149
+ elif kind == "on_tool_start":
150
+ ui.print_tool_call(event["name"], event["data"].get("input") or {})
151
+
152
+ elif kind == "on_tool_end":
153
+ output = event["data"].get("output")
154
+ content = getattr(output, "content", None)
155
+ if content is None:
156
+ content = str(output)
157
+ ui.print_tool_result(str(content))
158
+ if event.get("name") == "todo_write":
159
+ ui.print_todos(todo_store.get())
160
+
161
+ elif kind == "on_chain_end" and event.get("name") == "LangGraph":
162
+ output = event["data"].get("output")
163
+ if output and "messages" in output:
164
+ final_messages = output["messages"]
165
+
166
+ except GraphRecursionError:
167
+ # This used to be the visible symptom of the sandbox/workdir bug:
168
+ # write_file silently failing on an absolute-looking path, bash
169
+ # correctly showing an empty dir, and the model retrying the same
170
+ # broken step until the recursion cap kicked in. That root cause is
171
+ # fixed in harness.py now. If this still fires, it means the agent
172
+ # is genuinely stuck in a loop for some other reason (e.g. a
173
+ # command that keeps failing for a real, external reason) — surface
174
+ # that plainly instead of a raw traceback, rather than papering
175
+ # over it by just raising the limit.
176
+ ui.print_notice(
177
+ "Stopped: the agent hit the step limit for this turn without finishing "
178
+ "(likely repeating a failing action). Check the tool calls/results above "
179
+ "for what kept failing, then try again or rephrase the task.",
180
+ style="bold red",
181
+ )
182
+ return messages
183
+ except Exception as e:
184
+ detail = str(e) or repr(e)
185
+ cause = getattr(e, "__cause__", None)
186
+ if cause and str(cause) not in detail:
187
+ detail = f"{detail} (caused by: {cause})"
188
+ ui.print_notice(
189
+ f"Error during this turn [{type(e).__name__}]: {detail}", style="bold red"
190
+ )
191
+ return messages
192
+ finally:
193
+ esc.stop()
194
+ if live is not None:
195
+ ui.stream_stop(live)
196
+
197
+ if interrupted:
198
+ ui.print_notice(
199
+ "Interrupted (Esc) \u2014 turn stopped early; any tool call already in "
200
+ "flight may still finish on its own. History kept up to the last "
201
+ "completed step.",
202
+ style="yellow",
203
+ )
204
+ return final_messages
205
+
206
+ ui.print_turn_complete(time.monotonic() - turn_start)
207
+ return final_messages
208
+
209
+
210
+ def save_key_to_dotenv(key: str) -> None:
211
+ """Persist an API key into .env (creating the file if needed), replacing
212
+ any existing OPENROUTER_API_KEY line. .env is gitignored, so the key
213
+ never lands in version control."""
214
+ path = ".env"
215
+ lines: list[str] = []
216
+ if os.path.exists(path):
217
+ with open(path) as f:
218
+ lines = f.read().splitlines()
219
+ updated = False
220
+ for i, line in enumerate(lines):
221
+ if line.strip().startswith("OPENROUTER_API_KEY="):
222
+ lines[i] = f"OPENROUTER_API_KEY={key}"
223
+ updated = True
224
+ if not updated:
225
+ if lines and lines[-1].strip():
226
+ lines.append("")
227
+ lines.append(f"OPENROUTER_API_KEY={key}")
228
+ with open(path, "w") as f:
229
+ f.write("\n".join(lines) + "\n")
230
+
231
+
232
+ def ensure_api_key() -> str:
233
+ """Make sure an OpenRouter API key is available. If OPENROUTER_API_KEY
234
+ isn't set (env or .env), prompt the user to paste one — hidden input —
235
+ and offer to save it to .env for next time. Exits if no key is given,
236
+ since the agent can't call a model without one."""
237
+ key = os.environ.get("OPENROUTER_API_KEY", "").strip()
238
+ if key:
239
+ return key
240
+ ui.print_notice("No OPENROUTER_API_KEY found in env or .env.", style="yellow")
241
+ key = ui.prompt_api_key()
242
+ if not key:
243
+ ui.print_notice(
244
+ "No API key provided — the agent can't run without one. "
245
+ "Set OPENROUTER_API_KEY and restart.",
246
+ style="bold red",
247
+ )
248
+ raise SystemExit(1)
249
+ os.environ["OPENROUTER_API_KEY"] = key
250
+ if ui.confirm_save_key():
251
+ save_key_to_dotenv(key)
252
+ ui.print_notice("Saved to .env", style="green")
253
+ return key
254
+
255
+
256
+ COMPACT_PROMPT = """You are summarizing a coding-assistant session so work can continue
257
+ in a fresh context window. Write a dense, structured summary covering:
258
+
259
+ 1. What the user was trying to accomplish (the overall goal)
260
+ 2. What was actually done — files created/modified, commands run, key decisions
261
+ 3. Current state — what's working, what's unfinished or broken
262
+ 4. Anything the user explicitly asked to remember, plus useful context
263
+ (paths, model choices, config values) needed to continue seamlessly
264
+
265
+ Be concrete: name files, functions, and decisions. Skip greetings and
266
+ small talk. Write it so another agent could pick up exactly where this
267
+ one left off."""
268
+
269
+
270
+ async def compact_messages(messages: list, model_override: str = None) -> str:
271
+ """Summarize the conversation with the plain LLM (no tools) and return
272
+ the summary text. Long tool outputs are truncated so the summarizer
273
+ itself doesn't blow the context window."""
274
+ llm = get_llm(model_override)
275
+ lines = []
276
+ for m in messages:
277
+ role = getattr(m, "type", "?")
278
+ content = m.content if isinstance(m.content, str) else str(m.content)
279
+ if len(content) > 2000:
280
+ content = content[:2000] + "…[truncated]"
281
+ lines.append(f"[{role}] {content}")
282
+ convo = "\n\n".join(lines)
283
+ summary = await llm.ainvoke(
284
+ [SystemMessage(content=COMPACT_PROMPT), HumanMessage(content=convo)]
285
+ )
286
+ return summary.content if isinstance(summary.content, str) else str(summary.content)
287
+
288
+
289
+ async def main():
290
+ ensure_api_key()
291
+
292
+ workdir = os.environ.get("AGENT_WORKDIR", "./sandbox")
293
+ auto_approve = os.environ.get("AGENT_AUTO_APPROVE", "false").lower() == "true"
294
+ use_git = os.environ.get("AGENT_ENABLE_GIT", "false").lower() == "true"
295
+
296
+ harness = Harness(workdir=workdir, auto_approve=auto_approve, confirm_fn=ui.confirm)
297
+ bind_harness(harness)
298
+ bind_todo_store(todo_store)
299
+ bind_search_root(str(harness.workdir))
300
+
301
+ all_tools = list(LOCAL_TOOLS) + SEARCH_TOOLS + TODO_TOOLS
302
+ if use_git:
303
+ repo_path = get_git_repo_path()
304
+ try:
305
+ git_tools = await get_git_tools(repo_path)
306
+ all_tools.extend(git_tools)
307
+ except Exception as e:
308
+ ui.print_notice(f"Could not load git MCP tools, continuing without them: {e}", style="yellow")
309
+
310
+ mode = "build"
311
+ model_override = None
312
+ token_tracker = TokenTracker()
313
+ # Models shown by the most recent /models listing — /model <number>
314
+ # resolves against this. Starts as the curated shortlist so numbers
315
+ # work even before /models is ever run.
316
+ listed_models: list = list(KNOWN_MODELS)
317
+
318
+ model_name = model_override or os.environ.get("HF_MODEL_ID") or os.environ.get("OLLAMA_MODEL", DEFAULT_MODEL)
319
+
320
+ # Start fresh, or --continue resume the most-recently-used non-empty session
321
+ # from the SQLite store (metadata restores its mode/model too).
322
+ resume = "--continue" in sys.argv
323
+ current_id = session.latest_session_id() if resume else None
324
+ if current_id is not None:
325
+ loaded = session.load(current_id)
326
+ if loaded:
327
+ info = session.get_session(current_id)
328
+ if info is not None:
329
+ mode = info.mode or mode
330
+ if info.model and not model_override:
331
+ model_override = info.model
332
+ model_name = info.model
333
+ ui.print_notice(
334
+ f"Resumed session #{current_id} ({info.name if info else '(unnamed)'}, {len(loaded)} msgs)",
335
+ style="cyan",
336
+ )
337
+ else:
338
+ current_id = None
339
+
340
+ if current_id is None:
341
+ current_id = session.new_session(model=model_name, mode=mode)
342
+ loaded = None
343
+
344
+ messages = list(loaded) if loaded is not None else [system_message_for(mode)]
345
+
346
+ graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
347
+ ui.set_context(mode, model_name)
348
+ ui.print_banner(model_name, str(harness.workdir), [t.name for t in all_tools])
349
+
350
+ while True:
351
+ try:
352
+ raw = ui.user_prompt(mode)
353
+ except (EOFError, KeyboardInterrupt):
354
+ print()
355
+ break
356
+
357
+ if not raw:
358
+ continue
359
+ if raw.lower() in {"exit", "quit"}:
360
+ break
361
+
362
+ if raw.startswith("/"):
363
+ parts = raw[1:].strip().split(maxsplit=1)
364
+ cmd = parts[0].lower() if parts else ""
365
+ arg = parts[1] if len(parts) > 1 else ""
366
+
367
+ if cmd == "plan":
368
+ mode = "plan"
369
+ graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
370
+ set_system_message(messages, mode)
371
+ ui.set_context(mode, model_name)
372
+ ui.print_notice("Switched to PLAN mode \u2014 read-only tools only.", style="magenta")
373
+ elif cmd == "build":
374
+ mode = "build"
375
+ graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
376
+ set_system_message(messages, mode)
377
+ ui.set_context(mode, model_name)
378
+ ui.print_notice("Switched to BUILD mode \u2014 all tools enabled.", style="blue")
379
+ elif cmd == "model":
380
+ if not arg:
381
+ ui.print_notice("Usage: /model <number|model-id> (see /models)", style="yellow")
382
+ else:
383
+ new_model = resolve_model_arg(arg, listed_models)
384
+ if arg.strip().isdigit() and new_model == arg.strip():
385
+ ui.print_notice(
386
+ f"No model #{arg.strip()} in the current list — run /models "
387
+ "first (or pass a full OpenRouter model id).",
388
+ style="yellow",
389
+ )
390
+ else:
391
+ model_override = new_model
392
+ model_name = new_model
393
+ graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
394
+ ui.set_context(mode, model_name)
395
+ ui.print_notice(f"Switched model to {new_model}", style="cyan")
396
+ elif cmd == "models":
397
+ parts = arg.split()
398
+ force = "--refresh" in parts
399
+ query = " ".join(p for p in parts if p != "--refresh").strip().lower()
400
+ ui.print_notice("Fetching model list from OpenRouter…", style="dim")
401
+ models, source = fetch_openrouter_models(force_refresh=force)
402
+ if query:
403
+ models = [m for m in models
404
+ if query in m[0].lower() or query in m[1].lower()]
405
+ if not models:
406
+ ui.print_notice(f"No models match '{query}'.", style="yellow")
407
+ continue
408
+ listed_models = models
409
+ ui.print_models(models, model_name, source=source, query=query or None)
410
+ elif cmd == "key":
411
+ key = ui.prompt_api_key()
412
+ if not key:
413
+ ui.print_notice("No key entered — keeping the current one.", style="yellow")
414
+ else:
415
+ os.environ["OPENROUTER_API_KEY"] = key
416
+ if ui.confirm_save_key():
417
+ save_key_to_dotenv(key)
418
+ ui.print_notice("Saved to .env", style="green")
419
+ graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
420
+ ui.print_notice("API key updated.", style="green")
421
+ elif cmd == "sessions":
422
+ ui.print_sessions(session.list_sessions())
423
+ elif cmd == "resume":
424
+ if not arg:
425
+ ui.print_notice("Usage: /resume <session-id> (see /sessions)", style="yellow")
426
+ continue
427
+ try:
428
+ sid = int(arg)
429
+ except ValueError:
430
+ ui.print_notice("Usage: /resume <session-id> (see /sessions)", style="yellow")
431
+ continue
432
+ info = session.get_session(sid)
433
+ loaded = session.load(sid) if info else None
434
+ if info is None or not loaded:
435
+ ui.print_notice(f"No resumable session #{sid}. See /sessions.", style="yellow")
436
+ continue
437
+ if info.mode:
438
+ mode = info.mode
439
+ if info.model and not model_override:
440
+ model_override = info.model
441
+ model_name = info.model
442
+ graph = build_graph(filter_tools_for_mode(all_tools, mode), model_override)
443
+ current_id = sid
444
+ messages = list(loaded)
445
+ todo_store.clear()
446
+ set_system_message(messages, mode)
447
+ ui.set_context(mode, model_name)
448
+ ui.print_notice(
449
+ f"Resumed session #{sid} ({info.name or '(unnamed)'}, {len(messages)} msgs)",
450
+ style="cyan",
451
+ )
452
+ elif cmd == "delete":
453
+ if not arg:
454
+ ui.print_notice("Usage: /delete <session-id> (see /sessions)", style="yellow")
455
+ continue
456
+ try:
457
+ sid = int(arg)
458
+ except ValueError:
459
+ ui.print_notice("Usage: /delete <session-id> (see /sessions)", style="yellow")
460
+ continue
461
+ if sid == current_id:
462
+ ui.print_notice("Can't delete the active session. Resume a different one first.", style="yellow")
463
+ continue
464
+ if session.delete_session(sid):
465
+ ui.print_notice(f"Deleted session #{sid}.", style="yellow")
466
+ else:
467
+ ui.print_notice(f"No session #{sid}. See /sessions.", style="yellow")
468
+ elif cmd == "usage":
469
+ ui.print_token_usage(token_tracker.summary())
470
+ elif cmd == "clear":
471
+ messages = [system_message_for(mode)]
472
+ todo_store.clear()
473
+ ui.print_notice("History cleared.")
474
+ elif cmd == "compact":
475
+ if len(messages) <= 2:
476
+ ui.print_notice("Nothing to compact yet.", style="yellow")
477
+ else:
478
+ ui.print_notice("Compacting conversation…", style="dim")
479
+ old_count = len(messages)
480
+ try:
481
+ summary = await compact_messages(messages, model_override)
482
+ except Exception as e:
483
+ ui.print_notice(f"Compaction failed: {e}", style="bold red")
484
+ continue
485
+ messages = [
486
+ system_message_for(mode),
487
+ HumanMessage(
488
+ content="[Summary of earlier conversation]\n" + summary
489
+ ),
490
+ ]
491
+ session.save(current_id, messages, model=model_name, mode=mode)
492
+ ui.print_notice(
493
+ f"Compacted {old_count} messages into a summary.",
494
+ style="green",
495
+ )
496
+ elif cmd == "help":
497
+ ui.print_help()
498
+ else:
499
+ ui.print_notice(f"Unknown command: /{cmd} (try /help)", style="yellow")
500
+ continue
501
+
502
+ guard = check_user_input(raw)
503
+ if guard is not None:
504
+ ui.print_notice(f"Guardrail \u2014 {guard}", style="bold red")
505
+ continue
506
+
507
+ messages.append(HumanMessage(content=raw))
508
+ ui.print_user_message(raw)
509
+ messages = await run_turn(graph, messages, token_tracker)
510
+ session.save(current_id, messages, model=model_name, mode=mode)
511
+
512
+
513
+ def cli_entry():
514
+ """Entry point for the `closecode` console script (see pyproject.toml
515
+ [project.scripts]). Installed via pip/pipx, runs the async main()."""
516
+ asyncio.run(main())
517
+
518
+
519
+ if __name__ == "__main__":
520
+ asyncio.run(main())
mcp_tools.py ADDED
@@ -0,0 +1,27 @@
1
+ import os
2
+ from langchain_mcp_adapters.client import MultiServerMCPClient
3
+
4
+
5
+ def build_mcp_client(repo_path: str) -> MultiServerMCPClient:
6
+ return MultiServerMCPClient(
7
+ {
8
+ "git": {
9
+ "command": "python",
10
+ "args": ["-m", "mcp_server_git", "--repository", repo_path],
11
+ "transport": "stdio",
12
+ }
13
+ }
14
+ )
15
+
16
+
17
+ async def get_git_tools(repo_path: str):
18
+ """Returns a list of LangChain-compatible tools backed by mcp-server-git.
19
+ Call this once at startup (it's async — see main.py)."""
20
+ client = build_mcp_client(repo_path)
21
+ return await client.get_tools()
22
+
23
+
24
+ def get_git_repo_path() -> str:
25
+ """Where the git MCP server should operate. Defaults to the same
26
+ directory as the agent's sandbox, but can be pointed elsewhere."""
27
+ return os.environ.get("GIT_REPO_PATH", os.environ.get("AGENT_WORKDIR", "./sandbox"))
modes.py ADDED
@@ -0,0 +1,36 @@
1
+
2
+ PLAN_MODE_BLOCKED_EXACT = {"bash", "write_file", "edit_file"}
3
+ PLAN_MODE_BLOCKED_KEYWORDS = [
4
+ "commit", "push", "checkout", "reset", "merge", "rebase",
5
+ "add", "rm", "delete", "remove", "stash", "write", "create_branch",
6
+ ]
7
+
8
+
9
+ def filter_tools_for_mode(tools: list, mode: str) -> list:
10
+ if mode == "build":
11
+ return list(tools)
12
+
13
+ kept = []
14
+ for t in tools:
15
+ name = t.name.lower()
16
+ if name in PLAN_MODE_BLOCKED_EXACT:
17
+ continue
18
+ if any(keyword in name for keyword in PLAN_MODE_BLOCKED_KEYWORDS):
19
+ continue
20
+ kept.append(t)
21
+ return kept
22
+
23
+
24
+ def mode_system_note(mode: str) -> str:
25
+ if mode == "plan":
26
+ return (
27
+ "\n\nYou are currently in PLAN MODE. Tools that write files, edit files, "
28
+ "run shell commands, or change git state are not available to you right now. "
29
+ "Explore and read what you need, then respond with a clear, concrete, "
30
+ "step-by-step plan of the changes you would make. Do not claim to have made "
31
+ "changes you did not actually make — you can't, in this mode."
32
+ )
33
+ return (
34
+ "\n\nYou are currently in BUILD MODE. You have full access to read, write, edit, "
35
+ "run commands, and make git changes as needed to complete the task."
36
+ )