bareagent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,202 @@
1
+ """Stdio transport: subprocess + newline-delimited JSON.
2
+
3
+ Two background daemon threads consume the subprocess pipes:
4
+
5
+ - `_reader`: parses stdout as NDJSON, routes responses + notifications.
6
+ - `_stderr_reader`: drains stderr into the project logger so server banners
7
+ do not block the pipe.
8
+
9
+ On EOF / subprocess death, every pending future is failed with
10
+ `MCPTransportError` so callers never hang forever.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import subprocess
17
+ import threading
18
+ from collections.abc import Iterator
19
+ from typing import IO
20
+
21
+ from ..errors import MCPProtocolError, MCPTransportError
22
+ from ..protocol import Notification, Request, Response, decode_message
23
+ from .base import Transport
24
+
25
+ _log = logging.getLogger(__name__)
26
+
27
+ _TERMINATE_TIMEOUT = 2.0
28
+ _KILL_TIMEOUT = 2.0
29
+
30
+
31
+ class StdioTransport(Transport):
32
+ """Spawn an MCP server as a subprocess and talk to it over stdin/stdout."""
33
+
34
+ def __init__(
35
+ self,
36
+ command: list[str],
37
+ *,
38
+ env: dict[str, str] | None = None,
39
+ cwd: str | None = None,
40
+ ) -> None:
41
+ super().__init__()
42
+ if not command:
43
+ raise ValueError("command must be a non-empty list")
44
+ self._command = list(command)
45
+ self._env = env
46
+ self._cwd = cwd
47
+ self._proc: subprocess.Popen[bytes] | None = None
48
+ self._reader: threading.Thread | None = None
49
+ self._stderr_reader: threading.Thread | None = None
50
+ self._write_lock = threading.Lock()
51
+ self._closed = False
52
+ # Set true by ``close()`` so the reader thread can distinguish graceful
53
+ # shutdown (don't fire disconnect handler) from unexpected EOF.
54
+ self._closing = False
55
+
56
+ def start(self) -> None:
57
+ if self._proc is not None:
58
+ raise RuntimeError("StdioTransport already started")
59
+ try:
60
+ self._proc = subprocess.Popen(
61
+ self._command,
62
+ stdin=subprocess.PIPE,
63
+ stdout=subprocess.PIPE,
64
+ stderr=subprocess.PIPE,
65
+ env=self._env,
66
+ cwd=self._cwd,
67
+ bufsize=0,
68
+ )
69
+ except (OSError, FileNotFoundError) as exc:
70
+ raise MCPTransportError(f"failed to spawn MCP server: {exc}") from exc
71
+
72
+ self._reader = threading.Thread(
73
+ target=self._read_loop, name="mcp-stdio-reader", daemon=True
74
+ )
75
+ self._stderr_reader = threading.Thread(
76
+ target=self._stderr_loop, name="mcp-stdio-stderr", daemon=True
77
+ )
78
+ self._reader.start()
79
+ self._stderr_reader.start()
80
+
81
+ def send(self, message: str) -> None:
82
+ proc = self._proc
83
+ if proc is None or proc.stdin is None:
84
+ raise MCPTransportError("transport not started")
85
+ if proc.poll() is not None:
86
+ raise MCPTransportError(f"subprocess exited with code {proc.returncode}")
87
+ line = message + "\n"
88
+ try:
89
+ with self._write_lock:
90
+ proc.stdin.write(line.encode("utf-8"))
91
+ proc.stdin.flush()
92
+ except (BrokenPipeError, OSError) as exc:
93
+ raise MCPTransportError(
94
+ f"failed to write to subprocess stdin: {exc}"
95
+ ) from exc
96
+
97
+ def close(self) -> None:
98
+ if self._closed:
99
+ return
100
+ self._closed = True
101
+ self._closing = True
102
+ proc = self._proc
103
+ if proc is None:
104
+ return
105
+ # Graceful shutdown: close stdin → wait → terminate → wait → kill.
106
+ try:
107
+ if proc.stdin is not None and not proc.stdin.closed:
108
+ with self._write_lock:
109
+ try:
110
+ proc.stdin.close()
111
+ except OSError:
112
+ pass
113
+ try:
114
+ proc.wait(timeout=_TERMINATE_TIMEOUT)
115
+ except subprocess.TimeoutExpired:
116
+ proc.terminate()
117
+ try:
118
+ proc.wait(timeout=_KILL_TIMEOUT)
119
+ except subprocess.TimeoutExpired:
120
+ proc.kill()
121
+ try:
122
+ proc.wait(timeout=_KILL_TIMEOUT)
123
+ except subprocess.TimeoutExpired:
124
+ pass
125
+ finally:
126
+ self._fail_all_pending("stdio transport closed")
127
+
128
+ def is_alive(self) -> bool:
129
+ proc = self._proc
130
+ return proc is not None and proc.poll() is None
131
+
132
+ # --- background threads ---
133
+
134
+ def _read_loop(self) -> None:
135
+ proc = self._proc
136
+ assert proc is not None and proc.stdout is not None
137
+ disconnect_reason: str | None = None
138
+ try:
139
+ for raw in self._iter_lines(proc.stdout):
140
+ line = raw.decode("utf-8", errors="replace").strip()
141
+ if not line:
142
+ continue
143
+ try:
144
+ msg = decode_message(line)
145
+ except MCPProtocolError as exc:
146
+ _log.warning(
147
+ "MCP stdio: ignoring non-protocol line (%s): %r",
148
+ exc,
149
+ line[:200],
150
+ )
151
+ continue
152
+ self._dispatch(msg)
153
+ except Exception as exc: # pragma: no cover — defensive
154
+ disconnect_reason = f"stdio reader crashed: {exc}"
155
+ _log.warning("MCP stdio reader crashed: %s", exc)
156
+ finally:
157
+ # Distinguish graceful (user called close) from unexpected EOF /
158
+ # subprocess crash. Only fire disconnect handler in the unexpected
159
+ # case so a normal shutdown does not spuriously notify the manager.
160
+ if not self._closing:
161
+ if disconnect_reason is None:
162
+ returncode = proc.poll()
163
+ if returncode is not None:
164
+ disconnect_reason = (
165
+ f"stdout EOF (subprocess exited with code {returncode})"
166
+ )
167
+ else:
168
+ disconnect_reason = "stdout EOF (subprocess exited)"
169
+ self._invoke_disconnect(disconnect_reason)
170
+ self._fail_all_pending("stdio reader exited (subprocess EOF or error)")
171
+
172
+ def _stderr_loop(self) -> None:
173
+ proc = self._proc
174
+ assert proc is not None and proc.stderr is not None
175
+ try:
176
+ for raw in self._iter_lines(proc.stderr):
177
+ text = raw.decode("utf-8", errors="replace").rstrip()
178
+ if text:
179
+ _log.warning("MCP server stderr: %s", text)
180
+ except Exception: # pragma: no cover
181
+ pass
182
+
183
+ @staticmethod
184
+ def _iter_lines(stream: IO[bytes]) -> Iterator[bytes]:
185
+ # subprocess pipes opened in bytes mode iterate by line (\n-delimited).
186
+ while True:
187
+ line = stream.readline()
188
+ if not line:
189
+ return
190
+ yield line
191
+
192
+ def _dispatch(self, msg: Request | Response | Notification) -> None:
193
+ if isinstance(msg, Response):
194
+ self._route_response(msg)
195
+ elif isinstance(msg, Notification):
196
+ self._route_notification(msg)
197
+ else:
198
+ # Server-initiated request. PR1 does not support these; in PR2 the
199
+ # client will handle method dispatch (ping, sampling, elicitation).
200
+ _log.warning(
201
+ "MCP stdio: ignoring server-to-client request (method=%r)", msg.method
202
+ )
@@ -0,0 +1 @@
1
+ """Memory modules for BareAgent."""
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import logging
5
+ from typing import Any
6
+
7
+ from bareagent.core.fileutil import collect_tool_names, is_tool_result_message, stringify
8
+ from bareagent.memory.token_counter import estimate_tokens
9
+ from bareagent.memory.transcript import TranscriptManager
10
+ from bareagent.provider.base import BaseLLMProvider
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _TRUNCATED_PREFIX = "[truncated:"
15
+ _SUMMARY_SYSTEM_PROMPT = (
16
+ "你是 BareAgent 的上下文压缩助手。请用中文总结对话中的目标、已完成工作、"
17
+ "关键约束、重要文件路径、工具执行结果要点,以及接下来继续工作所需的上下文。"
18
+ "输出简洁但不能遗漏事实。"
19
+ )
20
+
21
+
22
+ def _micro_compact(
23
+ messages: list[dict[str, Any]],
24
+ keep_recent: int = 3,
25
+ tool_name_by_id: dict[str, str] | None = None,
26
+ ) -> dict[str, str]:
27
+ if tool_name_by_id is None:
28
+ tool_name_by_id = collect_tool_names(messages)
29
+ tool_result_indices = [
30
+ index
31
+ for index, message in enumerate(messages)
32
+ if is_tool_result_message(message)
33
+ ]
34
+ if keep_recent > 0:
35
+ compact_indices = set(tool_result_indices[:-keep_recent])
36
+ else:
37
+ compact_indices = set(tool_result_indices)
38
+
39
+ for index in compact_indices:
40
+ message = messages[index]
41
+ content = message.get("content")
42
+ if not isinstance(content, list):
43
+ continue
44
+ for block in content:
45
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
46
+ continue
47
+ original_text = stringify(block.get("content", ""))
48
+ if original_text.startswith(_TRUNCATED_PREFIX):
49
+ continue
50
+ tool_use_id = str(block.get("tool_use_id", ""))
51
+ tool_name = tool_name_by_id.get(tool_use_id, "unknown")
52
+ block["content"] = (
53
+ f"[truncated: {tool_name} result, {len(original_text)} chars]"
54
+ )
55
+ return tool_name_by_id
56
+
57
+
58
+ def _serialize(
59
+ messages: list[dict[str, Any]],
60
+ tool_name_by_id: dict[str, str] | None = None,
61
+ ) -> str:
62
+ lines: list[str] = []
63
+ if tool_name_by_id is None:
64
+ tool_name_by_id = collect_tool_names(messages)
65
+ for message in messages:
66
+ role = str(message.get("role", "unknown"))
67
+ lines.append(f"[{role}]")
68
+ lines.append(_serialize_content(message.get("content"), tool_name_by_id))
69
+ lines.append("")
70
+ return "\n".join(lines).strip()
71
+
72
+
73
+ class Compactor:
74
+ def __init__(
75
+ self,
76
+ provider: BaseLLMProvider,
77
+ transcript_mgr: TranscriptManager | None,
78
+ threshold: int = 50000,
79
+ session_id: str = "default",
80
+ ) -> None:
81
+ self._provider = provider
82
+ self._transcript_mgr = transcript_mgr
83
+ self._threshold = threshold
84
+ self._session_id = session_id
85
+
86
+ def get_session_id(self) -> str:
87
+ return self._session_id
88
+
89
+ def set_session_id(self, new_session_id: str) -> None:
90
+ self._session_id = new_session_id
91
+
92
+ def __call__(self, messages: list[dict[str, Any]], force: bool = False) -> None:
93
+ if not force and estimate_tokens(messages) <= self._threshold:
94
+ return
95
+
96
+ _backup = [_clone_message(m) for m in messages]
97
+
98
+ tool_name_by_id = _micro_compact(messages, keep_recent=3)
99
+
100
+ history_messages, pending_user_message = _split_pending_user_turn(messages)
101
+ summary_source_messages = [
102
+ message for message in history_messages if message.get("role") != "system"
103
+ ]
104
+ if not summary_source_messages:
105
+ messages[:] = _backup
106
+ return
107
+
108
+ if self._transcript_mgr is not None:
109
+ self._transcript_mgr.save(messages, self._session_id)
110
+ try:
111
+ summary = self._provider.create(
112
+ messages=[
113
+ {"role": "system", "content": _SUMMARY_SYSTEM_PROMPT},
114
+ {
115
+ "role": "user",
116
+ "content": (
117
+ "请简洁总结以下对话的关键信息和已完成的工作,供后续继续工作使用:\n\n"
118
+ + _serialize(summary_source_messages, tool_name_by_id)
119
+ ),
120
+ },
121
+ ],
122
+ tools=[],
123
+ max_tokens=2000,
124
+ )
125
+ except Exception:
126
+ logger.warning("Context compression failed", exc_info=True)
127
+ messages[:] = _backup
128
+ return
129
+
130
+ system_messages = [
131
+ _clone_message(message)
132
+ for message in messages
133
+ if message.get("role") == "system"
134
+ ]
135
+ messages.clear()
136
+ messages.extend(system_messages)
137
+ messages.extend(
138
+ [
139
+ {"role": "user", "content": f"[Context Compressed]\n{summary.text}"},
140
+ {
141
+ "role": "assistant",
142
+ "content": "收到,我已理解之前的上下文,继续工作。",
143
+ },
144
+ ]
145
+ )
146
+ if pending_user_message is not None:
147
+ messages.append(pending_user_message)
148
+
149
+
150
+ make_compact_fn = Compactor
151
+
152
+
153
+ def _serialize_content(content: Any, tool_name_by_id: dict[str, str]) -> str:
154
+ if content is None:
155
+ return ""
156
+ if isinstance(content, str):
157
+ return content
158
+ if isinstance(content, list):
159
+ parts: list[str] = []
160
+ for block in content:
161
+ serialized = _serialize_block(block, tool_name_by_id)
162
+ if serialized:
163
+ parts.append(serialized)
164
+ return "\n".join(parts)
165
+ return stringify(content)
166
+
167
+
168
+ def _serialize_block(block: Any, tool_name_by_id: dict[str, str]) -> str:
169
+ if isinstance(block, str):
170
+ return block
171
+ if not isinstance(block, dict):
172
+ return stringify(block)
173
+
174
+ block_type = block.get("type")
175
+ if block_type == "text":
176
+ return str(block.get("text", ""))
177
+ if block_type == "tool_use":
178
+ return (
179
+ f"[tool_use:{block.get('name', 'unknown')}] "
180
+ f"{stringify(block.get('input', {}))}"
181
+ )
182
+ if block_type == "tool_result":
183
+ tool_use_id = str(block.get("tool_use_id", ""))
184
+ tool_name = tool_name_by_id.get(tool_use_id, "unknown")
185
+ return f"[tool_result:{tool_name}] {stringify(block.get('content', ''))}"
186
+
187
+ if "content" in block:
188
+ return _serialize_content(block.get("content"), tool_name_by_id)
189
+ if "text" in block:
190
+ return str(block.get("text", ""))
191
+ return stringify(block)
192
+
193
+
194
+ def _clone_message(message: dict[str, Any]) -> dict[str, Any]:
195
+ return copy.deepcopy(message)
196
+
197
+
198
+ def _split_pending_user_turn(
199
+ messages: list[dict[str, Any]],
200
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
201
+ if messages and messages[-1].get("role") == "user":
202
+ return messages[:-1], _clone_message(messages[-1])
203
+ return messages, None
@@ -0,0 +1,226 @@
1
+ """Conversation import/export serialization (REPL-independent, unit-testable).
2
+
3
+ Pure helpers backing the ``/export`` and ``/import`` REPL commands:
4
+
5
+ - :func:`render_markdown` — human-readable Markdown (mirrors the traversal
6
+ structure of ``main._replay_stdio_transcript``).
7
+ - :func:`to_export_json` — self-contained, faithful JSON wrapper.
8
+ - :func:`parse_import` — auto-detecting loader with shape validation.
9
+
10
+ No dependency on ``bareagent.main`` / UI / loop, so this module can be exercised in
11
+ isolation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ EXPORT_VERSION = 1
20
+ _DEFAULT_MAX_TOOL_CHARS = 2000
21
+ _TOOL_INPUT_PREVIEW_CHARS = 200
22
+ _TRUNCATION_MARKER = "… (truncated)"
23
+
24
+
25
+ def _truncate(text: str, limit: int) -> str:
26
+ if limit < 0 or len(text) <= limit:
27
+ return text
28
+ return text[:limit] + _TRUNCATION_MARKER
29
+
30
+
31
+ def _stringify_content(content: Any) -> str:
32
+ """Coerce arbitrary tool_result content into display text."""
33
+ if isinstance(content, str):
34
+ return content
35
+ if content is None:
36
+ return ""
37
+ return json.dumps(content, ensure_ascii=False, default=str)
38
+
39
+
40
+ def _tool_use_summary(block: dict[str, Any]) -> str:
41
+ name = str(block.get("name", "unknown"))
42
+ raw_input = block.get("input", {})
43
+ preview = json.dumps(raw_input, ensure_ascii=False, default=str)
44
+ preview = _truncate(preview, _TOOL_INPUT_PREVIEW_CHARS)
45
+ return f"- **Tool call** `{name}`: `{preview}`"
46
+
47
+
48
+ def render_markdown(
49
+ messages: list[dict[str, Any]],
50
+ *,
51
+ include_thinking: bool = False,
52
+ max_tool_chars: int = _DEFAULT_MAX_TOOL_CHARS,
53
+ title: str | None = None,
54
+ ) -> str:
55
+ """Render *messages* as human-readable Markdown.
56
+
57
+ Skips ``system`` messages, renders user/assistant text, turns ``tool_use``
58
+ blocks into single-line summaries and ``tool_result`` blocks into truncated
59
+ code fences. ``thinking`` blocks are omitted unless *include_thinking* is
60
+ set. Mirrors the traversal of ``main._replay_stdio_transcript`` (including
61
+ the ``tool_use_id`` → tool name association). *title*, when provided, is
62
+ emitted as a top-level heading.
63
+ """
64
+ tool_name_by_id: dict[str, str] = {}
65
+ lines: list[str] = []
66
+
67
+ if title:
68
+ lines.append(f"# {title}")
69
+ lines.append("")
70
+
71
+ for message in messages:
72
+ role = message.get("role")
73
+ content = message.get("content")
74
+
75
+ if role == "system":
76
+ continue
77
+
78
+ if role == "user":
79
+ if isinstance(content, str):
80
+ lines.append("## User")
81
+ lines.append("")
82
+ lines.append(content)
83
+ lines.append("")
84
+ continue
85
+ if isinstance(content, list):
86
+ for block in content:
87
+ if not isinstance(block, dict):
88
+ continue
89
+ if block.get("type") == "text":
90
+ text_value = str(block.get("text", ""))
91
+ if text_value:
92
+ lines.append("## User")
93
+ lines.append("")
94
+ lines.append(text_value)
95
+ lines.append("")
96
+ continue
97
+ if block.get("type") != "tool_result":
98
+ continue
99
+ tool_name = tool_name_by_id.get(
100
+ str(block.get("tool_use_id", "")),
101
+ "unknown",
102
+ )
103
+ result_text = _truncate(
104
+ _stringify_content(block.get("content", "")),
105
+ max_tool_chars,
106
+ )
107
+ error_marker = " (error)" if block.get("is_error") else ""
108
+ lines.append(f"### Tool result: {tool_name}{error_marker}")
109
+ lines.append("")
110
+ lines.append("```")
111
+ lines.append(result_text)
112
+ lines.append("```")
113
+ lines.append("")
114
+ continue
115
+
116
+ if role != "assistant":
117
+ continue
118
+
119
+ if isinstance(content, str):
120
+ lines.append("## Assistant")
121
+ lines.append("")
122
+ lines.append(content)
123
+ lines.append("")
124
+ continue
125
+ if not isinstance(content, list):
126
+ continue
127
+
128
+ for block in content:
129
+ if not isinstance(block, dict):
130
+ continue
131
+ block_type = block.get("type")
132
+ if block_type == "text":
133
+ text_value = str(block.get("text", ""))
134
+ if text_value:
135
+ lines.append("## Assistant")
136
+ lines.append("")
137
+ lines.append(text_value)
138
+ lines.append("")
139
+ continue
140
+ if block_type == "thinking":
141
+ if not include_thinking:
142
+ continue
143
+ thinking_value = str(block.get("thinking", ""))
144
+ if thinking_value:
145
+ lines.append("### Thinking")
146
+ lines.append("")
147
+ lines.append("```")
148
+ lines.append(thinking_value)
149
+ lines.append("```")
150
+ lines.append("")
151
+ continue
152
+ if block_type != "tool_use":
153
+ continue
154
+
155
+ tool_id = str(block.get("id", ""))
156
+ if tool_id:
157
+ tool_name_by_id[tool_id] = str(block.get("name", "unknown"))
158
+ lines.append(_tool_use_summary(block))
159
+ lines.append("")
160
+
161
+ return "\n".join(lines).rstrip("\n") + "\n"
162
+
163
+
164
+ def to_export_json(
165
+ messages: list[dict[str, Any]],
166
+ *,
167
+ session_id: str,
168
+ exported_at: str,
169
+ ) -> str:
170
+ """Serialize *messages* into a self-contained, faithful JSON wrapper.
171
+
172
+ The wrapper preserves every message verbatim (including ``system`` /
173
+ ``thinking`` / tool blocks) so a round-trip through :func:`parse_import`
174
+ recovers an equivalent conversation.
175
+ """
176
+ payload = {
177
+ "version": EXPORT_VERSION,
178
+ "session_id": session_id,
179
+ "exported_at": exported_at,
180
+ "messages": messages,
181
+ }
182
+ return json.dumps(payload, ensure_ascii=False, indent=2)
183
+
184
+
185
+ def _validate_messages(value: Any) -> list[dict[str, Any]]:
186
+ if not isinstance(value, list):
187
+ raise ValueError("conversation must be a list of messages")
188
+ for index, item in enumerate(value):
189
+ if not isinstance(item, dict):
190
+ raise ValueError(f"message at index {index} is not an object")
191
+ if "role" not in item:
192
+ raise ValueError(f"message at index {index} is missing a 'role' key")
193
+ return value
194
+
195
+
196
+ def parse_import(text: str) -> list[dict[str, Any]]:
197
+ """Parse imported conversation *text* into validated messages.
198
+
199
+ Auto-detects the format: first try parsing the whole document as JSON — a
200
+ dict with a ``messages`` key yields those messages, a bare list is used
201
+ directly. If whole-document JSON parsing fails, fall back to JSONL
202
+ (one JSON object per non-blank line).
203
+
204
+ Validation: the result must be a list where every element is a dict
205
+ containing a ``role`` key; otherwise :class:`ValueError` is raised with a
206
+ human-readable reason. No other rewriting is performed (faithful load).
207
+ """
208
+ try:
209
+ document = json.loads(text)
210
+ except json.JSONDecodeError:
211
+ messages: list[Any] = []
212
+ for line_no, line in enumerate(text.splitlines(), 1):
213
+ stripped = line.strip()
214
+ if not stripped:
215
+ continue
216
+ try:
217
+ messages.append(json.loads(stripped))
218
+ except json.JSONDecodeError as exc:
219
+ raise ValueError(f"invalid JSON on line {line_no}: {exc}") from exc
220
+ return _validate_messages(messages)
221
+
222
+ if isinstance(document, dict):
223
+ if "messages" not in document:
224
+ raise ValueError("JSON object is missing a 'messages' key")
225
+ return _validate_messages(document["messages"])
226
+ return _validate_messages(document)