"""`/continue` command: list & restore past model_responses sessions. Pure functions + one `install(cls)` monkey-patch entry. No side effects at import. """ import ast, atexit, glob, json, os, random, re, shutil, threading, time _LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'model_responses') _LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt') _BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)', re.DOTALL | re.MULTILINE) _SUMMARY_RE = re.compile(r'\s*(.*?)\s*', re.DOTALL) _ROUND_HEADER_RE = re.compile(rb'^=== (Prompt|Response) ===', re.MULTILINE) _ROUNDS_CACHE_PATH = os.path.join(os.path.expanduser('~'), '.genericagent', 'continue_rounds_cache.json') _ROUNDS_CACHE_VERSION = 1 _rounds_cache = None _rounds_cache_dirty = False def _rel_time(mtime): d = int(time.time() - mtime) if d < 60: return f'{d}秒前' if d < 3600: return f'{d // 60}分前' if d < 86400: return f'{d // 3600}小时前' return f'{d // 86400}天前' def _pairs(content): blocks, pairs, pending = _BLOCK_RE.findall(content or ''), [], None for label, body in blocks: if label == 'Prompt': pending = body.strip() elif pending is not None: pairs.append((pending, body.strip())); pending = None return pairs def _first_user(pairs): for p, _ in pairs: try: msg = json.loads(p) except Exception: continue if not isinstance(msg, dict): continue for blk in msg.get('content', []) or []: if isinstance(blk, dict) and blk.get('type') == 'text': t = strip_project_mode(blk.get('text') or '').strip() if t and '' not in t and not t.startswith('### [WORKING MEMORY]'): return t for p, _ in pairs[:1]: for line in p.splitlines(): s = line.strip() if s and not s.startswith('###'): return s return '' def _last_user(text): """Last real user prompt. Scans `=== Prompt ===` blocks directly (no Prompt/Response pairing, so response-less/aborted sessions still preview), newest-first, returning the first one `_user_text` accepts (it drops tool_result continuations + all _INJECT_MARKERS). Better preview anchor than the first prompt — reflects what the session was most recently about.""" for label, body in reversed(_BLOCK_RE.findall(text or '')): if label == 'Prompt': t = _user_text(body) if t: return t return '' def _last_summary(pairs): for _, response_body in reversed(pairs): try: blocks = ast.literal_eval(response_body) except Exception: continue if not isinstance(blocks, list): continue text_parts = [] for block in blocks: if isinstance(block, dict) and block.get('type') == 'text': text = block.get('text', '') if isinstance(text, str) and text: text_parts.append(text) match = _SUMMARY_RE.search('\n'.join(text_parts)) if match: summary = match.group(1).strip() if summary: return summary return '' def _preview_text(pairs): return _last_summary(pairs) or _first_user(pairs) def _recent_context(my_pid, n=5): """扫描最近 n 个 model_response 文件(排除自身),提取 lastQ / lastA。""" out = [] for f in sorted(glob.glob(_LOG_GLOB), key=os.path.getmtime, reverse=True): m = re.search(r'model_responses_(\d+)', os.path.basename(f)) if not m or m.group(1) == str(my_pid): continue try: c = open(f, encoding='utf-8', errors='ignore').read() except Exception: continue q = s = "" for hm in re.finditer(r'(.*?)', c, re.DOTALL): u = re.search(r'\[USER\]:\s*(.+?)(?:\\n|<)', hm.group(1)) if u: q = u.group(1) sm = _SUMMARY_RE.search(c) if sm: s = sm.group(1).strip() q, s = q[:60].strip(), s[:60].replace('\n', ' ').strip() out.append(f'· {m.group(1)} | lastQ: {q or "-"} | lastA: {s or "-"}') if len(out) >= n: break return ('[RecentContext] 近期并行会话(非当前):\n' + '\n'.join(out) + '\n[/RecentContext]') if out else "" def _parse_native_history(pairs): history = [] for p, r in pairs: try: user_msg = json.loads(p) except Exception: return None try: blocks = ast.literal_eval(r) except Exception: return None if not (isinstance(user_msg, dict) and user_msg.get('role') == 'user'): return None if not isinstance(blocks, list): return None history.append(user_msg) # runtime history 尊重日志真实 prompt(含 project-mode 当轮注入) history.append({'role': 'assistant', 'content': blocks}) return history def parse_native_log(path, allow_empty=False): """Parse a native `model_responses_*.txt` log into backend.history. Public wrapper around the mature `/continue` parser. It intentionally only restores complete Prompt→Response pairs; dangling Prompt / partial turns keep the current continue semantics and are ignored. Returns: - list (possibly [] when allow_empty=True) on native success; - None when the file is unreadable / non-native / empty without allow_empty. """ try: with open(path, encoding='utf-8', errors='replace') as fh: content = fh.read() except Exception: return [] if allow_empty else None pairs = _pairs(content) if not pairs: # Native-looking but incomplete logs (e.g. dangling Prompt without Response) # have block headers but no complete pairs. For worldline's allow_empty mode # this means "0 completed rounds", not "parse failed → trust live history". if allow_empty and (_is_empty_log(path) or _BLOCK_RE.findall(content or '')): return [] return None return _parse_native_history(pairs) def _derive_hist_info(history): """从 native history 重建 history_info(轮级纪要):真实用户提问 → `[USER]: …`;每条 assistant(=一轮) → `[Agent] `(无 summary 取首行)。与 ga.turn_end_callback / worldline 树同口径。纯函数、不依赖 worldline,供续接 opt-in 恢复工作记忆(见 restore_wm)。""" def _all_text(m): c = m.get('content') if isinstance(m, dict) else None if isinstance(c, str): return c if isinstance(c, list): return '\n'.join(b.get('text', '') for b in c if isinstance(b, dict) and b.get('type') == 'text') return '' def _is_tool_result(m): c = m.get('content') if isinstance(m, dict) else None return isinstance(c, list) and any( isinstance(b, dict) and b.get('type') == 'tool_result' for b in c) out = [] for m in history or []: if not isinstance(m, dict): continue role = m.get('role') if role == 'user': if _is_tool_result(m): continue # tool_result 轮不是提问 t = strip_project_mode(_all_text(m)).strip() if t and not t.startswith(_INJECT_MARKERS): out.append(f'[USER]: {t}') elif role == 'assistant': txt = re.sub(r'```.*?```|.*?', '', _all_text(m), flags=re.DOTALL) mt = re.search(r'(.*?)', txt, re.DOTALL) s = mt.group(1).strip()[:80] if (mt and mt.group(1).strip()) else '' if not s: s = next((ln.strip()[:80] for ln in txt.splitlines() if ln.strip()), '(无摘要)') out.append(f'[Agent] {s}') return out _PREVIEW_WIN = 32 * 1024 # Content-grep budget for `/continue` search box: read at most this many bytes # per session (head window) so 17MB files don't stall the UI. Empirically the # user-typed prompt + first model reply + early summaries live in the first MB, # which is what users actually want to recall sessions by. _GREP_WIN = 1 * 1024 * 1024 def file_contains_all(path, terms, max_bytes=_GREP_WIN): """True iff every lowercase term in `terms` appears in the first `max_bytes` of `path` (case-insensitive). Empty `terms` returns True so callers can short-circuit. Reads as bytes + .lower() to avoid utf-8 cost and stays within a fixed memory envelope regardless of file size. """ if not terms: return True try: with open(path, 'rb') as fh: buf = fh.read(max_bytes) except OSError: return False if not buf: return False hay = buf.lower() for t in terms: if t and t.encode('utf-8', errors='ignore') not in hay: return False return True def search_sessions(query, sessions, max_bytes=_GREP_WIN): """Filter `sessions` ([(path, mtime, preview, n), ...]) by content grep. `query` is whitespace-split into AND terms (case-insensitive). Each session is kept iff its path/preview already match OR the first `max_bytes` of its file contain every term. Order is preserved. Empty/whitespace query returns the list as-is. """ q = (query or '').strip().lower() if not q: return list(sessions or []) terms = [t for t in q.split() if t] if not terms: return list(sessions or []) out = [] for item in sessions or []: path = item[0] if len(item) > 0 else '' preview = item[2] if len(item) > 2 else '' meta = (os.path.basename(path) + '\n' + (preview or '')).lower() if all(t in meta for t in terms): out.append(item) continue if file_contains_all(path, terms, max_bytes=max_bytes): out.append(item) return out def _preview_from_file(path): """Cheap preview: last in tail window, else first user line in head window.""" try: sz = os.path.getsize(path) with open(path, 'rb') as fh: if sz <= _PREVIEW_WIN * 2: head = tail = fh.read() else: head = fh.read(_PREVIEW_WIN) fh.seek(-_PREVIEW_WIN, 2); tail = fh.read() except OSError: return '' tail_s = tail.decode('utf-8', errors='replace') # Use only the latest , and reject it if dirty. Models sometimes emit # an unclosed , so the non-greedy DOTALL match pairs it with a far-away # and swallows === block headers / JSON across rounds. Treat such a # match as invalid and fall through to the last user prompt (don't dig older ones). cands = _SUMMARY_RE.findall(tail_s) if cands: s = ' '.join(cands[-1].split()) if s and '=== ' not in s and '"role"' not in s and len(s) <= 200: return s # Summary invalid/absent -> last real user prompt (JSON-aware, skips anchors; # scans Prompt blocks directly so response-less sessions still preview). lu = _last_user(tail_s) or _last_user(head.decode('utf-8', errors='replace')) if lu: return ' '.join(lu.split())[:120] return '' def _rounds_cache_key(path): return os.path.normcase(os.path.abspath(path)) def _load_rounds_cache(): """Load lazy mtime/size keyed round-count cache for /continue. Cache is intentionally triggered only by list_sessions(): no TUI startup cost, no logging-path coupling. Missing/stale entries are recomputed on demand. """ global _rounds_cache if _rounds_cache is not None: return _rounds_cache _rounds_cache = {} try: with open(_ROUNDS_CACHE_PATH, encoding='utf-8') as fh: data = json.load(fh) if isinstance(data, dict) and data.get('version') == _ROUNDS_CACHE_VERSION: items = data.get('items') if isinstance(items, dict): _rounds_cache = items except Exception: _rounds_cache = {} return _rounds_cache def _save_rounds_cache(valid_keys=None): global _rounds_cache_dirty if not _rounds_cache_dirty or _rounds_cache is None: return try: if valid_keys is not None: keep = set(valid_keys) for k in list(_rounds_cache.keys()): if k not in keep: _rounds_cache.pop(k, None) os.makedirs(os.path.dirname(_ROUNDS_CACHE_PATH), exist_ok=True) tmp = _ROUNDS_CACHE_PATH + '.tmp' data = {'version': _ROUNDS_CACHE_VERSION, 'items': _rounds_cache} with open(tmp, 'w', encoding='utf-8') as fh: json.dump(data, fh, ensure_ascii=False, separators=(',', ':')) os.replace(tmp, _ROUNDS_CACHE_PATH) _rounds_cache_dirty = False except Exception: # Cache is a performance hint only; never break /continue on cache I/O. pass def _count_complete_rounds_from_file(path): """Count completed Prompt→Response pairs using only block headers. Counting Prompt headers alone overcounts an in-flight/incomplete last round. Header-pair counting matched `_pairs()` on sampled real logs while avoiding expensive UTF-8 decode / body regex parsing. """ try: with open(path, 'rb') as fh: data = fh.read() except OSError: return 0 pending = False rounds = 0 for m in _ROUND_HEADER_RE.finditer(data): if m.group(1) == b'Prompt': pending = True elif pending: rounds += 1 pending = False return rounds def _rounds_for_file(path, st): global _rounds_cache_dirty cache = _load_rounds_cache() key = _rounds_cache_key(path) size = int(getattr(st, 'st_size', 0)) mtime_ns = int(getattr(st, 'st_mtime_ns', int(getattr(st, 'st_mtime', 0) * 1_000_000_000))) ent = cache.get(key) if isinstance(ent, dict) and ent.get('size') == size and ent.get('mtime_ns') == mtime_ns: try: return int(ent.get('rounds', 0)), key except Exception: pass n = _count_complete_rounds_from_file(path) cache[key] = {'size': size, 'mtime_ns': mtime_ns, 'rounds': int(n)} _rounds_cache_dirty = True return n, key def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None): """Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only. `exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's OWN current session — preferred over `exclude_pid`, which assumed the log file was named by PID (it isn't: agentmain mints a random 6-digit logid), so the pid tag never matched and the current session leaked into its own list.""" files = glob.glob(_LOG_GLOB) if exclude_pid is not None: tag = f'model_responses_{exclude_pid}.txt' files = [f for f in files if not f.endswith(tag)] if exclude_log: files = [f for f in files if os.path.basename(f) != exclude_log] out = [] valid_keys = [] for f in files: try: st = os.stat(f) mtime, sz = st.st_mtime, st.st_size except OSError: continue if sz < 32: continue preview = _preview_from_file(f) if not preview: continue rounds, key = _rounds_for_file(f, st) valid_keys.append(key) out.append((f, mtime, preview, rounds)) _save_rounds_cache(valid_keys) # 【门控·worldline】树感知发现:日志存在但为空且有非空世界线树的会话(回退到起点后日志被 # 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传, # 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。 if rewind_root and os.path.isdir(rewind_root): have = {os.path.basename(p) for p, *_ in out} try: keys = os.listdir(rewind_root) except OSError: keys = [] for key in keys: if not key.startswith('model_responses_'): continue log_name = key + '.txt' if log_name in have or log_name == exclude_log: continue log_path = os.path.join(_LOG_DIR, log_name) try: # 仅收"日志确实为空"的(非空日志已被主循环收录) if os.path.getsize(log_path) >= 32: continue except OSError: continue # 日志缺失(如已归档)不作为可续会话展示 try: with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh: d = json.load(fh) except Exception: continue nodes = d.get('nodes') or {} real = [v for v in nodes.values() if v.get('kind') != 'origin'] if not real: # 只有 origin 的空树 → 无内容,跳过 continue try: mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json')) except OSError: mtime = 0 head = d.get('head') title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)' out.append((log_path, mtime, f'[世界线] {title}', len(real))) out.sort(key=lambda x: x[1], reverse=True) return out _MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])') def _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\1', s) def _agent_clients(agent): clients = [] for client in getattr(agent, 'llmclients', []) or []: if client not in clients: clients.append(client) current = getattr(agent, 'llmclient', None) if current is not None and current not in clients: clients.insert(0, current) return clients def _replace_backend_history(agent, history): backend = getattr(getattr(agent, 'llmclient', None), 'backend', None) if backend is not None and hasattr(backend, 'history'): backend.history = list(history or []) def _current_log_path(pid=None): pid = os.getpid() if pid is None else pid return os.path.join(_LOG_DIR, f'model_responses_{pid}.txt') def _snapshot_current_log(pid=None): """Persist current PID log as a standalone recoverable snapshot, then clear it.""" path = _current_log_path(pid) if not os.path.isfile(path): return None try: with open(path, encoding='utf-8', errors='replace') as fh: content = fh.read() except Exception: return None if not _pairs(content): return None os.makedirs(_LOG_DIR, exist_ok=True) pid = os.getpid() if pid is None else pid stamp = time.strftime('%Y%m%d_%H%M%S') snapshot = os.path.join(_LOG_DIR, f'model_responses_snapshot_{pid}_{stamp}_{time.time_ns() % 1_000_000_000:09d}.txt') with open(snapshot, 'w', encoding='utf-8', errors='replace') as fh: fh.write(content) with open(path, 'w', encoding='utf-8', errors='replace'): pass return snapshot def reset_conversation(agent, message='🆕 已开启新对话,当前上下文已清空'): """Abort current work and clear all known frontend-visible conversation state.""" try: agent.abort() except Exception: pass _snapshot_current_log() if hasattr(agent, 'history'): agent.history = [] for client in _agent_clients(agent): backend = getattr(client, 'backend', None) if backend is not None and hasattr(backend, 'history'): backend.history = [] if hasattr(client, 'last_tools'): client.last_tools = '' if hasattr(agent, 'handler'): agent.handler = None return message def format_list(sessions, limit=20): if not sessions: return '❌ 没有可恢复的历史会话' lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', ''] for i, (_, mtime, first, n) in enumerate(sessions[:limit], 1): preview = _escape_md((first or '(无法预览)').replace('\n', ' ')[:60]) lines.append(f'{i}. `{_rel_time(mtime)}` · **{n} 轮** · {preview}') return '\n'.join(lines) def restore(agent, path): """Restore session at path. Returns (msg, is_full).""" try: with open(path, encoding='utf-8', errors='replace') as fh: content = fh.read() except Exception as e: return f'❌ 读取失败: {e}', False pairs = _pairs(content) if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False history = _parse_native_history(pairs) name = os.path.basename(path) if history is not None: agent.abort() _replace_backend_history(agent, history) return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})\n(已写入 backend.history,可直接继续)', True from chatapp_common import _restore_native_history, _restore_text_pairs summary = _restore_text_pairs(content) or _restore_native_history(content) if not summary: return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False agent.abort() agent.history.extend(summary) n = sum(1 for l in summary if l.startswith('[USER]: ')) return f'⚠️ 非 native 格式,已降级恢复 {n} 轮摘要({name})\n(请输入新问题继续)', False def handle(agent, query, display_queue): """Dispatch /continue or /continue N. Returns None if consumed else original query.""" s = (query or '').strip() if s == '/continue': display_queue.put({'done': format_list(list_sessions(exclude_pid=os.getpid())), 'source': 'system'}) return None m = re.match(r'/continue\s+(\d+)\s*$', s) if m: sessions = list_sessions(exclude_pid=os.getpid()) idx = int(m.group(1)) - 1 if not (0 <= idx < len(sessions)): display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)})', 'source': 'system'}) return None reset_conversation(agent, message=None) msg, _ = restore(agent, sessions[idx][0]) display_queue.put({'done': msg, 'source': 'system'}) return None return query _INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]', '[DANGER]', '### [总结提炼经验]', 'Continue from where you left off') # project_mode 插件把 `\n\n---\n[PROJECT MODE: ]\n…\n---` 追加到当轮 user # message(见 plugins/project_mode._build_injection)。runtime history 尊重日志真实 prompt, # 但 UI 预览/显示与派生 history_info 需要只取用户原话。老日志的 WORKING MEMORY 里还 # 可能嵌着已污染的 `[USER]: ... [PROJECT MODE] ... [Agent] ...`,所以剥离支持 text # 内任意位置与被截断的单行 title 形态。不能加进 _INJECT_MARKERS——那会把整条用户原话一起丢弃。 _PM_BLOCK_RE = re.compile(r"\s*-{3,}\s*\[PROJECT MODE:.*?(?:\n-{3,}\s*|$)", re.DOTALL) def strip_project_mode(text: str) -> str: """剔除文本中由 project_mode 插件追加的 L1 注入块。""" return _PM_BLOCK_RE.sub("", text or "") def _user_text(prompt_body): """User-typed text from a prompt JSON; '' if this is an agent auto-continuation. A Prompt is auto-continue when *either* (a) it carries any tool_result block (so it's the next round of an in-flight LLM call), or (b) its text blocks all match known injection prefixes ([WORKING MEMORY], [SYSTEM TIPS], [System] regenerate prompts, [DANGER] guards, etc.). Real first-prompts only contain one plain text block with no injection markers. """ try: msg = json.loads(prompt_body) except Exception: return '' if not isinstance(msg, dict): return '' blocks = msg.get('content', []) or [] if any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in blocks): return '' for blk in blocks: if isinstance(blk, dict) and blk.get('type') == 'text': t = strip_project_mode(blk.get('text') or '').strip() if t and not any(mk in t for mk in _INJECT_MARKERS): return t return '' def _assistant_text(response_body): """Joined plain text from a response blocks repr; '' on parse failure. Used by /export to grab the model's prose only, without tool noise. """ try: blocks = ast.literal_eval(response_body) except Exception: return '' if not isinstance(blocks, list): return '' return '\n'.join(b['text'] for b in blocks if isinstance(b, dict) and b.get('type') == 'text' and isinstance(b.get('text'), str) and b['text'].strip()) def _format_tool_use(block): """Match agent_loop.py:78 verbose tool-call header byte-for-byte. MUST use agent_loop's `get_pretty_json`, not a plain `json.dumps`: the former rewrites a `script` arg's `"; "` into `";\\n "`, so for tools carrying `script` (code_run, web_execute_js) a plain dumps produces a *different* fence body. The TUI's write/read/code cards content-address their captures by `hash(get_pretty_json(args))`; a mismatched fence here means the hash misses and the card silently falls back to the raw block.""" name = block.get('name', '?') args = block.get('input', {}) try: from agent_loop import get_pretty_json pretty = get_pretty_json(args) except Exception: try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n') except Exception: pretty = str(args) return f"🛠️ Tool: `{name}` 📥 args:\n````text\n{pretty}\n````\n" def _format_tool_result(content): """Match agent_loop.py:79-81 five-backtick fence around tool output.""" if isinstance(content, list): parts = [] for b in content: if isinstance(b, dict) and b.get('type') == 'text': parts.append(b.get('text', '') or '') elif isinstance(b, str): parts.append(b) body = '\n'.join(parts) else: body = '' if content is None else str(content) return f"`````\n{body}\n`````\n" def _tool_results_from_prompt(prompt_body): """Return {tool_use_id: formatted_fence} from a Prompt JSON's content blocks.""" try: msg = json.loads(prompt_body) except Exception: return {} if not isinstance(msg, dict): return {} out = {} for blk in msg.get('content', []) or []: if isinstance(blk, dict) and blk.get('type') == 'tool_result': tid = blk.get('tool_use_id') or '' if tid: out[tid] = _format_tool_result(blk.get('content')) return out def _format_response_segment(response_body, tool_results): """Rebuild one LLM call's transcript slice: text blocks + tool_use headers + matching tool_result fences. Mirrors agent_loop verbose output so fold_turns sees the same string shape as live mode. """ try: blocks = ast.literal_eval(response_body) except Exception: return '' if not isinstance(blocks, list): return '' texts, tool_parts = [], [] for b in blocks: if not isinstance(b, dict): continue t = b.get('type') if t == 'text': s = b.get('text', '') if isinstance(s, str) and s.strip(): texts.append(s) elif t == 'tool_use': tool_parts.append(_format_tool_use(b)) tid = b.get('id') or '' if tid and tid in tool_results: tool_parts.append(tool_results[tid]) return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p) _PLAN_ENTRY_RE = re.compile(r'enter_plan_mode\(\s*[\'"]([^\'"]+plan\.md)[\'"]') def find_plan_entry(path): """Last `enter_plan_mode("…plan.md")` call in a model_responses log. Plan mode has exactly one entry point (plan_sop.md): a `code_run` tool call whose inline_eval script invokes `handler.enter_plan_mode(...)`. That call survives in the log as a structured `tool_use` block — unlike a plan path merely *mentioned* in chat text, it cannot be produced by the user typing a filename. Scanning these blocks is therefore the restore criterion for the plan card; the last match wins so re-entered plans track the newest. Returns the plan.md path string as written in the script, or None. """ try: with open(path, encoding='utf-8', errors='replace') as f: content = f.read() except Exception: return None last = None for _prompt, response in _pairs(content): try: blocks = ast.literal_eval(response) except Exception: continue if not isinstance(blocks, list): continue for b in blocks: if not (isinstance(b, dict) and b.get('type') == 'tool_use' and b.get('name') == 'code_run'): continue m = _PLAN_ENTRY_RE.search(str((b.get('input') or {}).get('script') or '')) if m: last = m.group(1) return last def iter_write_captures(path): """Replay a log's file_write/file_patch/file_read calls into capture dicts the TUI can feed to its card renderers (`_WRITE_CAP`), keyed later by hash(get_pretty_json). Live mode fills `_WRITE_CAP` from tool_before/tool_after hooks (with a real pre-write disk snapshot); on /continue that history is gone, but the structured `tool_use.input` survives in the log — clean, complete args. We also track each path's content *within this session* so a file written/patched several times shows real old→new diffs (not N× full "new file"). Files first touched by an untracked on-disk state still fall back to a full-content block. Returns write entries `{"name", "args", "existed", "old", "status", "msg"}` and read entries `{"name", "args", "content"}` in call order. `status`/`msg` come from the matching tool_result so the header can show ✗ on a failed write; a read's `content` is the raw tool_result text (the read card strips its LLM-facing chrome itself). """ try: with open(path, encoding='utf-8', errors='replace') as f: content = f.read() except Exception: return [] pairs = _pairs(content) # tool_use_id -> (status, msg) from any prompt's tool_result blocks (the # result lands in the *next* round's Prompt as a tool_result whose content # is the json-dumped outcome.data, e.g. {"status":"success","msg":...}). # tr_raw keeps the undecoded text — a file_read result is plain text. tr_status, tr_raw = {}, {} for prompt, _ in pairs: try: msg_obj = json.loads(prompt) except Exception: continue if not isinstance(msg_obj, dict): continue for blk in msg_obj.get('content', []) or []: if not (isinstance(blk, dict) and blk.get('type') == 'tool_result'): continue tid = blk.get('tool_use_id') c = blk.get('content') if isinstance(c, list): c = ''.join(b.get('text', '') for b in c if isinstance(b, dict) and b.get('type') == 'text') if tid and isinstance(c, str): tr_raw[tid] = c try: d = json.loads(c) if isinstance(c, str) else None except Exception: d = None if tid and isinstance(d, dict): tr_status[tid] = (d.get('status'), str(d.get('msg') or '')) out, state = [], {} for _prompt, response in pairs: try: blocks = ast.literal_eval(response) except Exception: continue if not isinstance(blocks, list): continue for b in blocks: if not (isinstance(b, dict) and b.get('type') == 'tool_use'): continue name = b.get('name') if name not in ('file_write', 'file_patch', 'file_read', 'code_run'): continue args = b.get('input') or {} p = args.get('path') if name == 'file_read': out.append({'name': name, 'args': args, 'content': tr_raw.get(b.get('id'))}) continue if name == 'code_run': # data = the tool_result text; a dict result is JSON, an # inline_eval / code-missing result is plain text. Pass the # parsed dict when possible so the card reads exit_code/stdout; # else the raw string (the card handles both). raw = tr_raw.get(b.get('id')) d = raw try: parsed = json.loads(raw) if isinstance(raw, str) else None if isinstance(parsed, dict): d = parsed except Exception: pass out.append({'name': name, 'args': args, 'data': d}) continue st, mg = tr_status.get(b.get('id'), (None, '')) if name == 'file_patch': # If this file's content is tracked within the session, pass it as # the pre-write full file so the renderer can do a whole-file diff # (real line numbers + context); else fall back to the fragment. pre = state.get(p, '') out.append({'name': name, 'args': args, 'existed': p in state, 'old': pre, 'status': st, 'msg': mg}) if st == 'error': continue # failed call left the disk untouched — don't book it old = args.get('old_content') or '' if p in state and old: state[p] = state[p].replace(old, args.get('new_content') or '', 1) else: # file_write existed = p in state old = state.get(p, '') new = str(args.get('content') or '') mode = str(args.get('mode') or 'overwrite') out.append({'name': name, 'args': args, 'existed': existed, 'old': old, 'status': st, 'msg': mg}) if st == 'error': continue # failed call left the disk untouched — don't book it if mode == 'append': state[p] = old + new elif mode == 'prepend': state[p] = new + old else: state[p] = new return out def extract_ui_messages(path): """Parse a model_responses log into [{role, content}, ...] for UI replay. Each user-initiated round becomes one user bubble plus one assistant bubble. Auto-continuation LLM calls are concatenated into the same assistant bubble, separated by ``**LLM Running (Turn N) ...**`` markers. Tool calls and their results are rendered into the assistant content using the same string format that agent_loop yields live, so fold_turns can fold them identically. """ try: with open(path, encoding='utf-8', errors='replace') as f: content = f.read() except Exception: return [] pairs = _pairs(content) if not pairs: return [] # tool_results live in the *next* Prompt's content; index look-ahead. next_tr = [{} for _ in pairs] for i in range(len(pairs) - 1): next_tr[i] = _tool_results_from_prompt(pairs[i + 1][0]) out, assistant, round_turn = [], None, 0 for i, (prompt, response) in enumerate(pairs): user = _user_text(prompt) seg = _format_response_segment(response, next_tr[i]) if user: if assistant is not None: out.append(assistant) out.append({'role': 'user', 'content': user}) # Turn 1 marker too — agent_loop yields one per LLM call, including the # first, so fold_turns treats every non-last call uniformly as a fold. assistant = {'role': 'assistant', 'content': f"\n\n**LLM Running (Turn 1) ...**\n\n{seg}"} round_turn = 1 else: if assistant is None: assistant = {'role': 'assistant', 'content': ''} round_turn = 1 round_turn += 1 marker = f"\n\n**LLM Running (Turn {round_turn}) ...**\n\n" assistant['content'] = (assistant['content'] or '') + marker + seg if assistant is not None: out.append(assistant) return [m for m in out if (m.get('content') or '').strip()] def handle_frontend_command(agent, query, exclude_pid=None): """Frontend-friendly /continue entry that returns text directly.""" s = (query or '').strip() exclude_pid = os.getpid() if exclude_pid is None else exclude_pid if s == '/continue': return format_list(list_sessions(exclude_pid=exclude_pid)) m = re.match(r'/continue\s+(\d+)\s*$', s) if not m: return '用法: /continue 或 /continue N' sessions = list_sessions(exclude_pid=exclude_pid) idx = int(m.group(1)) - 1 if not (0 <= idx < len(sessions)): return f'❌ 索引越界(有效范围 1-{len(sessions)})' reset_conversation(agent, message=None) msg, _ = restore(agent, sessions[idx][0]) return msg # =========================================================================== # 原地复原(in-place continue)共享层 —— 仅供 TUI(tui_v2/tui_v3 及其 rewind 副本) # 调用;其它前端(IM/qt/streamlit…)不调用这些函数,行为完全不受影响。 # # 模型:每个会话 = 一个 `model_responses_.txt`,身份就是文件本身。 # · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。 # · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。 # · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。 # # 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/.lock`); # 整进程共用一个心跳线程,每 ~5s touch 锁文件 mtime(无 fsync)。 # 判活 = 锁 mtime 在 30s 内新鲜;超 30s 视为持锁者已死,可被接管。 # 抢锁用原子 O_EXCL;锁基础设施任何故障都降级为"假定空闲、放行续接",绝不阻断 /continue。 # =========================================================================== _LOCK_DIR = os.path.join(_LOG_DIR, '.locks') _HB_INTERVAL = 5.0 # 心跳间隔(秒) _STALE_AFTER = 30.0 # 超过这么久无心跳 → 持锁者视为已死,可接管 _held_locks = set() # 本进程当前持有的 log_path 集合 _hb_lock = threading.Lock() _hb_thread = None def _lock_path(log_path): base = os.path.splitext(os.path.basename(log_path))[0] return os.path.join(_LOCK_DIR, base + '.lock') def _read_lock(lock_file): try: with open(lock_file, encoding='utf-8') as fh: return json.load(fh) except Exception: return None def _lock_fresh(lock_file): """心跳新鲜度 = 锁文件 mtime 距今 < _STALE_AFTER。""" try: return (time.time() - os.path.getmtime(lock_file)) < _STALE_AFTER except OSError: return False def session_occupant(log_path): """若 `log_path` 正被一个活着的(心跳新鲜)进程持有,返回其 owner 元数据 dict; 否则返回 None(空闲,或锁已过期可接管)。供 TUI 判断"原地 / 弹窗拷贝"。""" lf = _lock_path(log_path) meta = _read_lock(lf) if meta is not None and _lock_fresh(lf): return meta return None def acquire_lock(log_path, agent_id=None): """尝试独占 `log_path`。成功(或锁设施故障降级)返回 True; 仅当被另一活进程(心跳新鲜)持有时返回 False。""" try: os.makedirs(_LOCK_DIR, exist_ok=True) lf = _lock_path(log_path) meta = {'pid': os.getpid(), 'agent_id': agent_id, 'log': os.path.basename(log_path), 'started': time.time()} blob = json.dumps(meta, ensure_ascii=False) try: fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) with os.fdopen(fd, 'w', encoding='utf-8') as fh: fh.write(blob) except FileExistsError: cur = _read_lock(lf) if cur and cur.get('pid') != os.getpid() and _lock_fresh(lf): return False # 被另一活进程持有 # 过期锁 / 本进程自己的 → 接管(覆盖)。小竞态窗口可接受。 try: os.remove(lf) except OSError: pass try: fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) with os.fdopen(fd, 'w', encoding='utf-8') as fh: fh.write(blob) except FileExistsError: cur2 = _read_lock(lf) if cur2 and cur2.get('pid') != os.getpid() and _lock_fresh(lf): return False # 抢锁竞态输了 with open(lf, 'w', encoding='utf-8') as fh: fh.write(blob) with _hb_lock: _held_locks.add(log_path) _ensure_hb_thread() return True except Exception: # 锁设施故障绝不阻断续接 —— 降级为"假定空闲、放行"。 return True def release_lock(log_path): """释放本进程对 `log_path` 的锁(只删自己持有/无主的锁文件)。""" with _hb_lock: _held_locks.discard(log_path) try: lf = _lock_path(log_path) cur = _read_lock(lf) if cur is None or cur.get('pid') == os.getpid(): os.remove(lf) except Exception: pass def _hb_tick(): now = time.time() with _hb_lock: items = list(_held_locks) for lp in items: try: os.utime(_lock_path(lp), (now, now)) # 仅更新 mtime,无 fsync except OSError: pass def _hb_loop(): while True: time.sleep(_HB_INTERVAL) try: _hb_tick() except Exception: pass def _ensure_hb_thread(): global _hb_thread with _hb_lock: if _hb_thread is None: _hb_thread = threading.Thread(target=_hb_loop, name='ga-session-heartbeat', daemon=True) _hb_thread.start() @atexit.register def _release_all_locks(): for lp in list(_held_locks): release_lock(lp) def _new_log_path(): """铸一个新的 6 位 logid 日志路径(与 agentmain 同公式)。""" logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' return os.path.join(_LOG_DIR, f'model_responses_{logid}.txt') def _retarget_log(agent, new_path): """把 agent(及其所有 llmclient)的日志写入点切到 new_path —— 之后的轮次写这里。""" try: agent.log_path = new_path except Exception: pass for client in _agent_clients(agent): try: client.log_path = new_path except Exception: pass def is_snapshot(path): """遗留快照存档(model_responses_snapshot_*.txt)。这类只能拷贝续,不参与原地 (provisional,待 worktree 复审)。""" return os.path.basename(path).startswith('model_responses_snapshot_') def _clear_conversation_state(agent): """清空对话状态(对齐 reset_conversation,但不碰日志文件)。""" if hasattr(agent, 'history'): agent.history = [] for client in _agent_clients(agent): backend = getattr(client, 'backend', None) if backend is not None and hasattr(backend, 'history'): backend.history = [] if hasattr(client, 'last_tools'): client.last_tools = '' if hasattr(agent, 'handler'): agent.handler = None def acquire_birth_lock(agent, agent_id=None): """会话出生时持有自己当前日志的锁(新 logid 必然抢到)。TUI 在建会话时调用, 使本会话对"占用检测"可见 —— 别的会话才能据此判定它是否还活着。""" lp = getattr(agent, 'log_path', '') or '' if lp: acquire_lock(lp, agent_id) def release_current(agent): """切走:释放 agent 当前日志的锁,旧日志原样留作"空闲会话"(不存快照、不清空)。""" lp = getattr(agent, 'log_path', '') or '' if lp: release_lock(lp) def begin_fresh_session(agent, agent_id=None): """新对话 / clear:释放当前锁(旧日志留作空闲会话)→ 铸新 logid 重指 → 持新锁 → 清空对话状态。**替代 TUI 里的 reset_conversation**(不再存快照/清空旧日志)。""" try: agent.abort() except Exception: pass release_current(agent) newp = _new_log_path() _retarget_log(agent, newp) acquire_lock(newp, agent_id) _clear_conversation_state(agent) def _load_history_into(agent, path, restore_wm=False): """把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析, 但不 abort/不快照(日志重指由调用方先做好)。返回 (msg, is_full)。 `restore_wm`(默认 False,仅显式传入的调用方启用,如 worldline TUI):从同一份日志 派生 history_info 写回 `agent.history`,使续接后工作记忆不丢。默认关闭 → 其它前端 行为完全不变。""" try: with open(path, encoding='utf-8', errors='replace') as fh: content = fh.read() except Exception as e: return f'❌ 读取失败: {e}', False pairs = _pairs(content) if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False history = _parse_native_history(pairs) name = os.path.basename(path) if history is not None: _replace_backend_history(agent, history) if restore_wm and hasattr(agent, 'history'): agent.history = _derive_hist_info(history) # 续接恢复工作记忆(opt-in) return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})', True from chatapp_common import _restore_native_history, _restore_text_pairs summary = _restore_text_pairs(content) or _restore_native_history(content) if not summary: return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False if hasattr(agent, 'history'): agent.history.extend(summary) n = sum(1 for l in summary if l.startswith('[USER]: ')) return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False def _is_empty_log(path): """日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。""" try: return os.path.getsize(path) < 32 except OSError: return True def continue_inplace(agent, path, agent_id=None, allow_empty=False, restore_wm=False): """原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。 调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。 `allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话, 由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。 `restore_wm`(opt-in):续接后从日志派生 history_info 恢复工作记忆。返回 (msg, ok)。""" try: agent.abort() except Exception: pass if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁 return '❌ 会话已被占用,无法原地接管', False cur = getattr(agent, 'log_path', '') or '' if cur and os.path.basename(cur) != os.path.basename(path): release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放) _retarget_log(agent, path) msg, ok = _load_history_into(agent, path, restore_wm=restore_wm) if not ok and allow_empty and _is_empty_log(path): _replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清) return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True return msg, ok def continue_copy(agent, path, agent_id=None, allow_empty=False, restore_wm=False): """拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。 用于"被占用→用户选拷贝"以及快照源。`restore_wm`(opt-in)同 continue_inplace。 返回 (msg, ok)。""" try: agent.abort() except Exception: pass release_current(agent) newp = _new_log_path() try: shutil.copyfile(path, newp) except Exception: pass acquire_lock(newp, agent_id) _retarget_log(agent, newp) msg, ok = _load_history_into(agent, newp, restore_wm=restore_wm) if not ok and allow_empty and _is_empty_log(newp): _replace_backend_history(agent, []) return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True return msg, ok def install(cls): """Wrap cls._handle_slash_cmd so /continue is handled before original dispatch.""" orig = cls._handle_slash_cmd if getattr(orig, '_continue_patched', False): return def patched(self, raw_query, display_queue): if (raw_query or '').startswith('/continue'): r = handle(self, raw_query, display_queue) if r is None: return None return orig(self, raw_query, display_queue) patched._continue_patched = True cls._handle_slash_cmd = patched