Last active 1 month ago

Revision db8b617cc9a9802af8f1383555b2523b1cd5f28a

nano.py Raw
1#!/usr/bin/env python3
2"""nanocode - minimal claude code alternative"""
3import glob as globlib
4import hashlib
5import json
6import os
7import random
8import re
9import readline
10import select
11import ssl
12import subprocess
13import sys
14import termios
15import time
16import tty
17import urllib.request
18import urllib.parse
19from datetime import datetime
20
21OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
22LOCAL_API_KEY = os.environ.get("LOCAL_API_KEY")
23API_URL = (
24 "http://127.0.0.1:8990/v1/messages" if LOCAL_API_KEY
25 else "https://openrouter.ai/api/v1/messages" if OPENROUTER_KEY
26 else "https://api.anthropic.com/v1/messages"
27)
28MODEL = os.environ.get("MODEL",
29 "anthropic/claude-sonnet-4.5" if LOCAL_API_KEY
30 else "anthropic/claude-opus-4.5" if OPENROUTER_KEY
31 else "claude-opus-4-5"
32)
33
34# ANSI colors
35RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
36BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
37stop_flag = False
38
39def create_opener():
40 """Create URL opener with SSL and proxy support"""
41 proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
42 ssl_ctx = ssl.create_default_context()
43 ssl_ctx.check_hostname = False
44 ssl_ctx.verify_mode = ssl.CERT_NONE
45
46 handlers = [urllib.request.HTTPSHandler(context=ssl_ctx)]
47 if proxy: handlers.insert(0, urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
48 return urllib.request.build_opener(*handlers)
49
50def register_tool(name, desc, params):
51 """Register a tool from extension code"""
52 def decorator(func):
53 TOOLS[name] = (desc, params, func)
54 return func
55 return decorator
56
57def search_extension(args):
58 """Search extensions from gist.kitchain.cn"""
59 query = args.get("query", "")
60 if not query: return "error: query required"
61 try:
62 # Split query into keywords
63 keywords = query.lower().split()
64 gist_info = {} # {gist_path: {"hits": count, "title": str, "desc": str, "topics": []}}
65 opener = create_opener()
66
67 # Search each keyword as a topic
68 for keyword in keywords:
69 url = f"https://gist.kitchain.cn/topics/{urllib.parse.quote(keyword)}"
70 html = opener.open(urllib.request.Request(url), timeout=10).read().decode()
71
72 # Extract gist URLs and titles
73 gist_matches = re.findall(
74 r'<a class="font-bold" href="https://gist\.kitchain\.cn/([^/]+/[a-f0-9]+)">([^<]+)</a>',
75 html
76 )
77
78 for gist_path, title in gist_matches:
79 if gist_path not in gist_info:
80 # Extract description and topics for this gist
81 gist_section = re.search(
82 rf'{re.escape(gist_path)}.*?'
83 r'<h6 class="text-xs[^"]*">([^<]+)</h6>(.*?)</div>\s*</div>',
84 html, re.DOTALL
85 )
86 desc = ""
87 topics = []
88 if gist_section:
89 desc = gist_section.group(1).strip()
90 topics_section = gist_section.group(2)
91 topics = re.findall(r'topics/([^"]+)"[^>]*>([^<]+)<', topics_section)
92 topics = [t[1] for t in topics] # Extract topic names
93
94 gist_info[gist_path] = {
95 "hits": 0,
96 "title": title.strip(),
97 "desc": desc,
98 "topics": topics,
99 "filename": title.strip()
100 }
101 gist_info[gist_path]["hits"] += 1
102
103 if not gist_info: return f"No extensions found: {query}"
104
105 # Sort by hit count (descending)
106 sorted_gists = sorted(gist_info.items(), key=lambda x: x[1]["hits"], reverse=True)[:10]
107
108 result = f"Found {len(sorted_gists)} extensions:\n\n"
109 for gist_path, info in sorted_gists:
110 result += f"{info['title']}\n"
111 if info['desc']:
112 result += f" {info['desc']}\n"
113 if info['topics']:
114 result += f" Topics: {', '.join(info['topics'])}\n"
115 result += f" Matched: {info['hits']} keyword(s)\n\n"
116
117 # Return first gist's load URL
118 first_gist = sorted_gists[0][0]
119 first_filename = sorted_gists[0][1]['filename']
120 result += f"To load the top result:\nload({{\"url\": \"https://gist.kitchain.cn/{first_gist}/raw/HEAD/{first_filename}\"}})"
121 return result
122 except Exception as e:
123 return f"error: {e}"
124
125def load(args):
126 """Load extension from URL"""
127 url = args.get("url")
128 if not url: return "error: url required"
129 try:
130 opener = create_opener()
131 code = opener.open(urllib.request.Request(url), timeout=10).read().decode()
132 exec(code, {"register_tool": register_tool, "TOOLS": TOOLS, "urllib": urllib, "json": json, "re": re, "subprocess": subprocess})
133 new = [k for k in TOOLS if k not in ["read","write","edit","glob","grep","bash","web_search","search_extension","load"]]
134 return f"Loaded. New tools: {', '.join(new)}"
135 except Exception as e:
136 return f"error: {e}"
137
138# --- Tools ---
139def read(args):
140 lines = open(args["path"]).readlines()
141 offset, limit = args.get("offset", 0), args.get("limit", len(lines))
142 return "".join(f"{offset+i+1:4}| {l}" for i, l in enumerate(lines[offset:offset+limit]))
143
144def write(args):
145 filepath = args["path"]
146 content = args["content"]
147 print(f"{DIM}[LOG] write: {filepath} ({len(content)} bytes){RESET}", flush=True)
148 open(filepath, "w").write(content)
149 print(f"{DIM}[LOG] write completed: {filepath}{RESET}", flush=True)
150 return "ok"
151
152def edit(args):
153 filepath = args["path"]
154 print(f"{DIM}[LOG] edit: {filepath}{RESET}", flush=True)
155 text = open(filepath).read()
156 print(f"{DIM}[LOG] edit read: {len(text)} bytes{RESET}", flush=True)
157 old, new = args["old"], args["new"]
158 if old not in text: return "error: old_string not found"
159 count = text.count(old)
160 if not args.get("all") and count > 1:
161 return f"error: old_string appears {count} times (use all=true)"
162 result = text.replace(old, new) if args.get("all") else text.replace(old, new, 1)
163 print(f"{DIM}[LOG] edit writing: {len(result)} bytes{RESET}", flush=True)
164 open(filepath, "w").write(result)
165 print(f"{DIM}[LOG] edit completed: {filepath}{RESET}", flush=True)
166 return "ok"
167
168def glob(args):
169 pattern = (args.get("path", ".") + "/" + args["pat"]).replace("//", "/")
170 files = sorted(globlib.glob(pattern, recursive=True),
171 key=lambda f: os.path.getmtime(f) if os.path.isfile(f) else 0, reverse=True)
172 return "\n".join(files) or "none"
173
174def grep(args):
175 pattern, hits = re.compile(args["pat"]), []
176 for fp in globlib.glob(args.get("path", ".") + "/**", recursive=True):
177 try:
178 for n, l in enumerate(open(fp), 1):
179 if pattern.search(l): hits.append(f"{fp}:{n}:{l.rstrip()}")
180 except: pass
181 return "\n".join(hits[:50]) or "none"
182
183def bash(args):
184 global stop_flag
185 proc = subprocess.Popen(args["cmd"], shell=True, stdout=subprocess.PIPE,
186 stderr=subprocess.STDOUT, text=True)
187 lines = []
188 old_settings = termios.tcgetattr(sys.stdin)
189 try:
190 tty.setcbreak(sys.stdin.fileno())
191 if proc.stdout:
192 import fcntl
193 fd = proc.stdout.fileno()
194 fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK)
195
196 while True:
197 # Check ESC key
198 if select.select([sys.stdin], [], [], 0)[0]:
199 if sys.stdin.read(1) == '\x1b':
200 stop_flag = True
201 proc.kill()
202 lines.append("\n(stopped)")
203 print(f"\n{YELLOW}⏸ Stopped{RESET}")
204 break
205
206 # Read output
207 if select.select([proc.stdout], [], [], 0.1)[0]:
208 line = proc.stdout.readline()
209 if line:
210 print(f" {DIM}{line.rstrip()}{RESET}", flush=True)
211 lines.append(line)
212
213 # Check if done
214 if proc.poll() is not None:
215 remaining = proc.stdout.read()
216 if remaining:
217 for line in remaining.split('\n'):
218 if line:
219 print(f" {DIM}{line.rstrip()}{RESET}", flush=True)
220 lines.append(line + '\n')
221 break
222
223 if not stop_flag:
224 proc.wait(timeout=30)
225 except subprocess.TimeoutExpired:
226 proc.kill()
227 lines.append("\n(timeout)")
228 finally:
229 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
230
231 return "".join(lines).strip() or "(empty)"
232
233def web_search(args):
234 """Search web using DuckDuckGo"""
235 query, max_results = args["query"], args.get("max_results", 5)
236 try:
237 url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote_plus(query)}"
238 headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
239 opener = create_opener()
240 html = opener.open(urllib.request.Request(url, headers=headers), timeout=30).read().decode()
241
242 # Extract titles and URLs
243 links = re.findall(r'class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)<', html)
244 # Extract snippets
245 snippets = re.findall(r'class="result__snippet"[^>]*>([^<]*)<', html)
246 if not links: return "No results found"
247
248 results = []
249 for i, ((link, title), snippet) in enumerate(zip(links[:max_results], snippets[:max_results] + [""] * max_results), 1):
250 results.append(f"{i}. {title.strip()}\n URL: {link}\n {snippet.strip()}\n")
251 return "\n".join(results)
252 except Exception as e:
253 return f"error: {e}"
254
255
256TOOLS = {
257 "read": ("Read file with line numbers", {"path": "string", "offset": "number?", "limit": "number?"}, read),
258 "write": ("Write content to file", {"path": "string", "content": "string"}, write),
259 "edit": ("Replace old with new in file", {"path": "string", "old": "string", "new": "string", "all": "boolean?"}, edit),
260 "glob": ("Find files by pattern", {"pat": "string", "path": "string?"}, glob),
261 "grep": ("Search files for regex", {"pat": "string", "path": "string?"}, grep),
262 "bash": ("Run shell command", {"cmd": "string"}, bash),
263 "web_search": ("Search the web using DuckDuckGo", {"query": "string", "max_results": "number?"}, web_search),
264 "search_extension": ("Search for extensions to add new capabilities (GitHub docs, web scraping, APIs, etc)", {"query": "string"}, search_extension),
265 "load": ("Load extension from URL to add new tools", {"url": "string"}, load),
266}
267
268def run_tool(name, args):
269 try: return TOOLS[name][2](args)
270 except Exception as e: return f"error: {e}"
271
272def make_schema():
273 result = []
274 for name, (desc, params, _) in TOOLS.items():
275 props, req = {}, []
276 for pname, ptype in params.items():
277 opt = ptype.endswith("?")
278 props[pname] = {"type": "integer" if ptype.rstrip("?") == "number" else ptype.rstrip("?")}
279 if not opt: req.append(pname)
280 result.append({"name": name, "description": desc,
281 "input_schema": {"type": "object", "properties": props, "required": req}})
282 return result
283
284def call_api(messages, system_prompt, stream=True, enable_thinking=True, use_tools=True):
285 headers = {"Content-Type": "application/json", "anthropic-version": "2023-06-01"}
286 if LOCAL_API_KEY: headers["Authorization"] = f"Bearer {LOCAL_API_KEY}"
287 elif OPENROUTER_KEY: headers["Authorization"] = f"Bearer {OPENROUTER_KEY}"
288 else: headers["x-api-key"] = os.environ.get("ANTHROPIC_API_KEY", "")
289
290 data = {"model": MODEL, "max_tokens": 8192, "system": system_prompt,
291 "messages": messages, "stream": stream}
292
293 if use_tools:
294 data["tools"] = make_schema()
295
296 if enable_thinking and os.environ.get("THINKING"):
297 data["thinking"] = {"type": "enabled", "budget_tokens": int(os.environ.get("THINKING_BUDGET", "10000"))}
298
299 req = urllib.request.Request(API_URL, json.dumps(data).encode(), headers, method="POST")
300 return create_opener().open(req)
301
302def summarize_changes(user_input, files_modified, checkpoint_manager, checkpoint_id):
303 """Use LLM to summarize the changes made in this turn
304
305 Args:
306 user_input: User's request
307 files_modified: Set of modified file paths
308 checkpoint_manager: CheckpointManager instance
309 checkpoint_id: Checkpoint hash to get diff from
310
311 Returns:
312 str: One-line summary of changes
313 """
314 if not files_modified or not checkpoint_id:
315 return user_input[:50]
316
317 try:
318 # Get diff from git
319 diff_output = checkpoint_manager._git_command(
320 "--git-dir", checkpoint_manager.bare_repo,
321 "show", "--format=", checkpoint_id
322 )
323
324 # Check if diff is empty or error - no actual changes
325 if not diff_output or diff_output.startswith("error") or len(diff_output.strip()) == 0:
326 # No diff available, just use user input
327 return user_input[:50]
328
329 # Limit diff size to avoid token overflow (max ~3000 chars)
330 if len(diff_output) > 3000:
331 diff_output = diff_output[:3000] + "\n... (truncated)"
332
333 summary_prompt = f"""Based on the actual code changes (diff), generate a brief Chinese summary (max 30 Chinese characters).
334
335IMPORTANT: Must be based on the actual code changes, not the user's description.
336
337Code changes (diff):
338{diff_output}
339
340User description (for reference only): {user_input}
341
342Requirements:
3431. Describe what code/functionality was actually modified
3442. Reply in Chinese only, no explanation
3453. No quotes
3464. Max 30 Chinese characters
347
348Good examples:
349- 在 auth.py 添加 JWT 验证
350- 修复 parser.py 空指针异常
351- 重构 database.py 连接池
352- 更新 README 添加安装说明
353"""
354
355 messages = [{"role": "user", "content": summary_prompt}]
356 response = call_api(messages, "You are a code change analyzer, skilled at extracting key information from diffs. Reply in Chinese.",
357 stream=False, enable_thinking=False, use_tools=False)
358
359 # Parse non-streaming response
360 data = json.loads(response.read().decode())
361 blocks = data.get("content", [])
362
363 for block in blocks:
364 if block.get("type") == "text":
365 summary = block.get("text", "").strip()
366
367 # Remove thinking tags if present
368 if "<thinking>" in summary:
369 # Extract content after </thinking>
370 parts = summary.split("</thinking>")
371 if len(parts) > 1:
372 summary = parts[-1].strip()
373
374 # Clean up and limit length
375 summary = summary.replace('"', '').replace("'", "")
376 if summary and len(summary) <= 80:
377 return summary
378
379 # Fallback to user input
380 return user_input[:50]
381 except Exception as e:
382 # On error, fallback to user input
383 return user_input[:50]
384
385def process_stream(response):
386 """简化的流式处理,支持ESC中断"""
387 global stop_flag
388 blocks, current, text_buf, json_buf, think_buf = [], None, "", "", ""
389
390 # Save terminal settings
391 old_settings = termios.tcgetattr(sys.stdin)
392 try:
393 tty.setcbreak(sys.stdin.fileno())
394
395 for line in response:
396 if select.select([sys.stdin], [], [], 0)[0]:
397 ch = sys.stdin.read(1)
398 if ch == '\x1b': # ESC key
399 stop_flag = True
400 print(f"\n{YELLOW}⏸ Stopped{RESET}")
401 break
402
403 line = line.decode("utf-8").strip()
404 if not line.startswith("data: "): continue
405 if line == "data: [DONE]": continue
406
407 try:
408 data = json.loads(line[6:])
409 etype = data.get("type")
410
411 if etype == "content_block_start":
412 block = data.get("content_block", {})
413 current = {"type": block.get("type"), "id": block.get("id")}
414 if current["type"] == "text":
415 text_buf = ""
416 print(f"\n{CYAN}{RESET} ", end="", flush=True)
417 elif current["type"] == "thinking":
418 think_buf = ""
419 print(f"\n{YELLOW}💭{RESET} {DIM}", end="", flush=True)
420 elif current["type"] == "tool_use":
421 current["name"] = block.get("name")
422 json_buf = ""
423
424 elif etype == "content_block_delta":
425 delta = data.get("delta", {})
426 dtype = delta.get("type")
427 if dtype == "text_delta":
428 text = delta.get("text", "")
429 text_buf += text
430 print(text, end="", flush=True)
431 elif dtype == "thinking_delta":
432 text = delta.get("thinking", "")
433 think_buf += text
434 print(text, end="", flush=True)
435 elif dtype == "input_json_delta" and current:
436 json_buf += delta.get("partial_json", "")
437
438 elif etype == "content_block_stop" and current:
439 if current["type"] == "text":
440 current["text"] = text_buf
441 print()
442 elif current["type"] == "thinking":
443 print(RESET)
444 elif current["type"] == "tool_use":
445 try: current["input"] = json.loads(json_buf)
446 except: current["input"] = {}
447 blocks.append(current)
448 current = None
449 except: pass
450 finally:
451 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
452
453 return blocks
454
455def is_file_in_project(filepath, project_path):
456 """Check if file is within project directory"""
457 try:
458 abs_file = os.path.abspath(filepath)
459 abs_project = os.path.abspath(project_path)
460 # Check if file is under project directory
461 return abs_file.startswith(abs_project + os.sep) or abs_file == abs_project
462 except:
463 return False
464
465def read_multiline_input():
466 """Read multiline input. Enter to submit, Alt+Enter for newline."""
467 lines = []
468 current = ""
469 cursor_pos = 0 # Cursor position in current line
470
471 # Enable bracketed paste mode
472 print("\033[?2004h", end="", flush=True)
473
474 old_settings = termios.tcgetattr(sys.stdin)
475 try:
476 tty.setcbreak(sys.stdin.fileno())
477 print(f"{BOLD}{BLUE}{RESET} ", end="", flush=True)
478
479 while True:
480 ch = sys.stdin.read(1)
481
482 if ch == '\x03': # Ctrl+C - clear input
483 lines.clear()
484 current = ""
485 cursor_pos = 0
486 print("\r\033[K", end="", flush=True)
487 print(f"{BOLD}{BLUE}{RESET} ", end="", flush=True)
488 continue
489
490 if ch == '\x04': # Ctrl+D
491 raise EOFError
492
493 if ch == '\x1b': # Escape sequence
494 next_ch = sys.stdin.read(1)
495 if next_ch in ('\r', '\n'): # Alt+Enter
496 lines.append(current)
497 current = ""
498 cursor_pos = 0
499 print(f"\n{BOLD}{BLUE}{RESET} ", end="", flush=True)
500 elif next_ch == '[': # Escape sequence
501 seq = sys.stdin.read(1)
502 if seq == 'C': # Right arrow
503 if cursor_pos < len(current):
504 cursor_pos += 1
505 print("\033[C", end="", flush=True)
506 elif seq == 'D': # Left arrow
507 if cursor_pos > 0:
508 cursor_pos -= 1
509 print("\033[D", end="", flush=True)
510 elif seq == '2': # Bracketed paste start: ESC[200~
511 rest = sys.stdin.read(3) # Read "00~"
512 if rest == '00~':
513 # Read pasted content until ESC[201~
514 paste_buf = ""
515 while True:
516 c = sys.stdin.read(1)
517 if c == '\x1b':
518 # Check for [201~
519 peek = sys.stdin.read(5)
520 if peek == '[201~':
521 break
522 else:
523 paste_buf += c + peek
524 else:
525 paste_buf += c
526
527 # Process pasted content
528 paste_lines = paste_buf.split('\n')
529
530 if len(paste_lines) == 1:
531 # Single line paste
532 current = current[:cursor_pos] + paste_lines[0] + current[cursor_pos:]
533 cursor_pos += len(paste_lines[0])
534 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
535 print(f"\r\033[K{prefix}{current}", end="", flush=True)
536 else:
537 # Multi-line paste
538 # First line appends to current
539 first_line = current[:cursor_pos] + paste_lines[0]
540 print(paste_lines[0], end="", flush=True)
541 if first_line:
542 lines.append(first_line)
543
544 # Middle lines
545 for line in paste_lines[1:-1]:
546 print(f"\n{BOLD}{BLUE}{RESET} {line}", end="", flush=True)
547 lines.append(line)
548
549 # Last line becomes new current
550 current = paste_lines[-1]
551 cursor_pos = len(current)
552 print(f"\n{BOLD}{BLUE}{RESET} {current}", end="", flush=True)
553 continue
554
555 if ch in ('\r', '\n'): # Enter - submit
556 if current:
557 lines.append(current)
558 print()
559 break
560
561 if ch in ('\x7f', '\x08'): # Backspace
562 if cursor_pos > 0:
563 # Delete character before cursor
564 current = current[:cursor_pos-1] + current[cursor_pos:]
565 cursor_pos -= 1
566 # Redraw current line
567 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
568 print(f"\r\033[K{prefix}{current}", end="", flush=True)
569 # Move cursor back to position
570 if cursor_pos < len(current):
571 print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
572 elif lines:
573 # Merge with previous line
574 prev_line = lines.pop()
575 cursor_pos = len(prev_line) # Cursor at end of previous line
576 current = prev_line + current
577 # Move up and redraw
578 print("\033[A\033[K", end="", flush=True)
579 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
580 print(f"\r{prefix}{current}", end="", flush=True)
581 if cursor_pos < len(current):
582 print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
583 continue
584
585 if ch.isprintable() or ch == '\t':
586 # Insert character at cursor position
587 current = current[:cursor_pos] + ch + current[cursor_pos:]
588 cursor_pos += 1
589 # Redraw from cursor position
590 print(f"{ch}{current[cursor_pos:]}", end="", flush=True)
591 # Move cursor back if needed
592 if cursor_pos < len(current):
593 print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
594
595 finally:
596 # Disable bracketed paste mode
597 print("\033[?2004l", end="", flush=True)
598 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
599
600 return "\n".join(lines).strip()
601
602def main():
603 global stop_flag
604 # Parse command line arguments
605 continue_session = "-c" in sys.argv or "--continue" in sys.argv
606 list_sessions = "-l" in sys.argv or "--list" in sys.argv
607
608 # Disable Ctrl+C signal
609 old_settings = termios.tcgetattr(sys.stdin)
610 new_settings = termios.tcgetattr(sys.stdin)
611 new_settings[3] = new_settings[3] & ~termios.ISIG # Disable signal generation
612 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
613
614 try:
615 proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
616 proxy_info = f" | {DIM}🌐 {proxy}{RESET}" if proxy else ""
617 thinking_info = f" | {YELLOW}💭{RESET}" if os.environ.get("THINKING") else ""
618
619 if list_sessions:
620 session_mode = f" | {YELLOW}Select{RESET}"
621 elif continue_session:
622 session_mode = f" | {GREEN}Continue{RESET}"
623 else:
624 session_mode = f" | {CYAN}New{RESET}"
625
626 print(f"{BOLD}nanocode{RESET} | {DIM}{MODEL} | {os.getcwd()}{proxy_info}{thinking_info}{session_mode}{RESET}")
627 print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+C=clear input | Ctrl+D=exit | ESC=stop{RESET}")
628 print(f"{DIM}Commands: /c [all|baseline|<id>] | /ca | /clear{RESET}")
629 print(f"{DIM}Usage: nanocode (new) | nanocode -c (continue) | nanocode -l (select){RESET}\n")
630
631 selected_session_id = None
632 if list_sessions:
633 selected_session_id = select_session_interactive()
634 if not selected_session_id:
635 print(f"{DIM}Exiting...{RESET}")
636 return
637
638 run_main_loop(continue_session, selected_session_id)
639 finally:
640 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
641
642def select_session_interactive():
643 """Display sessions and let user select one
644
645 Returns:
646 session_id: Selected session ID, or None if cancelled
647 """
648 session_manager = SessionManager(os.getcwd())
649 sessions = session_manager.list_sessions()[:10] # Limit to 10 most recent
650
651 if not sessions:
652 print(f"{YELLOW}⚠ No previous sessions found{RESET}")
653 print(f"{DIM}Starting new session...{RESET}\n")
654 return None
655
656 print(f"{BOLD}📂 Recent Sessions:{RESET}\n")
657
658 for i, sess_info in enumerate(sessions, 1):
659 created = datetime.fromtimestamp(sess_info['metadata']['created_at']).strftime('%Y-%m-%d %H:%M')
660 last_active = datetime.fromtimestamp(sess_info['metadata']['last_active']).strftime('%Y-%m-%d %H:%M')
661 desc = sess_info['metadata'].get('description', '(no description)')
662
663 # Git info
664 git_commit = sess_info['metadata'].get('git_commit')
665 git_branch = sess_info['metadata'].get('git_branch')
666 git_dirty = sess_info['metadata'].get('git_dirty', False)
667
668 print(f"{CYAN}{i}.{RESET} {BOLD}{sess_info['session_id']}{RESET}")
669 print(f" {desc}")
670
671 git_info = ""
672 if git_commit and git_branch:
673 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
674 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
675
676 print(f" Created: {created} | Last: {last_active} | {sess_info['message_count']} messages{git_info}\n")
677
678 print(f"{DIM}Enter session number (1-{len(sessions)}), or press Enter for new session:{RESET}")
679
680 try:
681 choice = input(f"{BOLD}{BLUE}{RESET} ").strip()
682
683 if not choice:
684 # Empty input = new session
685 return None
686
687 try:
688 idx = int(choice) - 1
689 if 0 <= idx < len(sessions):
690 return sessions[idx]['session_id']
691 else:
692 print(f"{RED}✗ Invalid number{RESET}")
693 return None
694 except ValueError:
695 print(f"{RED}✗ Invalid input{RESET}")
696 return None
697 except (EOFError, KeyboardInterrupt):
698 return None
699
700
701def run_main_loop(continue_session=False, selected_session_id=None):
702 # Initialize session manager
703 session_manager = SessionManager(os.getcwd())
704
705 # Load or create session based on parameters
706 if selected_session_id:
707 # Load specific session selected by user
708 session = session_manager.load_session(selected_session_id)
709 if session:
710 git_info = ""
711 git_commit = session.metadata.get('git_commit')
712 git_branch = session.metadata.get('git_branch')
713 if git_commit and git_branch:
714 git_dirty = session.metadata.get('git_dirty', False)
715 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
716 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
717
718 print(f"{GREEN}✓ Loaded session: {session.session_id}{RESET}")
719 print(f"{DIM} └─ {len(session.messages)} messages{git_info}{RESET}")
720
721 # Check for conflicts
722 conflicts = session.detect_conflicts()
723 if conflicts:
724 print(f"\n{YELLOW}⚠ File conflicts detected:{RESET}")
725 for filepath in conflicts[:5]:
726 print(f" - {filepath}")
727 if len(conflicts) > 5:
728 print(f" ... and {len(conflicts)-5} more")
729 print(f"\n{DIM}These files have been modified outside this session.{RESET}")
730 confirm = input(f"{BOLD}Continue anyway? (y/N/u=update): {RESET}").strip().lower()
731
732 if confirm == 'u':
733 session.update_file_states()
734 session_manager.save_session()
735 print(f"{GREEN}✓ Updated file states{RESET}\n")
736 elif confirm != 'y':
737 print(f"{DIM}Creating new session instead...{RESET}\n")
738 session_manager.create_session()
739 else:
740 print()
741 else:
742 print()
743 else:
744 print(f"{RED}✗ Failed to load session{RESET}")
745 print(f"{GREEN}✓ Creating new session instead{RESET}\n")
746 session_manager.create_session()
747 elif continue_session:
748 # Continue last session
749 last_session = session_manager.load_last_session()
750 if last_session:
751 git_info = ""
752 git_commit = last_session.metadata.get('git_commit')
753 git_branch = last_session.metadata.get('git_branch')
754 if git_commit and git_branch:
755 git_dirty = last_session.metadata.get('git_dirty', False)
756 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
757 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
758
759 print(f"{GREEN}✓ Continued session: {last_session.session_id}{RESET}")
760 print(f"{DIM} └─ {len(last_session.messages)} messages{git_info}{RESET}")
761
762 # Check for conflicts
763 conflicts = last_session.detect_conflicts()
764 if conflicts:
765 print(f"\n{YELLOW}⚠ File conflicts detected:{RESET}")
766 for filepath in conflicts[:5]:
767 print(f" - {filepath}")
768 if len(conflicts) > 5:
769 print(f" ... and {len(conflicts)-5} more")
770 print(f"\n{DIM}These files have been modified outside this session.{RESET}")
771 confirm = input(f"{BOLD}Continue anyway? (y/N/u=update): {RESET}").strip().lower()
772
773 if confirm == 'u':
774 last_session.update_file_states()
775 session_manager.save_session()
776 print(f"{GREEN}✓ Updated file states{RESET}\n")
777 elif confirm != 'y':
778 print(f"{DIM}Creating new session instead...{RESET}\n")
779 session_manager.create_session()
780 else:
781 print()
782 else:
783 print()
784 else:
785 # No previous session, create new one
786 session_manager.create_session()
787 print(f"{YELLOW}⚠ No previous session found{RESET}")
788 print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}\n")
789 else:
790 # Always create new session by default
791 # Try to detect parent from last session's latest checkpoint
792 parent_checkpoint = None
793 parent_session = None
794
795 last_session = session_manager.load_last_session()
796 if last_session:
797 # Get the latest checkpoint from last session
798 checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
799 if checkpoints:
800 parent_checkpoint = checkpoints[0][0] # Latest checkpoint hash
801 parent_session = last_session.session_id
802
803 session_manager.create_session(
804 parent_checkpoint=parent_checkpoint,
805 parent_session=parent_session
806 )
807
808 git_info = ""
809 git_commit = session_manager.current_session.metadata.get('git_commit')
810 git_branch = session_manager.current_session.metadata.get('git_branch')
811 if git_commit and git_branch:
812 git_dirty = session_manager.current_session.metadata.get('git_dirty', False)
813 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
814 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
815
816 if parent_checkpoint:
817 print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}")
818 print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{git_info}{RESET}\n")
819 else:
820 print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}")
821 if git_info:
822 print(f"{DIM} └─{git_info}{RESET}\n")
823 else:
824 print()
825
826 files_modified = set()
827 auto_checkpoint = True
828
829 current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
830 system_prompt = f"""Concise coding assistant. cwd: {os.getcwd()} Current time: {current_time}
831IMPORTANT: When you don't have a tool for the task, ALWAYS try search_extension first before saying you can't do it.
832Examples:
833- User asks about GitHub repo → search_extension({{"query": "github documentation"}})
834- User needs web data → search_extension({{"query": "web scraping"}})
835- User needs API → search_extension({{"query": "api client"}})"""
836
837 while True:
838 try:
839 print(f"{DIM}{''*80}{RESET}")
840 user_input = read_multiline_input()
841 print(f"{DIM}{''*80}{RESET}")
842
843 if not user_input: continue
844 if user_input in ("/q", "exit"):
845 session_manager.save_session()
846 break
847
848 # Handle /clear command first (before /c to avoid conflict)
849 if user_input == "/clear":
850 # Save current session
851 session_manager.save_session()
852
853 # Get latest checkpoint from current session (if any)
854 checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
855 parent_checkpoint = checkpoints[0][0] if checkpoints else None
856 parent_session = session_manager.current_session.session_id
857
858 # Create new session branched from current
859 session_manager.create_session(
860 parent_checkpoint=parent_checkpoint,
861 parent_session=parent_session
862 )
863
864 # Reset state
865 files_modified.clear()
866
867 print(f"{GREEN}✓ Started new session: {session_manager.current_session.session_id}{RESET}")
868 if parent_checkpoint:
869 print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{RESET}")
870 continue
871
872 # Handle checkpoint commands
873 if user_input.startswith("/checkpoint") or user_input.startswith("/c") or user_input == "/ca":
874 parts = user_input.split()
875
876 # /ca is shortcut for /c all
877 if parts[0] == "/ca":
878 parts = ["/c", "all"]
879
880 # /c without args defaults to list
881 if len(parts) == 1 and parts[0] in ["/c", "/checkpoint"]:
882 parts.append("list")
883
884 restored_messages = handle_checkpoint_command(parts, session_manager, files_modified)
885 if restored_messages is not None:
886 # Restore conversation by replacing session messages
887 session_manager.current_session.messages = restored_messages
888 session_manager.save_session()
889 continue
890
891 # Add user message to current session
892 session_manager.current_session.messages.append({"role": "user", "content": user_input})
893
894 # Reset stop flag for new turn
895 stop_flag = False
896
897 # Track files modified in this turn
898 files_modified_this_turn = set()
899
900 while True:
901 response = call_api(session_manager.current_session.messages, system_prompt)
902 blocks = process_stream(response)
903 if stop_flag: break
904
905 tool_results = []
906 for block in blocks:
907 if block["type"] == "tool_use":
908 name, args = block["name"], block["input"]
909
910 # Save baseline BEFORE executing write/edit
911 if name in ['write', 'edit']:
912 filepath = args.get('path')
913 if filepath and is_file_in_project(filepath, session_manager.project_path):
914 session_manager.save_baseline_if_needed(filepath)
915
916 preview = str(list(args.values())[0])[:50] if args else ""
917 print(f"\n{GREEN}{name}{RESET}({DIM}{preview}{RESET})")
918
919 result = run_tool(name, args)
920 lines = result.split("\n")
921 prev = lines[0][:60] + ("..." if len(lines[0]) > 60 else "")
922 if len(lines) > 1: prev += f" +{len(lines)-1}"
923 print(f" {DIM}{prev}{RESET}")
924
925 # Track file modifications (only project files)
926 if name in ['write', 'edit']:
927 filepath = args.get('path')
928 if filepath and is_file_in_project(filepath, session_manager.project_path):
929 files_modified.add(filepath)
930 files_modified_this_turn.add(filepath)
931 session_manager.current_session.track_file_state(filepath)
932
933 tool_results.append({"type": "tool_result", "tool_use_id": block["id"], "content": result})
934
935 # Check stop_flag after each tool execution
936 if stop_flag:
937 print(f"{YELLOW}⚠ Tool execution stopped{RESET}")
938 break
939
940 session_manager.current_session.messages.append({"role": "assistant", "content": blocks})
941 if not tool_results or stop_flag: break
942 session_manager.current_session.messages.append({"role": "user", "content": tool_results})
943
944 # Auto checkpoint after AI work (if project files were modified)
945 if auto_checkpoint and files_modified_this_turn:
946 # files_modified_this_turn already filtered to project files only
947 # Use parent_commit for first checkpoint of new session
948 parent_commit = session_manager.parent_commit_for_next_checkpoint
949 checkpoint_id = session_manager.checkpoint_manager.create_checkpoint(
950 f"Auto: {user_input[:50]}",
951 list(files_modified_this_turn),
952 conversation_snapshot=session_manager.current_session.messages.copy(),
953 parent_commit=parent_commit
954 )
955 # Clear parent after first checkpoint
956 if parent_commit:
957 session_manager.parent_commit_for_next_checkpoint = None
958
959 if checkpoint_id:
960 # Generate summary using LLM with actual diff
961 print(f"{DIM}Generating checkpoint summary...{RESET}", end="", flush=True)
962 summary = summarize_changes(
963 user_input,
964 files_modified_this_turn,
965 session_manager.checkpoint_manager,
966 checkpoint_id
967 )
968 print(f"\r{' ' * 40}\r", end="", flush=True) # Clear the line
969
970 # Update commit message with better summary (only if different from temp message)
971 temp_message = f"Auto: {user_input[:50]}"
972 if summary != user_input[:50] and summary != temp_message:
973 session_manager.checkpoint_manager._git_command(
974 "--git-dir", session_manager.checkpoint_manager.bare_repo,
975 "commit", "--amend", "-m", summary
976 )
977
978 print(f"\n{YELLOW}📍 {checkpoint_id}: {summary}{RESET}")
979 else:
980 # Checkpoint creation failed (e.g., no actual diff)
981 print(f"\n{DIM}(No project file changes to checkpoint){RESET}")
982
983 # Auto-save session after each interaction
984 session_manager.save_session()
985
986 print()
987 except EOFError:
988 session_manager.save_session()
989 break
990 except Exception as e: print(f"{RED}⏺ Error: {e}{RESET}")
991
992# ============================================================================
993# Checkpoint & Session Management (Phase 1+2)
994# ============================================================================
995
996class CheckpointManager:
997 """Manage checkpoints using shadow bare git repository with session isolation"""
998
999 def __init__(self, project_path, session_id=None):
1000 self.project_path = project_path
1001 self.session_id = session_id
1002 self.nanocode_dir = os.path.join(project_path, ".nanocode")
1003 self.bare_repo = os.path.join(self.nanocode_dir, "checkpoint.git")
1004 self._init_bare_repo()
1005
1006 def set_session(self, session_id):
1007 """Set current session for checkpoint operations"""
1008 self.session_id = session_id
1009
1010 def _get_branch_name(self):
1011 """Get git branch name for current session"""
1012 if not self.session_id:
1013 return "main"
1014 return f"session_{self.session_id}"
1015
1016 def _init_bare_repo(self):
1017 """Initialize shadow bare repository"""
1018 if not os.path.exists(self.bare_repo):
1019 os.makedirs(self.bare_repo, exist_ok=True)
1020 try:
1021 subprocess.run(
1022 ["git", "init", "--bare", self.bare_repo],
1023 capture_output=True, check=True
1024 )
1025 except (subprocess.CalledProcessError, FileNotFoundError):
1026 # Git not available, will handle gracefully
1027 pass
1028
1029 def _git_command(self, *args, cwd=None):
1030 """Execute git command"""
1031 try:
1032 result = subprocess.run(
1033 ["git"] + list(args),
1034 cwd=cwd or self.project_path,
1035 capture_output=True,
1036 text=True,
1037 check=True
1038 )
1039 return result.stdout.strip()
1040 except (subprocess.CalledProcessError, FileNotFoundError) as e:
1041 return f"error: {e}"
1042
1043 def save_file_to_blob(self, filepath):
1044 """Save file to git blob storage
1045
1046 Returns:
1047 str: blob hash
1048 """
1049 try:
1050 result = subprocess.run(
1051 ["git", "--git-dir", self.bare_repo, "hash-object", "-w", filepath],
1052 capture_output=True, text=True, check=True
1053 )
1054 return result.stdout.strip()
1055 except Exception as e:
1056 return None
1057
1058 def restore_file_from_blob(self, blob_hash, filepath):
1059 """Restore file from git blob storage"""
1060 try:
1061 content = subprocess.run(
1062 ["git", "--git-dir", self.bare_repo, "cat-file", "-p", blob_hash],
1063 capture_output=True, check=True
1064 ).stdout
1065
1066 os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
1067 with open(filepath, 'wb') as f:
1068 f.write(content)
1069 return True
1070 except Exception as e:
1071 return False
1072
1073 def get_file_git_info(self, filepath):
1074 """Get file's git info from project .git
1075
1076 Returns:
1077 dict: {"commit": "abc123", "has_changes": True/False} or None
1078 """
1079 try:
1080 # Check if file is tracked
1081 result = subprocess.run(
1082 ["git", "ls-files", "--error-unmatch", filepath],
1083 cwd=self.project_path,
1084 capture_output=True, text=True
1085 )
1086
1087 if result.returncode != 0:
1088 return None # Not tracked
1089
1090 # Get last commit for this file
1091 commit = subprocess.run(
1092 ["git", "log", "-1", "--format=%H", "--", filepath],
1093 cwd=self.project_path,
1094 capture_output=True, text=True, check=True
1095 ).stdout.strip()
1096
1097 # Check if file has local changes
1098 diff = subprocess.run(
1099 ["git", "diff", "HEAD", "--", filepath],
1100 cwd=self.project_path,
1101 capture_output=True, text=True, check=True
1102 ).stdout.strip()
1103
1104 return {
1105 "commit": commit,
1106 "has_changes": bool(diff)
1107 }
1108 except Exception as e:
1109 return None
1110
1111 def create_checkpoint(self, message, files_changed, conversation_snapshot=None, parent_commit=None):
1112 """Create a checkpoint on current session's branch
1113
1114 Args:
1115 message: Commit message
1116 files_changed: List of modified files
1117 conversation_snapshot: Conversation state to save
1118 parent_commit: Parent commit hash to branch from (for new sessions)
1119 """
1120 print(f"{DIM}[LOG] create_checkpoint: files_changed={files_changed}{RESET}", flush=True)
1121 if not files_changed or not self.session_id:
1122 return None
1123
1124 branch_name = self._get_branch_name()
1125
1126 # Save conversation snapshot
1127 if conversation_snapshot:
1128 snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
1129 snapshots = {}
1130 if os.path.exists(snapshot_file):
1131 with open(snapshot_file, 'r') as f:
1132 snapshots = json.load(f)
1133
1134 # Create temp worktree for this session
1135 temp_worktree = os.path.join(self.nanocode_dir, f"temp_worktree_{self.session_id}")
1136
1137 try:
1138 # Check if branch exists
1139 branch_exists = self._git_command("--git-dir", self.bare_repo, "rev-parse", "--verify", branch_name)
1140
1141 if not branch_exists or branch_exists.startswith("error"):
1142 # Create new branch
1143 os.makedirs(temp_worktree, exist_ok=True)
1144 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "config", "core.bare", "false")
1145
1146 # If parent_commit specified, branch from it
1147 if parent_commit:
1148 # Create branch from parent commit
1149 self._git_command("--git-dir", self.bare_repo, "branch", branch_name, parent_commit)
1150 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1151 else:
1152 # Create orphan branch (no parent)
1153 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", "--orphan", branch_name)
1154
1155 # Copy files to temp worktree
1156 for filepath in files_changed:
1157 print(f"{DIM}[LOG] checkpoint copying: {filepath}{RESET}", flush=True)
1158 if os.path.exists(filepath):
1159 file_size = os.path.getsize(filepath)
1160 print(f"{DIM}[LOG] source file exists: {filepath} ({file_size} bytes){RESET}", flush=True)
1161 # Convert absolute path to relative path
1162 if os.path.isabs(filepath):
1163 rel_filepath = os.path.relpath(filepath, self.project_path)
1164 else:
1165 rel_filepath = filepath
1166 dest = os.path.join(temp_worktree, rel_filepath)
1167 os.makedirs(os.path.dirname(dest), exist_ok=True)
1168 with open(filepath, 'rb') as src, open(dest, 'wb') as dst:
1169 content = src.read()
1170 dst.write(content)
1171 print(f"{DIM}[LOG] copied to temp_worktree: {dest} ({len(content)} bytes){RESET}", flush=True)
1172 else:
1173 print(f"{DIM}[LOG] source file NOT exists: {filepath}{RESET}", flush=True)
1174
1175 # Commit
1176 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "add", "-A")
1177 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1178 "commit", "-m", message, "--allow-empty")
1179
1180 commit_hash = self._git_command("--git-dir", self.bare_repo, "rev-parse", "HEAD")
1181 checkpoint_id = commit_hash[:8] if commit_hash and not commit_hash.startswith("error") else None
1182
1183 # Save conversation snapshot with checkpoint_id
1184 if checkpoint_id and conversation_snapshot:
1185 snapshots[checkpoint_id] = conversation_snapshot
1186 with open(snapshot_file, 'w') as f:
1187 json.dump(snapshots, f, indent=2)
1188
1189 return checkpoint_id
1190 else:
1191 # Branch exists, checkout and commit
1192 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1193
1194 # Update temp worktree
1195 for filepath in files_changed:
1196 print(f"{DIM}[LOG] checkpoint updating: {filepath}{RESET}", flush=True)
1197 if os.path.exists(filepath):
1198 file_size = os.path.getsize(filepath)
1199 print(f"{DIM}[LOG] source file exists: {filepath} ({file_size} bytes){RESET}", flush=True)
1200 # Convert absolute path to relative path
1201 if os.path.isabs(filepath):
1202 rel_filepath = os.path.relpath(filepath, self.project_path)
1203 else:
1204 rel_filepath = filepath
1205 dest = os.path.join(temp_worktree, rel_filepath)
1206 os.makedirs(os.path.dirname(dest), exist_ok=True)
1207 with open(filepath, 'rb') as src, open(dest, 'wb') as dst:
1208 content = src.read()
1209 dst.write(content)
1210 print(f"{DIM}[LOG] copied to temp_worktree: {dest} ({len(content)} bytes){RESET}", flush=True)
1211 else:
1212 print(f"{DIM}[LOG] source file NOT exists: {filepath}{RESET}", flush=True)
1213
1214 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "add", "-A")
1215 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1216 "commit", "-m", message, "--allow-empty")
1217
1218 commit_hash = self._git_command("--git-dir", self.bare_repo, "rev-parse", "HEAD")
1219 checkpoint_id = commit_hash[:8] if commit_hash and not commit_hash.startswith("error") else None
1220
1221 # Save conversation snapshot with checkpoint_id
1222 if checkpoint_id and conversation_snapshot:
1223 snapshots[checkpoint_id] = conversation_snapshot
1224 with open(snapshot_file, 'w') as f:
1225 json.dump(snapshots, f, indent=2)
1226
1227 return checkpoint_id
1228 except Exception as e:
1229 return None
1230
1231 def list_checkpoints(self, limit=10, show_all=False):
1232 """List recent checkpoints for current session
1233
1234 Args:
1235 limit: Maximum number of checkpoints to show
1236 show_all: If True, show all sessions; if False, only show current session
1237 """
1238 if not self.session_id and not show_all:
1239 return []
1240
1241 try:
1242 if show_all:
1243 # Show all branches
1244 args = ["--git-dir", self.bare_repo, "log", f"--max-count={limit}", "--oneline", "--all"]
1245 else:
1246 # Show only current session's branch
1247 branch_name = self._get_branch_name()
1248 args = ["--git-dir", self.bare_repo, "log", f"--max-count={limit}", "--oneline", branch_name]
1249
1250 log = self._git_command(*args)
1251 if log and not log.startswith("error"):
1252 return [line.split(" ", 1) for line in log.split("\n") if line]
1253 return []
1254 except:
1255 return []
1256
1257 def restore_checkpoint(self, checkpoint_id, session_baseline_files):
1258 """Restore files to checkpoint state and reset current session's branch
1259
1260 This properly handles files that were added after the checkpoint by:
1261 1. Restoring files that exist in checkpoint
1262 2. Restoring files to baseline if they don't exist in checkpoint
1263
1264 Args:
1265 checkpoint_id: Checkpoint hash to restore to
1266 session_baseline_files: Dict of baseline file states from session
1267
1268 Returns:
1269 tuple: (success: bool, conversation_snapshot: dict or None)
1270 """
1271 if not self.session_id:
1272 return False, None
1273
1274 branch_name = self._get_branch_name()
1275 temp_worktree = os.path.join(self.nanocode_dir, f"temp_worktree_{self.session_id}")
1276
1277 try:
1278 # Checkout branch first
1279 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1280
1281 # Reset branch to checkpoint (discards future commits on this branch)
1282 self._git_command("--git-dir", self.bare_repo, "reset", "--hard", checkpoint_id)
1283
1284 # Checkout to temp worktree
1285 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1286 "checkout", checkpoint_id, "-f")
1287
1288 # Get list of files in checkpoint
1289 files_in_checkpoint_str = self._git_command(
1290 "--git-dir", self.bare_repo,
1291 "ls-tree", "-r", "--name-only", checkpoint_id
1292 )
1293
1294 files_in_checkpoint = set()
1295 if files_in_checkpoint_str and not files_in_checkpoint_str.startswith("error"):
1296 files_in_checkpoint = set(f for f in files_in_checkpoint_str.split('\n') if f.strip())
1297
1298 print(f"\n{DIM}Files in checkpoint: {len(files_in_checkpoint)}{RESET}")
1299 print(f"{DIM}Files modified in session: {len(session_baseline_files)}{RESET}\n")
1300
1301 # Process each file that was modified in this session
1302 for filepath, baseline_source in session_baseline_files.items():
1303 # Convert to relative path for comparison
1304 if os.path.isabs(filepath):
1305 rel_filepath = os.path.relpath(filepath, self.project_path)
1306 else:
1307 rel_filepath = filepath
1308
1309 # Normalize path: remove leading ./
1310 normalized_rel_path = rel_filepath.lstrip('./')
1311
1312 if normalized_rel_path in files_in_checkpoint:
1313 # File exists in checkpoint - restore from checkpoint
1314 src = os.path.join(temp_worktree, normalized_rel_path)
1315 dest = os.path.join(self.project_path, normalized_rel_path)
1316
1317 dest_dir = os.path.dirname(dest)
1318 if dest_dir:
1319 os.makedirs(dest_dir, exist_ok=True)
1320
1321 with open(src, 'rb') as s, open(dest, 'wb') as d:
1322 d.write(s.read())
1323
1324 print(f" {GREEN}{RESET} {filepath} {DIM}(from checkpoint){RESET}")
1325 else:
1326 # File doesn't exist in checkpoint - restore to baseline
1327 abs_filepath = filepath if os.path.isabs(filepath) else os.path.join(self.project_path, filepath)
1328
1329 if baseline_source["type"] == "git":
1330 # Restore from project .git (use normalized path for git)
1331 result = subprocess.run(
1332 ["git", "checkout", baseline_source["commit"], "--", normalized_rel_path],
1333 cwd=self.project_path,
1334 capture_output=True, text=True
1335 )
1336 if result.returncode == 0:
1337 print(f" {CYAN}{RESET} {filepath} {DIM}(to baseline: git {baseline_source['commit'][:8]}){RESET}")
1338
1339 elif baseline_source["type"] == "blob":
1340 # Restore from blob
1341 if self.restore_file_from_blob(baseline_source["hash"], abs_filepath):
1342 print(f" {CYAN}{RESET} {filepath} {DIM}(to baseline: blob {baseline_source['hash'][:8]}){RESET}")
1343
1344 elif baseline_source["type"] == "new":
1345 # Delete new file
1346 if os.path.exists(abs_filepath):
1347 os.remove(abs_filepath)
1348 print(f" {YELLOW}{RESET} {filepath} {DIM}(deleted: was added after checkpoint){RESET}")
1349
1350 # Load conversation snapshot
1351 snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
1352 conversation_snapshot = None
1353 if os.path.exists(snapshot_file):
1354 with open(snapshot_file, 'r') as f:
1355 snapshots = json.load(f)
1356 conversation_snapshot = snapshots.get(checkpoint_id)
1357
1358 return True, conversation_snapshot
1359 except Exception as e:
1360 print(f"{RED}Error during restore: {e}{RESET}")
1361 return False, None
1362
1363
1364class Session:
1365 """Represents a conversation session"""
1366
1367 def __init__(self, session_id=None):
1368 self.session_id = session_id or self._generate_session_id()
1369 self.messages = []
1370 self.file_states = {}
1371 self.baseline_files = {} # Track original file versions for rollback
1372 self.metadata = {
1373 'created_at': time.time(),
1374 'last_active': time.time(),
1375 'description': '',
1376 'cwd': os.getcwd(),
1377 'parent_checkpoint': None, # Track where this session branched from
1378 'parent_session': None, # Track which session it branched from
1379 'git_commit': None, # Project .git commit hash when session started
1380 'git_branch': None, # Project .git branch when session started
1381 'git_dirty': False, # Whether project had uncommitted changes
1382 }
1383
1384 def _generate_session_id(self):
1385 """Generate unique session ID"""
1386 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
1387 random_suffix = ''.join(random.choices('0123456789abcdef', k=4))
1388 return f"{timestamp}_{random_suffix}"
1389
1390 def _get_project_git_info(self):
1391 """Get current project git state"""
1392 try:
1393 cwd = self.metadata.get('cwd', os.getcwd())
1394
1395 # Get current commit
1396 commit = subprocess.run(
1397 ["git", "rev-parse", "HEAD"],
1398 cwd=cwd,
1399 capture_output=True, text=True, check=True
1400 ).stdout.strip()
1401
1402 # Get current branch
1403 branch = subprocess.run(
1404 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1405 cwd=cwd,
1406 capture_output=True, text=True, check=True
1407 ).stdout.strip()
1408
1409 # Check if dirty
1410 status = subprocess.run(
1411 ["git", "status", "--porcelain"],
1412 cwd=cwd,
1413 capture_output=True, text=True, check=True
1414 ).stdout.strip()
1415
1416 return {
1417 'git_commit': commit[:8],
1418 'git_branch': branch,
1419 'git_dirty': bool(status)
1420 }
1421 except:
1422 return None
1423
1424 def capture_git_state(self):
1425 """Capture current project git state into metadata"""
1426 git_info = self._get_project_git_info()
1427 if git_info:
1428 self.metadata.update(git_info)
1429
1430 def track_file_state(self, filepath):
1431 """Track file state for conflict detection"""
1432 if os.path.exists(filepath):
1433 with open(filepath, 'rb') as f:
1434 content = f.read()
1435 file_hash = hashlib.md5(content).hexdigest()
1436 self.file_states[filepath] = {
1437 'hash': file_hash,
1438 'mtime': os.path.getmtime(filepath),
1439 'size': len(content)
1440 }
1441
1442 def detect_conflicts(self):
1443 """Detect if tracked files have been modified outside this session
1444
1445 Returns:
1446 list: List of conflicted file paths
1447 """
1448 conflicts = []
1449
1450 for filepath, saved_state in self.file_states.items():
1451 if os.path.exists(filepath):
1452 with open(filepath, 'rb') as f:
1453 content = f.read()
1454 current_hash = hashlib.md5(content).hexdigest()
1455 current_mtime = os.path.getmtime(filepath)
1456
1457 # Check if file has changed
1458 if (current_hash != saved_state['hash'] or
1459 current_mtime != saved_state['mtime']):
1460 conflicts.append(filepath)
1461 else:
1462 # File was deleted
1463 conflicts.append(f"{filepath} (deleted)")
1464
1465 return conflicts
1466
1467 def update_file_states(self):
1468 """Update all tracked file states to current state"""
1469 for filepath in list(self.file_states.keys()):
1470 if os.path.exists(filepath):
1471 self.track_file_state(filepath)
1472 else:
1473 # Remove deleted files from tracking
1474 del self.file_states[filepath]
1475
1476 def to_dict(self):
1477 """Serialize to dict"""
1478 return {
1479 'session_id': self.session_id,
1480 'messages': self.messages,
1481 'file_states': self.file_states,
1482 'baseline_files': self.baseline_files,
1483 'metadata': self.metadata
1484 }
1485
1486 @staticmethod
1487 def from_dict(data):
1488 """Deserialize from dict"""
1489 session = Session(session_id=data['session_id'])
1490 session.messages = data.get('messages', [])
1491 session.file_states = data.get('file_states', {})
1492 session.baseline_files = data.get('baseline_files', {})
1493 session.metadata = data.get('metadata', {})
1494 return session
1495
1496
1497class SessionManager:
1498 """Manage multiple sessions"""
1499
1500 def __init__(self, project_path):
1501 self.project_path = project_path
1502 self.sessions_dir = os.path.join(project_path, ".nanocode", "sessions")
1503 self.current_session = None
1504 self.checkpoint_manager = CheckpointManager(project_path)
1505 self.parent_commit_for_next_checkpoint = None # Track parent for first checkpoint
1506 os.makedirs(self.sessions_dir, exist_ok=True)
1507
1508 def save_baseline_if_needed(self, filepath):
1509 """Save file's baseline version before first modification
1510
1511 This is called before write/edit operations to preserve the original state.
1512 """
1513 if not self.current_session:
1514 return
1515
1516 # Already saved
1517 if filepath in self.current_session.baseline_files:
1518 return
1519
1520 print(f"{DIM}[LOG] Saving baseline for: {filepath}{RESET}", flush=True)
1521
1522 # Get file info from project .git
1523 git_info = self.checkpoint_manager.get_file_git_info(filepath)
1524
1525 if git_info:
1526 # File is tracked by project .git
1527 if git_info["has_changes"]:
1528 # Has local changes - save to blob
1529 blob_hash = self.checkpoint_manager.save_file_to_blob(filepath)
1530 if blob_hash:
1531 self.current_session.baseline_files[filepath] = {
1532 "type": "blob",
1533 "hash": blob_hash
1534 }
1535 print(f"{DIM}[LOG] Saved dirty file to blob: {blob_hash[:8]}{RESET}", flush=True)
1536 else:
1537 # Clean - just record commit
1538 self.current_session.baseline_files[filepath] = {
1539 "type": "git",
1540 "commit": git_info["commit"]
1541 }
1542 print(f"{DIM}[LOG] Recorded git commit: {git_info['commit'][:8]}{RESET}", flush=True)
1543 else:
1544 # Untracked file or no .git
1545 if os.path.exists(filepath):
1546 # Save existing untracked file to blob
1547 blob_hash = self.checkpoint_manager.save_file_to_blob(filepath)
1548 if blob_hash:
1549 self.current_session.baseline_files[filepath] = {
1550 "type": "blob",
1551 "hash": blob_hash
1552 }
1553 print(f"{DIM}[LOG] Saved untracked file to blob: {blob_hash[:8]}{RESET}", flush=True)
1554 else:
1555 # New file - mark as new
1556 self.current_session.baseline_files[filepath] = {
1557 "type": "new"
1558 }
1559 print(f"{DIM}[LOG] Marked as new file{RESET}", flush=True)
1560
1561 # Auto-save session to persist baseline_files
1562 self.save_session()
1563
1564 def restore_baseline(self):
1565 """Restore all files to their baseline state
1566
1567 Returns:
1568 bool: Success status
1569 """
1570 if not self.current_session:
1571 return False
1572
1573 if not self.current_session.baseline_files:
1574 print(f"{YELLOW}⚠ No baseline files to restore{RESET}")
1575 return False
1576
1577 print(f"\n{BOLD}Restoring {len(self.current_session.baseline_files)} files to baseline...{RESET}\n")
1578
1579 success_count = 0
1580 for filepath, source in self.current_session.baseline_files.items():
1581 try:
1582 # Normalize to absolute path
1583 abs_filepath = filepath if os.path.isabs(filepath) else os.path.join(self.project_path, filepath)
1584 # Get relative path for git operations
1585 rel_filepath = os.path.relpath(abs_filepath, self.project_path)
1586
1587 if source["type"] == "git":
1588 # Restore from project .git (use relative path)
1589 result = subprocess.run(
1590 ["git", "checkout", source["commit"], "--", rel_filepath],
1591 cwd=self.project_path,
1592 capture_output=True, text=True
1593 )
1594 if result.returncode == 0:
1595 print(f" {GREEN}{RESET} {filepath} {DIM}(from git {source['commit'][:8]}){RESET}")
1596 success_count += 1
1597 else:
1598 print(f" {RED}{RESET} {filepath} {DIM}(git checkout failed){RESET}")
1599
1600 elif source["type"] == "blob":
1601 # Restore from checkpoint.git blob (use absolute path)
1602 if self.checkpoint_manager.restore_file_from_blob(source["hash"], abs_filepath):
1603 print(f" {GREEN}{RESET} {filepath} {DIM}(from blob {source['hash'][:8]}){RESET}")
1604 success_count += 1
1605 else:
1606 print(f" {RED}{RESET} {filepath} {DIM}(blob restore failed){RESET}")
1607
1608 elif source["type"] == "new":
1609 # Delete new file (use absolute path)
1610 if os.path.exists(abs_filepath):
1611 os.remove(abs_filepath)
1612 print(f" {GREEN}{RESET} {filepath} {DIM}(deleted new file){RESET}")
1613 success_count += 1
1614 else:
1615 print(f" {DIM}{RESET} {filepath} {DIM}(already deleted){RESET}")
1616 success_count += 1
1617
1618 except Exception as e:
1619 print(f" {RED}{RESET} {filepath} {DIM}(error: {e}){RESET}")
1620
1621 print(f"\n{GREEN}✓ Restored {success_count}/{len(self.current_session.baseline_files)} files{RESET}")
1622 return success_count > 0
1623
1624 def create_session(self, description="", parent_checkpoint=None, parent_session=None):
1625 """Create new session
1626
1627 Args:
1628 description: Session description
1629 parent_checkpoint: Checkpoint ID this session branches from
1630 parent_session: Session ID this session branches from
1631 """
1632 session = Session()
1633 session.metadata['description'] = description
1634 session.metadata['parent_checkpoint'] = parent_checkpoint
1635 session.metadata['parent_session'] = parent_session
1636 # Capture project git state
1637 session.capture_git_state()
1638 self.current_session = session
1639 # Set checkpoint manager to use this session
1640 self.checkpoint_manager.set_session(session.session_id)
1641 # Store parent commit for first checkpoint
1642 self.parent_commit_for_next_checkpoint = parent_checkpoint
1643 self.save_session()
1644 return session
1645
1646 def save_session(self):
1647 """Save current session to disk"""
1648 if not self.current_session:
1649 return
1650
1651 self.current_session.metadata['last_active'] = time.time()
1652 session_file = os.path.join(
1653 self.sessions_dir,
1654 f"{self.current_session.session_id}.json"
1655 )
1656
1657 with open(session_file, 'w') as f:
1658 json.dump(self.current_session.to_dict(), f, indent=2)
1659
1660 def load_session(self, session_id):
1661 """Load session from disk"""
1662 session_file = os.path.join(self.sessions_dir, f"{session_id}.json")
1663
1664 if not os.path.exists(session_file):
1665 return None
1666
1667 with open(session_file, 'r') as f:
1668 data = json.load(f)
1669
1670 session = Session.from_dict(data)
1671 self.current_session = session
1672 # Set checkpoint manager to use this session
1673 self.checkpoint_manager.set_session(session.session_id)
1674 return session
1675
1676 def list_sessions(self):
1677 """List all sessions"""
1678 sessions = []
1679
1680 if not os.path.exists(self.sessions_dir):
1681 return sessions
1682
1683 for filename in os.listdir(self.sessions_dir):
1684 if filename.endswith('.json'):
1685 filepath = os.path.join(self.sessions_dir, filename)
1686 try:
1687 with open(filepath, 'r') as f:
1688 data = json.load(f)
1689 sessions.append({
1690 'session_id': data['session_id'],
1691 'metadata': data['metadata'],
1692 'message_count': len(data.get('messages', [])),
1693 })
1694 except:
1695 pass
1696
1697 return sorted(sessions, key=lambda x: x['metadata'].get('last_active', 0), reverse=True)
1698
1699 def load_last_session(self):
1700 """Load the most recent session"""
1701 sessions = self.list_sessions()
1702 if sessions:
1703 return self.load_session(sessions[0]['session_id'])
1704 return None
1705
1706
1707def handle_checkpoint_command(parts, session_manager, files_modified):
1708 """Handle /checkpoint or /c commands
1709
1710 Returns:
1711 messages: New messages list if conversation was restored, None otherwise
1712 """
1713 # Default to list if no subcommand
1714 if len(parts) < 2:
1715 parts.append("list")
1716
1717 cmd = parts[1]
1718
1719 # If cmd looks like a commit hash (7-8 hex chars), treat as restore
1720 if len(cmd) >= 7 and len(cmd) <= 8 and all(c in '0123456789abcdef' for c in cmd.lower()):
1721 cmd = "restore"
1722 checkpoint_id = parts[1]
1723 else:
1724 checkpoint_id = None
1725
1726 if cmd == "baseline" or cmd == "base":
1727 # Restore all files to baseline (session start state)
1728 if not session_manager.current_session.baseline_files:
1729 print(f"{YELLOW}⚠ No baseline: no files have been modified in this session{RESET}")
1730 return None
1731
1732 print(f"{YELLOW}⚠ This will restore all modified files to their original state{RESET}")
1733 print(f"{YELLOW}⚠ Files to restore: {len(session_manager.current_session.baseline_files)}{RESET}")
1734
1735 # Show files
1736 print(f"\n{DIM}Files:{RESET}")
1737 for filepath in list(session_manager.current_session.baseline_files.keys())[:10]:
1738 print(f" {DIM}{filepath}{RESET}")
1739 if len(session_manager.current_session.baseline_files) > 10:
1740 print(f" {DIM}... and {len(session_manager.current_session.baseline_files) - 10} more{RESET}")
1741
1742 confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
1743
1744 if confirm != 'y':
1745 print(f"{DIM}Cancelled{RESET}")
1746 return None
1747
1748 success = session_manager.restore_baseline()
1749 if success:
1750 # Clear conversation
1751 print(f"{GREEN}✓ Conversation cleared{RESET}")
1752 return []
1753 else:
1754 return None
1755
1756 elif cmd == "list" or cmd == "all" or cmd == "--all":
1757 show_all = (cmd == "all" or "--all" in parts)
1758
1759 if show_all:
1760 # Show git graph of all branches
1761 print(f"\n{BOLD}📍 Checkpoint Graph:{RESET}\n")
1762
1763 # Use git log --graph --all to show the tree
1764 # Format: %h = short hash, %d = ref names, %s = subject, %ar = relative date
1765 graph_output = session_manager.checkpoint_manager._git_command(
1766 "--git-dir", session_manager.checkpoint_manager.bare_repo,
1767 "log", "--graph", "--all", "--oneline",
1768 "--format=%h %s (%ar)", "-20"
1769 )
1770
1771 if graph_output and not graph_output.startswith("error"):
1772 # Also get branch info for each commit
1773 branches_output = session_manager.checkpoint_manager._git_command(
1774 "--git-dir", session_manager.checkpoint_manager.bare_repo,
1775 "branch", "-a", "--contains"
1776 )
1777
1778 # Parse and display
1779 for line in graph_output.split('\n'):
1780 if not line.strip():
1781 continue
1782
1783 # Extract commit hash
1784 match = re.search(r'\b([0-9a-f]{7,8})\b', line)
1785 if match:
1786 commit_hash = match.group(1)
1787
1788 # Get branches containing this commit
1789 branch_info = session_manager.checkpoint_manager._git_command(
1790 "--git-dir", session_manager.checkpoint_manager.bare_repo,
1791 "branch", "-a", "--contains", commit_hash
1792 )
1793
1794 # Extract session names from branches
1795 session_names = []
1796 if branch_info and not branch_info.startswith("error"):
1797 for branch_line in branch_info.split('\n'):
1798 branch_line = branch_line.strip().lstrip('* ')
1799 if branch_line.startswith('session_'):
1800 # Shorten session name: session_20260130_103323_f7 -> s:20260130_103323_f7
1801 session_short = 's:' + branch_line[8:] # Remove 'session_' prefix
1802 session_names.append(session_short)
1803
1804 # Highlight commit hash
1805 line = line.replace(commit_hash, f"{CYAN}{commit_hash}{RESET}")
1806
1807 # Add session info if found
1808 if session_names:
1809 # Insert session names after commit hash
1810 session_str = f"{GREEN}[{', '.join(session_names[:2])}]{RESET}"
1811 line = line.replace(commit_hash + f"{RESET}", commit_hash + f"{RESET} {session_str}")
1812
1813
1814 print(f" {line}")
1815 print()
1816 else:
1817 print(f"{DIM}No checkpoints yet{RESET}\n")
1818
1819 print(f"{DIM}Restore: /c <hash>{RESET}")
1820 return None
1821 else:
1822 # Show current session's checkpoints
1823 checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
1824 if not checkpoints:
1825 print(f"{DIM}No checkpoints yet{RESET}")
1826 return None
1827
1828 print(f"\n{BOLD}📍 Checkpoints:{RESET}\n")
1829
1830 # Get checkpoint details from git log with timestamp
1831 for commit_hash, message in checkpoints[:10]: # Show first 10 (already newest first from git log)
1832 # Try to get timestamp from git
1833 timestamp_str = session_manager.checkpoint_manager._git_command(
1834 "--git-dir", session_manager.checkpoint_manager.bare_repo,
1835 "log", "-1", "--format=%ar", commit_hash
1836 )
1837 if timestamp_str.startswith("error"):
1838 timestamp_str = ""
1839
1840 # Get modified files
1841 files_str = session_manager.checkpoint_manager._git_command(
1842 "--git-dir", session_manager.checkpoint_manager.bare_repo,
1843 "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash
1844 )
1845 files = []
1846 if files_str and not files_str.startswith("error"):
1847 files = [f.strip() for f in files_str.split('\n') if f.strip()]
1848
1849 # Format: hash | time ago | message
1850 time_part = f"{DIM}{timestamp_str}{RESET}" if timestamp_str else ""
1851 print(f" {CYAN}{commit_hash}{RESET} {time_part}")
1852 print(f" {DIM}└─{RESET} {message}")
1853 if files:
1854 files_display = ", ".join(files[:3])
1855 if len(files) > 3:
1856 files_display += f" +{len(files)-3} more"
1857 print(f" {DIM} Files: {files_display}{RESET}")
1858 print()
1859
1860 print(f"{DIM}Tip: Use '/c all' or '/ca' to see git graph{RESET}")
1861 print(f"{DIM}Restore: /c <hash>{RESET}")
1862 return None
1863
1864 elif cmd == "restore":
1865 if not checkpoint_id:
1866 print(f"{RED}Usage: /c <checkpoint_id>{RESET}")
1867 return None
1868
1869 print(f"{YELLOW}⚠ This will restore files AND conversation to checkpoint {checkpoint_id}{RESET}")
1870 print(f"{YELLOW}⚠ Future checkpoints will be discarded from history{RESET}")
1871 confirm = input(f"{BOLD}Continue? (y/N): {RESET}").strip().lower()
1872
1873 if confirm != 'y':
1874 print(f"{DIM}Cancelled{RESET}")
1875 return None
1876
1877 success, conversation_snapshot = session_manager.checkpoint_manager.restore_checkpoint(
1878 checkpoint_id,
1879 session_manager.current_session.baseline_files
1880 )
1881 if success:
1882 print(f"{GREEN}✓ Restored files to checkpoint {checkpoint_id}{RESET}")
1883 if conversation_snapshot:
1884 print(f"{GREEN}✓ Restored conversation ({len(conversation_snapshot)} messages){RESET}")
1885 return conversation_snapshot
1886 else:
1887 print(f"{YELLOW}⚠ No conversation snapshot found for this checkpoint{RESET}")
1888 return None
1889 else:
1890 print(f"{RED}✗ Failed to restore checkpoint{RESET}")
1891 return None
1892
1893 else:
1894 print(f"{RED}Unknown command: {cmd}{RESET}")
1895 return None
1896
1897
1898if __name__ == "__main__":
1899 main()
1900