terno-agent 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.
@@ -0,0 +1,7 @@
1
+ """terno-agent: a multi-agent CLI that answers questions about your database."""
2
+
3
+ from terno_agent.agents.orchestrator import Orchestrator as Agent
4
+ from terno_agent.config import Config
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["Agent", "Config", "__version__"]
@@ -0,0 +1,4 @@
1
+ from terno_agent.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,6 @@
1
+ from terno_agent.agents.base import AgentRun, BaseAgent
2
+ from terno_agent.agents.coder import CoderAgent
3
+ from terno_agent.agents.database import DatabaseAgent
4
+ from terno_agent.agents.orchestrator import Orchestrator
5
+
6
+ __all__ = ["AgentRun", "BaseAgent", "CoderAgent", "DatabaseAgent", "Orchestrator"]
@@ -0,0 +1,123 @@
1
+ """Agent run loop.
2
+
3
+ A `BaseAgent` ties together an LLM client, a system prompt, and a set of
4
+ tools. The `run` loop is the standard "think → call tools → think" cycle,
5
+ terminating when the model produces a final assistant message with no tool
6
+ calls (or when a per-agent iteration cap is reached).
7
+
8
+ The agent emits `AgentEvent`s to an optional ``on_event`` hook: streamed
9
+ text deltas, tool calls, tool results, and turn endings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Iterable
16
+
17
+ from terno_agent.core.events import (
18
+ EventHook,
19
+ IterationStart,
20
+ TextDelta,
21
+ ToolCallEvent,
22
+ ToolResultEvent,
23
+ TurnEnd,
24
+ )
25
+ from terno_agent.core.exceptions import AgentError, ToolError
26
+ from terno_agent.core.messages import (
27
+ Message,
28
+ SystemMessage,
29
+ ToolCall,
30
+ ToolResult,
31
+ ToolResultMessage,
32
+ UserMessage,
33
+ )
34
+ from terno_agent.core.tool import Tool
35
+ from terno_agent.llm.base import LLMClient
36
+
37
+ Trace = list[Message]
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class AgentRun:
42
+ answer: str
43
+ trace: Trace = field(default_factory=list)
44
+ iterations: int = 0
45
+
46
+
47
+ class BaseAgent:
48
+ name: str = "agent"
49
+ max_iterations: int = 12
50
+
51
+ def __init__(
52
+ self,
53
+ llm: LLMClient,
54
+ system_prompt: str,
55
+ tools: Iterable[Tool] = (),
56
+ *,
57
+ on_event: EventHook | None = None,
58
+ ) -> None:
59
+ self.llm = llm
60
+ self.system_prompt = system_prompt
61
+ self.tools: dict[str, Tool] = {t.schema.name: t for t in tools}
62
+ self.on_event = on_event
63
+
64
+ def run(self, task: str, *, extra_context: str | None = None) -> AgentRun:
65
+ system = self.system_prompt
66
+ if extra_context:
67
+ system += "\n\n---\n" + extra_context
68
+
69
+ messages: Trace = [SystemMessage(system), UserMessage(task)]
70
+
71
+ for i in range(1, self.max_iterations + 1):
72
+ self._emit(IterationStart(agent=self.name, iteration=i))
73
+
74
+ def _on_text(text: str) -> None:
75
+ self._emit(TextDelta(agent=self.name, text=text))
76
+
77
+ response = self.llm.complete(
78
+ messages,
79
+ tools=[t.schema for t in self.tools.values()],
80
+ on_text_delta=_on_text,
81
+ )
82
+ assistant = response.message
83
+ messages.append(assistant)
84
+ self._emit(TurnEnd(agent=self.name, message=assistant))
85
+
86
+ if not assistant.tool_calls:
87
+ return AgentRun(answer=assistant.content, trace=messages, iterations=i)
88
+
89
+ results: list[ToolResult] = []
90
+ for tc in assistant.tool_calls:
91
+ self._emit(ToolCallEvent(agent=self.name, call=tc))
92
+ result = self._run_tool_call(tc)
93
+ self._emit(ToolResultEvent(agent=self.name, result=result))
94
+ results.append(result)
95
+
96
+ messages.append(ToolResultMessage(results=results))
97
+
98
+ raise AgentError(
99
+ f"{self.name} exceeded max_iterations ({self.max_iterations}) without finishing."
100
+ )
101
+
102
+ def _run_tool_call(self, tc: ToolCall) -> ToolResult:
103
+ tool = self.tools.get(tc.name)
104
+ if tool is None:
105
+ return ToolResult(call_id=tc.id, content=f"Unknown tool: {tc.name}", is_error=True)
106
+ try:
107
+ output = tool.run(**tc.arguments)
108
+ return ToolResult(call_id=tc.id, content=output, is_error=False)
109
+ except ToolError as exc:
110
+ return ToolResult(call_id=tc.id, content=str(exc), is_error=True)
111
+ except Exception as exc: # pragma: no cover - defensive
112
+ return ToolResult(call_id=tc.id, content=f"Unhandled tool error: {exc}", is_error=True)
113
+
114
+ def _emit(self, event) -> None:
115
+ if self.on_event is None:
116
+ return
117
+ try:
118
+ self.on_event(event)
119
+ except Exception:
120
+ pass
121
+
122
+
123
+ __all__ = ["AgentRun", "BaseAgent", "EventHook", "Trace"]
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from terno_agent.agents.base import BaseAgent
4
+ from terno_agent.llm.base import LLMClient
5
+ from terno_agent.prompts.coder import CODER_PROMPT
6
+ from terno_agent.sandbox.base import Sandbox
7
+ from terno_agent.tools.code_exec import RunPythonTool
8
+
9
+
10
+ class CoderAgent(BaseAgent):
11
+ name = "coder"
12
+
13
+ def __init__(
14
+ self,
15
+ llm: LLMClient,
16
+ sandbox: Sandbox,
17
+ *,
18
+ timeout_s: int = 30,
19
+ on_event=None,
20
+ ) -> None:
21
+ tools = [RunPythonTool(sandbox, timeout_s=timeout_s)]
22
+ super().__init__(llm, CODER_PROMPT, tools, on_event=on_event)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from terno_agent.agents.base import BaseAgent
4
+ from terno_agent.db.connection import Database
5
+ from terno_agent.llm.base import LLMClient
6
+ from terno_agent.prompts.database import DATABASE_PROMPT
7
+ from terno_agent.tools.sql import DescribeTableTool, ListTablesTool, SqlQueryTool
8
+
9
+
10
+ class DatabaseAgent(BaseAgent):
11
+ name = "database"
12
+
13
+ def __init__(
14
+ self,
15
+ llm: LLMClient,
16
+ db: Database,
17
+ *,
18
+ max_rows: int = 200,
19
+ read_only: bool = True,
20
+ on_event=None,
21
+ ) -> None:
22
+ tools = [
23
+ ListTablesTool(db),
24
+ DescribeTableTool(db),
25
+ SqlQueryTool(db, max_rows=max_rows, read_only=read_only),
26
+ ]
27
+ system = DATABASE_PROMPT + "\n\nDatabase context:\n" + db.schema_overview()
28
+ super().__init__(llm, system, tools, on_event=on_event)
@@ -0,0 +1,149 @@
1
+ """Orchestrator agent.
2
+
3
+ The orchestrator is itself a `BaseAgent` whose tools are calls to specialist
4
+ sub-agents. The LLM picks which specialist to invoke; we instantiate
5
+ specialists lazily on the first call so they can share the orchestrator's
6
+ LLM client.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from terno_agent.agents.base import AgentRun, BaseAgent
15
+ from terno_agent.agents.coder import CoderAgent
16
+ from terno_agent.agents.database import DatabaseAgent
17
+ from terno_agent.config import Config
18
+ from terno_agent.core.exceptions import ConfigError, ToolError
19
+ from terno_agent.core.tool import ToolSchema
20
+ from terno_agent.db.connection import Database
21
+ from terno_agent.llm.base import LLMClient
22
+ from terno_agent.llm.factory import create_llm_client
23
+ from terno_agent.prompts.orchestrator import ORCHESTRATOR_PROMPT
24
+ from terno_agent.sandbox.base import Sandbox
25
+ from terno_agent.sandbox.factory import create_sandbox
26
+
27
+
28
+ @dataclass
29
+ class _DatabaseDelegateTool:
30
+ agent: DatabaseAgent
31
+
32
+ @property
33
+ def schema(self) -> ToolSchema:
34
+ return ToolSchema(
35
+ name="ask_database_agent",
36
+ description=(
37
+ "Delegate a task to the Database specialist. It can introspect "
38
+ "the schema and run read-only SQL. Returns the specialist's "
39
+ "natural-language answer."
40
+ ),
41
+ parameters={
42
+ "type": "object",
43
+ "properties": {
44
+ "task": {
45
+ "type": "string",
46
+ "description": "Self-contained task or question for the DB specialist.",
47
+ }
48
+ },
49
+ "required": ["task"],
50
+ },
51
+ )
52
+
53
+ def run(self, **kwargs: Any) -> str:
54
+ task = kwargs.get("task")
55
+ if not task:
56
+ raise ToolError("ask_database_agent requires a 'task' argument.")
57
+ return self.agent.run(task).answer
58
+
59
+
60
+ @dataclass
61
+ class _CoderDelegateTool:
62
+ agent: CoderAgent
63
+
64
+ @property
65
+ def schema(self) -> ToolSchema:
66
+ return ToolSchema(
67
+ name="ask_coder_agent",
68
+ description=(
69
+ "Delegate a task to the Coder specialist. It writes Python and "
70
+ "runs it in a sandbox. Pass any rows it needs to operate on "
71
+ "via 'input_data' as a JSON/CSV string — it cannot see prior "
72
+ "tool results."
73
+ ),
74
+ parameters={
75
+ "type": "object",
76
+ "properties": {
77
+ "task": {"type": "string", "description": "What the coder should do."},
78
+ "input_data": {
79
+ "type": "string",
80
+ "description": "Optional data the coder should operate on (JSON/CSV/text).",
81
+ },
82
+ },
83
+ "required": ["task"],
84
+ },
85
+ )
86
+
87
+ def run(self, **kwargs: Any) -> str:
88
+ task = kwargs.get("task")
89
+ if not task:
90
+ raise ToolError("ask_coder_agent requires a 'task' argument.")
91
+ data = kwargs.get("input_data")
92
+ full = task if not data else f"{task}\n\nInput data:\n{data}"
93
+ return self.agent.run(full).answer
94
+
95
+
96
+ class Orchestrator(BaseAgent):
97
+ name = "orchestrator"
98
+ max_iterations = 16
99
+
100
+ def __init__(
101
+ self,
102
+ llm: LLMClient,
103
+ *,
104
+ database: Database,
105
+ sandbox: Sandbox | None,
106
+ on_event=None,
107
+ ) -> None:
108
+ self.database = database
109
+ self.sandbox = sandbox
110
+ self._db_agent = DatabaseAgent(llm, database, on_event=on_event)
111
+ tools = [_DatabaseDelegateTool(self._db_agent)]
112
+ if sandbox is not None:
113
+ self._coder_agent = CoderAgent(llm, sandbox, on_event=on_event)
114
+ tools.append(_CoderDelegateTool(self._coder_agent))
115
+ else:
116
+ self._coder_agent = None
117
+
118
+ system = ORCHESTRATOR_PROMPT
119
+ if sandbox is None:
120
+ system += "\n\nNote: the coder agent is disabled in this session — answer using SQL only."
121
+ super().__init__(llm, system, tools, on_event=on_event)
122
+
123
+ # ----- Construction helpers -------------------------------------------------
124
+
125
+ @classmethod
126
+ def from_env(cls, on_event=None) -> "Orchestrator":
127
+ return cls.from_config(Config.from_env(), on_event=on_event)
128
+
129
+ @classmethod
130
+ def from_config(cls, config: Config, *, on_event=None) -> "Orchestrator":
131
+ if not config.database_url:
132
+ raise ConfigError("TERNO_DATABASE_URL is required.")
133
+ llm = create_llm_client(
134
+ provider=config.llm_provider,
135
+ model=config.llm_model,
136
+ api_key=config.llm_api_key,
137
+ )
138
+ database = Database(config.database_url)
139
+ sandbox: Sandbox | None
140
+ if config.sandbox == "none":
141
+ sandbox = None
142
+ else:
143
+ sandbox = create_sandbox(config.sandbox)
144
+ return cls(llm, database=database, sandbox=sandbox, on_event=on_event)
145
+
146
+ # ----- Convenience ---------------------------------------------------------
147
+
148
+ def ask(self, question: str) -> AgentRun:
149
+ return self.run(question)
terno_agent/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ """argparse-based CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from importlib.metadata import PackageNotFoundError, distribution
9
+ from typing import Sequence
10
+
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.syntax import Syntax
14
+ from rich.text import Text
15
+
16
+ from terno_agent import __version__
17
+ from terno_agent.agents.orchestrator import Orchestrator
18
+ from terno_agent.config import Config
19
+ from terno_agent.core.events import (
20
+ AgentEvent,
21
+ IterationStart,
22
+ TextDelta,
23
+ ToolCallEvent,
24
+ ToolResultEvent,
25
+ TurnEnd,
26
+ )
27
+ from terno_agent.core.exceptions import TernoError
28
+
29
+ _PACKAGE_NAME = "terno-agent"
30
+
31
+
32
+ def _is_editable_install() -> bool:
33
+ """Return True for development installs (editable or run-from-source).
34
+
35
+ Detection follows PEP 610: a non-editable install from a wheel/PyPI either
36
+ has no ``direct_url.json`` or has ``dir_info.editable = false``. An
37
+ editable install always sets ``dir_info.editable = true``. Running from
38
+ source without ``pip install`` will raise ``PackageNotFoundError``, which
39
+ we also treat as development mode.
40
+ """
41
+ try:
42
+ dist = distribution(_PACKAGE_NAME)
43
+ except PackageNotFoundError:
44
+ return True
45
+ raw = dist.read_text("direct_url.json")
46
+ if not raw:
47
+ return False
48
+ try:
49
+ info = json.loads(raw)
50
+ except json.JSONDecodeError:
51
+ return False
52
+ return bool((info.get("dir_info") or {}).get("editable"))
53
+
54
+
55
+ _AGENT_COLORS = {
56
+ "orchestrator": "bold magenta",
57
+ "database": "bold cyan",
58
+ "coder": "bold green",
59
+ }
60
+
61
+
62
+ def main(argv: Sequence[str] | None = None) -> int:
63
+ parser = _build_parser()
64
+ args = parser.parse_args(argv)
65
+
66
+ if args.command is None:
67
+ parser.print_help()
68
+ return 0
69
+
70
+ try:
71
+ return args.func(args)
72
+ except TernoError as exc:
73
+ Console(stderr=True).print(f"[bold red]error:[/] {exc}")
74
+ return 2
75
+ except KeyboardInterrupt:
76
+ print("\ninterrupted", file=sys.stderr)
77
+ return 130
78
+
79
+
80
+ def _build_parser() -> argparse.ArgumentParser:
81
+ p = argparse.ArgumentParser(
82
+ prog="terno",
83
+ description="Multi-agent CLI that answers questions about your database.",
84
+ )
85
+ p.add_argument("--version", action="version", version=f"terno-agent {__version__}")
86
+ p.add_argument(
87
+ "-q",
88
+ "--quiet",
89
+ action="store_true",
90
+ help="Suppress agent activity; print only the final answer.",
91
+ )
92
+ sub = p.add_subparsers(dest="command")
93
+
94
+ ask = sub.add_parser("ask", help="Ask a single question and exit.")
95
+ ask.add_argument("question", nargs="+", help="The question to ask.")
96
+ ask.set_defaults(func=_cmd_ask)
97
+
98
+ chat = sub.add_parser("chat", help="Start an interactive REPL.")
99
+ chat.set_defaults(func=_cmd_chat)
100
+
101
+ cfg = sub.add_parser("config", help="Show effective configuration.")
102
+ cfg.set_defaults(func=_cmd_config)
103
+
104
+ return p
105
+
106
+
107
+ def _cmd_ask(args: argparse.Namespace) -> int:
108
+ question = " ".join(args.question)
109
+ console = Console()
110
+ renderer = None if args.quiet else AgentRenderer(console)
111
+ agent = Orchestrator.from_env(on_event=renderer)
112
+ result = agent.ask(question)
113
+ if renderer is not None:
114
+ renderer.finalize()
115
+ if args.quiet:
116
+ print(result.answer)
117
+ return 0
118
+
119
+
120
+ def _cmd_chat(args: argparse.Namespace) -> int:
121
+ console = Console()
122
+ if not _is_editable_install():
123
+ Console(stderr=True).print(
124
+ "[bold red]error:[/] `terno chat` is only available in a development install.\n"
125
+ "Install from source to use the REPL:\n"
126
+ " [bold]pip install -e .[/] or [bold]uv tool install --editable .[/]\n"
127
+ "For published installs, use [bold]terno ask \"...\"[/] instead."
128
+ )
129
+ return 2
130
+ renderer = None if args.quiet else AgentRenderer(console)
131
+ agent = Orchestrator.from_env(on_event=renderer)
132
+ console.print("[bold]terno-agent REPL[/] — type 'exit' or Ctrl-D to quit.\n")
133
+ while True:
134
+ try:
135
+ line = input("you> ").strip()
136
+ except EOFError:
137
+ print()
138
+ return 0
139
+ if not line:
140
+ continue
141
+ if line.lower() in {"exit", "quit", ":q"}:
142
+ return 0
143
+ try:
144
+ result = agent.ask(line)
145
+ except TernoError as exc:
146
+ console.print(f"[bold red]error:[/] {exc}")
147
+ continue
148
+ if renderer is not None:
149
+ renderer.finalize()
150
+ renderer.reset()
151
+ if args.quiet:
152
+ print(f"terno> {result.answer}\n")
153
+ else:
154
+ console.print()
155
+ return 0
156
+
157
+
158
+ def _cmd_config(_args: argparse.Namespace) -> int:
159
+ print(Config.from_env().display())
160
+ return 0
161
+
162
+
163
+ # --------------------------------------------------------------------------- #
164
+ # Event renderer
165
+ # --------------------------------------------------------------------------- #
166
+
167
+
168
+ class AgentRenderer:
169
+ """Render `AgentEvent`s to a rich Console.
170
+
171
+ Streams assistant text inline; surrounds tool calls and results with
172
+ syntax-highlighted panels. Designed to be called from any thread.
173
+ """
174
+
175
+ def __init__(self, console: Console) -> None:
176
+ self.console = console
177
+ self._stream_open = False
178
+ self._current_agent: str | None = None
179
+
180
+ # callable as the event hook
181
+ def __call__(self, event: AgentEvent) -> None:
182
+ if isinstance(event, IterationStart):
183
+ return
184
+ if isinstance(event, TextDelta):
185
+ self._handle_text(event)
186
+ elif isinstance(event, ToolCallEvent):
187
+ self._close_stream()
188
+ self._render_tool_call(event)
189
+ elif isinstance(event, ToolResultEvent):
190
+ self._close_stream()
191
+ self._render_tool_result(event)
192
+ elif isinstance(event, TurnEnd):
193
+ self._close_stream()
194
+
195
+ def finalize(self) -> None:
196
+ self._close_stream()
197
+
198
+ def reset(self) -> None:
199
+ self._current_agent = None
200
+
201
+ # ----- streaming text ------------------------------------------------- #
202
+
203
+ def _handle_text(self, event: TextDelta) -> None:
204
+ if not event.text:
205
+ return
206
+ if not self._stream_open or self._current_agent != event.agent:
207
+ self._close_stream()
208
+ tag = self._agent_tag(event.agent)
209
+ self.console.print(tag, end=" ", highlight=False)
210
+ self._stream_open = True
211
+ self._current_agent = event.agent
212
+ self.console.print(event.text, end="", highlight=False, markup=False)
213
+
214
+ def _close_stream(self) -> None:
215
+ if self._stream_open:
216
+ self.console.print() # newline to end the streaming line
217
+ self._stream_open = False
218
+
219
+ def _agent_tag(self, agent: str) -> Text:
220
+ style = _AGENT_COLORS.get(agent, "bold white")
221
+ return Text(f"[{agent}]", style=style)
222
+
223
+ # ----- tool call/result panels --------------------------------------- #
224
+
225
+ def _render_tool_call(self, event: ToolCallEvent) -> None:
226
+ call = event.call
227
+ body = _format_call_body(call.name, call.arguments)
228
+ title = f"[{event.agent}] → {call.name}"
229
+ self.console.print(
230
+ Panel(
231
+ body,
232
+ title=title,
233
+ title_align="left",
234
+ border_style=_AGENT_COLORS.get(event.agent, "white"),
235
+ padding=(0, 1),
236
+ )
237
+ )
238
+
239
+ def _render_tool_result(self, event: ToolResultEvent) -> None:
240
+ result = event.result
241
+ body = _format_result_body(result.content)
242
+ style = "red" if result.is_error else "dim"
243
+ marker = "✗" if result.is_error else "✓"
244
+ title = f"[{event.agent}] {marker} result"
245
+ self.console.print(
246
+ Panel(body, title=title, title_align="left", border_style=style, padding=(0, 1))
247
+ )
248
+
249
+
250
+ # --------------------------------------------------------------------------- #
251
+ # Tool-call / result formatting helpers
252
+ # --------------------------------------------------------------------------- #
253
+
254
+
255
+ def _format_call_body(name: str, args: dict) -> object:
256
+ """Pick the best rich renderable for the tool's payload."""
257
+ if name == "sql_query" and isinstance(args.get("sql"), str):
258
+ return Syntax(args["sql"].strip(), "sql", theme="ansi_dark", word_wrap=True)
259
+ if name == "run_python" and isinstance(args.get("code"), str):
260
+ return Syntax(args["code"], "python", theme="ansi_dark", word_wrap=True)
261
+ if name in {"ask_database_agent", "ask_coder_agent"}:
262
+ task = args.get("task", "")
263
+ extra = args.get("input_data")
264
+ body = Text(task, no_wrap=False)
265
+ if extra:
266
+ body.append("\n\ninput_data:\n", style="dim")
267
+ body.append(_truncate(extra, 800))
268
+ return body
269
+ pretty = json.dumps(args, indent=2, default=str)
270
+ return Syntax(pretty, "json", theme="ansi_dark", word_wrap=True)
271
+
272
+
273
+ def _format_result_body(content: str) -> object:
274
+ text = content.strip()
275
+ if not text:
276
+ return Text("(empty)", style="dim italic")
277
+ try:
278
+ parsed = json.loads(text)
279
+ except (json.JSONDecodeError, ValueError):
280
+ return Text(_truncate(text, 4000))
281
+ if isinstance(parsed, dict) and {"columns", "rows"} <= parsed.keys():
282
+ return _format_query_result(parsed)
283
+ pretty = json.dumps(parsed, indent=2, default=str)
284
+ return Syntax(_truncate(pretty, 4000), "json", theme="ansi_dark", word_wrap=True)
285
+
286
+
287
+ def _format_query_result(payload: dict) -> object:
288
+ from rich.table import Table
289
+
290
+ columns: list[str] = payload.get("columns") or []
291
+ rows: list[list] = payload.get("rows") or []
292
+ table = Table(show_header=True, header_style="bold cyan", expand=False)
293
+ for col in columns:
294
+ table.add_column(str(col))
295
+ preview = rows[:20]
296
+ for row in preview:
297
+ table.add_row(*[_cell(v) for v in row])
298
+ footer = f"{payload.get('row_count', len(rows))} rows"
299
+ if payload.get("truncated"):
300
+ footer += " (truncated)"
301
+ if len(rows) > len(preview):
302
+ footer += f" — showing first {len(preview)}"
303
+ table.caption = footer
304
+ return table
305
+
306
+
307
+ def _cell(value: object) -> str:
308
+ if value is None:
309
+ return "—"
310
+ text = str(value)
311
+ if len(text) > 80:
312
+ text = text[:77] + "..."
313
+ return text
314
+
315
+
316
+ def _truncate(text: str, limit: int) -> str:
317
+ if len(text) <= limit:
318
+ return text
319
+ return text[:limit] + f"\n... [truncated {len(text) - limit} chars]"
320
+
321
+
322
+ if __name__ == "__main__":
323
+ raise SystemExit(main())