texai 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.
texai/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """texai: a tiny local PDF viewer that maps clicks back to LaTeX source."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
texai/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
texai/agent.py ADDED
@@ -0,0 +1,366 @@
1
+ """The background Claude Agent SDK session that edits the document.
2
+
3
+ One long-lived session per project, so the agent accumulates knowledge of the
4
+ paper across turns instead of re-reading it every time. Everything it emits is
5
+ republished on the event bus, which is what the chat panel renders.
6
+
7
+ The SDK is an optional dependency: without it the viewer, SyncTeX bridge and
8
+ selection file all still work, and the chat panel reports why it is disabled.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from . import transcript
19
+ from .config import AppConfig
20
+ from .events import EventBus
21
+ from .navigate import LocateError, locate
22
+ from .prompt import SYSTEM_PROMPT_APPEND
23
+
24
+ __all__ = ["AgentUnavailable", "TurnOutcome", "AgentSession", "sdk_status"]
25
+
26
+ # Everything the agent needs to edit LaTeX. Bash is deliberately absent: the
27
+ # harness owns compilation, so a shell would only add risk and thrash.
28
+ ALLOWED_TOOLS = [
29
+ "Read",
30
+ "Write",
31
+ "Edit",
32
+ "Glob",
33
+ "Grep",
34
+ "NotebookEdit",
35
+ # In-process tool that scrolls the user's PDF view (see build_navigation_server).
36
+ "mcp__texai__show_in_pdf",
37
+ ]
38
+ DISALLOWED_TOOLS = ["Bash", "WebFetch", "WebSearch", "Task", "KillShell", "BashOutput"]
39
+
40
+ INSTALL_HINT = (
41
+ "The Claude Agent SDK is not installed. Run `uv sync --group agent` "
42
+ "(or `uv add claude-agent-sdk`) and restart texai."
43
+ )
44
+ CLI_HINT = (
45
+ "The `claude` CLI was not found on PATH. The Agent SDK drives it, so install "
46
+ "Claude Code and make sure `claude` is runnable."
47
+ )
48
+
49
+
50
+ class AgentUnavailable(RuntimeError):
51
+ """The agent cannot run (SDK missing, CLI missing, or start-up failed)."""
52
+
53
+
54
+ @dataclass
55
+ class TurnOutcome:
56
+ """What one agent turn produced."""
57
+
58
+ ok: bool
59
+ text: str = ""
60
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
61
+ entries: list[dict[str, Any]] = field(default_factory=list)
62
+ error: str | None = None
63
+ terminal_reason: str | None = None
64
+ cost_usd: float | None = None
65
+ duration_ms: int | None = None
66
+ num_turns: int | None = None
67
+ session_id: str | None = None
68
+
69
+
70
+ def sdk_status() -> tuple[bool, str | None]:
71
+ """Whether the agent can run here, and why not if it cannot."""
72
+ import shutil
73
+
74
+ try:
75
+ import claude_agent_sdk # noqa: F401
76
+ except ImportError:
77
+ return False, INSTALL_HINT
78
+ if shutil.which("claude") is None:
79
+ return False, CLI_HINT
80
+ return True, None
81
+
82
+
83
+ def _relative(path_value: Any, root: Path) -> str:
84
+ """Show tool paths relative to the project root; they are long otherwise."""
85
+ try:
86
+ return str(Path(str(path_value)).resolve().relative_to(root))
87
+ except (ValueError, OSError, TypeError):
88
+ return str(path_value)
89
+
90
+
91
+ def summarize_tool_use(name: str, tool_input: dict[str, Any], root: Path) -> str:
92
+ """A one-line description of a tool call for the activity log."""
93
+ if name in ("Read", "Write", "Edit", "NotebookEdit"):
94
+ target = tool_input.get("file_path") or tool_input.get("notebook_path")
95
+ return _relative(target, root) if target else ""
96
+ if name in ("Glob", "Grep"):
97
+ pattern = tool_input.get("pattern", "")
98
+ path = tool_input.get("path")
99
+ return f"{pattern} in {_relative(path, root)}" if path else str(pattern)
100
+ for key in ("command", "description", "prompt"):
101
+ if key in tool_input:
102
+ return str(tool_input[key])[:200]
103
+ return ""
104
+
105
+
106
+ SHOW_IN_PDF_DESCRIPTION = """
107
+ Scroll the user's PDF view to a source location, so they are looking at the
108
+ thing you are talking about.
109
+
110
+ Call this whenever the user asks to be shown, taken to, or pointed at something
111
+ in the document — a table, a figure, a definition, an equation, a section — and
112
+ whenever your answer refers to a specific place they would want to see. Find the
113
+ file and line first (Grep is usually enough), then call this with them.
114
+
115
+ It moves the view only; it changes nothing. Prefer calling it over describing
116
+ where something is in words.
117
+ """.strip()
118
+
119
+
120
+ def build_navigation_server(config: AppConfig, bus: EventBus) -> Any:
121
+ """An in-process MCP server giving the agent one action: move the view.
122
+
123
+ Runs in this process, so the handler can publish straight onto the event
124
+ bus the browser is already listening to.
125
+ """
126
+ from claude_agent_sdk import create_sdk_mcp_server, tool
127
+
128
+ @tool(
129
+ "show_in_pdf",
130
+ SHOW_IN_PDF_DESCRIPTION,
131
+ {
132
+ "file": str,
133
+ "line": int,
134
+ "why": str,
135
+ },
136
+ )
137
+ async def show_in_pdf(args: dict[str, Any]) -> dict[str, Any]:
138
+ file = str(args.get("file", "")).strip()
139
+ line = int(args.get("line", 1) or 1)
140
+ why = str(args.get("why", "") or "").strip()
141
+
142
+ try:
143
+ found = await asyncio.to_thread(locate, config, file, line)
144
+ except LocateError as exc:
145
+ return {"content": [{"type": "text", "text": f"Could not show that: {exc}"}]}
146
+
147
+ if not found["found"]:
148
+ return {
149
+ "content": [
150
+ {
151
+ "type": "text",
152
+ "text": (
153
+ f"SyncTeX has no record of {found['file']}:{found['line']}, so the "
154
+ "view did not move. That line may produce no output (a comment, a "
155
+ "definition, or something inside an untracked environment) — try a "
156
+ "line that renders visible text."
157
+ ),
158
+ }
159
+ ]
160
+ }
161
+
162
+ bus.publish("navigate", **found, why=why)
163
+ return {
164
+ "content": [
165
+ {
166
+ "type": "text",
167
+ "text": (
168
+ f"Showing {found['file']}:{found['line']} — the view moved to page "
169
+ f"{found['page']}."
170
+ ),
171
+ }
172
+ ]
173
+ }
174
+
175
+ return create_sdk_mcp_server(name="texai", version="1.0.0", tools=[show_in_pdf])
176
+
177
+
178
+ class AgentSession:
179
+ """A single persistent agent conversation scoped to one project."""
180
+
181
+ def __init__(self, config: AppConfig, bus: EventBus, model: str | None = None) -> None:
182
+ self.config = config
183
+ self.bus = bus
184
+ self.model = model
185
+ self._client: Any = None
186
+ self._lock = asyncio.Lock()
187
+ self._started = False
188
+ self._session_id: str | None = None
189
+
190
+ @property
191
+ def running(self) -> bool:
192
+ return self._started
193
+
194
+ @property
195
+ def session_id(self) -> str | None:
196
+ """The Claude Code session id, once the first turn has reported it.
197
+
198
+ This is what makes `claude --resume <id>` attach a real terminal to the
199
+ very conversation the panel is driving.
200
+ """
201
+ return self._session_id
202
+
203
+ def resume_command(self) -> str | None:
204
+ if not self._session_id:
205
+ return None
206
+ return f"claude --resume {self._session_id}"
207
+
208
+ async def start(self) -> None:
209
+ """Connect the session. Raises :class:`AgentUnavailable` if it cannot."""
210
+ if self._started:
211
+ return
212
+
213
+ ok, reason = sdk_status()
214
+ if not ok:
215
+ raise AgentUnavailable(reason or "agent unavailable")
216
+
217
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
218
+
219
+ options = ClaudeAgentOptions(
220
+ cwd=str(self.config.root),
221
+ mcp_servers={"texai": build_navigation_server(self.config, self.bus)},
222
+ permission_mode="acceptEdits",
223
+ allowed_tools=list(ALLOWED_TOOLS),
224
+ disallowed_tools=list(DISALLOWED_TOOLS),
225
+ # Pick up the project's CLAUDE.md, skills and settings, like the CLI.
226
+ setting_sources=["user", "project"],
227
+ system_prompt={
228
+ "type": "preset",
229
+ "preset": "claude_code",
230
+ "append": SYSTEM_PROMPT_APPEND,
231
+ },
232
+ **({"model": self.model} if self.model else {}),
233
+ )
234
+
235
+ try:
236
+ self._client = ClaudeSDKClient(options=options)
237
+ await self._client.connect()
238
+ except Exception as exc: # noqa: BLE001 - surfaced to the UI verbatim
239
+ self._client = None
240
+ raise AgentUnavailable(f"Could not start the agent session: {exc}") from exc
241
+
242
+ self._started = True
243
+ self.bus.publish("agent_ready", model=self.model)
244
+
245
+ async def run_turn(self, prompt: str, turn_id: str) -> TurnOutcome:
246
+ """Send one turn and stream everything it produces onto the bus."""
247
+ if not self._started or self._client is None:
248
+ raise AgentUnavailable("agent session is not running")
249
+
250
+ from claude_agent_sdk import (
251
+ AssistantMessage,
252
+ ResultMessage,
253
+ SystemMessage,
254
+ TextBlock,
255
+ ThinkingBlock,
256
+ ToolResultBlock,
257
+ ToolUseBlock,
258
+ UserMessage,
259
+ )
260
+
261
+ texts: list[str] = []
262
+ tool_calls: list[dict[str, Any]] = []
263
+ entries: list[dict[str, Any]] = []
264
+ tool_names: dict[str, str] = {} # tool_use_id -> tool name, to label results
265
+ outcome = TurnOutcome(ok=False)
266
+
267
+ def emit(entry: dict[str, Any]) -> None:
268
+ entries.append(entry)
269
+ self.bus.publish("agent_entry", turnId=turn_id, entry=entry)
270
+
271
+ async with self._lock:
272
+ try:
273
+ await self._client.query(prompt)
274
+ async for message in self._client.receive_response():
275
+ if isinstance(message, AssistantMessage):
276
+ for block in message.content:
277
+ if isinstance(block, TextBlock):
278
+ texts.append(block.text)
279
+ emit(transcript.text_entry(block.text))
280
+ elif isinstance(block, ToolUseBlock):
281
+ tool_names[block.id] = block.name
282
+ summary = summarize_tool_use(
283
+ block.name, block.input or {}, self.config.root
284
+ )
285
+ tool_calls.append({"name": block.name, "summary": summary})
286
+ emit(
287
+ transcript.tool_use_entry(
288
+ block.name, summary, block.input or {}
289
+ )
290
+ )
291
+ elif isinstance(block, ThinkingBlock):
292
+ emit(transcript.thinking_entry())
293
+ elif isinstance(message, UserMessage):
294
+ # Tool results come back as user turns, as in Claude Code.
295
+ for block in message.content:
296
+ if isinstance(block, ToolResultBlock):
297
+ emit(
298
+ transcript.tool_result_entry(
299
+ tool_names.get(block.tool_use_id, "tool"),
300
+ block.content,
301
+ bool(block.is_error),
302
+ )
303
+ )
304
+ elif isinstance(message, SystemMessage):
305
+ entry = transcript.system_entry(message.subtype, message.data)
306
+ if entry is not None: # routine bookkeeping is dropped
307
+ emit(entry)
308
+ elif isinstance(message, ResultMessage):
309
+ subtype = getattr(message, "subtype", None)
310
+ new_session = getattr(message, "session_id", None)
311
+ if new_session and new_session != self._session_id:
312
+ self._session_id = new_session
313
+ # Lets the UI offer `claude --resume <id>` right away.
314
+ self.bus.publish(
315
+ "agent_session",
316
+ sessionId=new_session,
317
+ resumeCommand=self.resume_command(),
318
+ )
319
+ outcome = TurnOutcome(
320
+ ok=subtype != "error",
321
+ text="".join(texts),
322
+ tool_calls=tool_calls,
323
+ entries=entries,
324
+ terminal_reason=getattr(message, "terminal_reason", None),
325
+ cost_usd=getattr(message, "total_cost_usd", None),
326
+ duration_ms=getattr(message, "duration_ms", None),
327
+ num_turns=getattr(message, "num_turns", None),
328
+ session_id=self._session_id,
329
+ )
330
+ emit(
331
+ transcript.result_entry(
332
+ status=subtype or "done",
333
+ cost_usd=outcome.cost_usd,
334
+ duration_ms=outcome.duration_ms,
335
+ num_turns=outcome.num_turns,
336
+ )
337
+ )
338
+ except asyncio.CancelledError:
339
+ raise
340
+ except Exception as exc: # noqa: BLE001 - surfaced to the UI verbatim
341
+ outcome = TurnOutcome(
342
+ ok=False,
343
+ text="".join(texts),
344
+ tool_calls=tool_calls,
345
+ entries=entries,
346
+ error=str(exc),
347
+ )
348
+
349
+ if outcome.error:
350
+ emit(transcript.notice_entry(outcome.error, is_error=True))
351
+ self.bus.publish("agent_error", turnId=turn_id, message=outcome.error)
352
+ return outcome
353
+
354
+ async def interrupt(self) -> None:
355
+ if self._started and self._client is not None:
356
+ await self._client.interrupt()
357
+ self.bus.publish("agent_interrupted")
358
+
359
+ async def stop(self) -> None:
360
+ if self._client is not None:
361
+ try:
362
+ await self._client.disconnect()
363
+ except Exception: # noqa: BLE001 - shutdown is best effort
364
+ pass
365
+ self._client = None
366
+ self._started = False
texai/build.py ADDED
@@ -0,0 +1,177 @@
1
+ """Compiling the document.
2
+
3
+ The harness owns the build rather than the agent. That is what makes the rest
4
+ of the loop deterministic: we know exactly when a build started, we can diff
5
+ against the snapshot taken just before it, and we can hand compilation errors
6
+ back to the agent as a follow-up turn instead of watching it guess.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import re
13
+ import shlex
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ __all__ = [
18
+ "BuildResult",
19
+ "BuildError",
20
+ "default_build_command",
21
+ "find_root_tex",
22
+ "parse_log_errors",
23
+ "run_build",
24
+ ]
25
+
26
+ BUILD_TIMEOUT_SECONDS = 300.0
27
+ MAX_LOG_ERRORS = 12
28
+ MAX_OUTPUT_CHARS = 20_000
29
+
30
+
31
+ class BuildError(RuntimeError):
32
+ """The build could not be started at all (bad command, missing binary)."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BuildResult:
37
+ ok: bool
38
+ command: list[str]
39
+ returncode: int | None
40
+ output: str
41
+ errors: list[str] = field(default_factory=list)
42
+ timed_out: bool = False
43
+
44
+ def summary(self) -> str:
45
+ if self.ok:
46
+ return "Build succeeded."
47
+ if self.timed_out:
48
+ return f"Build timed out after {BUILD_TIMEOUT_SECONDS:g}s."
49
+ head = "\n".join(self.errors[:MAX_LOG_ERRORS]) or self.output[-2000:]
50
+ return f"Build failed (exit {self.returncode}).\n{head}"
51
+
52
+
53
+ def find_root_tex(pdf_path: Path, root: Path) -> Path | None:
54
+ """Guess the root LaTeX file for a PDF.
55
+
56
+ A sibling ``<stem>.tex`` wins; otherwise the first file under the project
57
+ root that declares ``\\begin{document}``.
58
+ """
59
+ sibling = pdf_path.with_suffix(".tex")
60
+ if sibling.is_file():
61
+ return sibling
62
+
63
+ candidates = sorted(root.rglob("*.tex"))
64
+ for candidate in candidates:
65
+ if candidate.name == f"{pdf_path.stem}.tex":
66
+ return candidate
67
+ for candidate in candidates:
68
+ try:
69
+ if "\\begin{document}" in candidate.read_text(encoding="utf-8", errors="replace"):
70
+ return candidate
71
+ except OSError:
72
+ continue
73
+ return None
74
+
75
+
76
+ def default_build_command(root_tex: Path) -> list[str]:
77
+ return [
78
+ "latexmk",
79
+ "-pdf",
80
+ "-synctex=1",
81
+ "-interaction=nonstopmode",
82
+ root_tex.name,
83
+ ]
84
+
85
+
86
+ def parse_log_errors(log_text: str) -> list[str]:
87
+ """Pull the useful error lines out of a LaTeX log.
88
+
89
+ TeX errors start with ``!`` and the offending source line follows a few
90
+ lines later as ``l.<number>``; we stitch those together so the agent gets
91
+ "what went wrong" and "where" in one string.
92
+ """
93
+ errors: list[str] = []
94
+ lines = log_text.splitlines()
95
+
96
+ for index, line in enumerate(lines):
97
+ if not line.startswith("!"):
98
+ continue
99
+ message = line.strip()
100
+ location = ""
101
+ for following in lines[index + 1 : index + 12]:
102
+ match = re.match(r"^l\.(\d+)(.*)", following)
103
+ if match:
104
+ location = f" (line {match.group(1)}){match.group(2).rstrip()}"
105
+ break
106
+ errors.append(f"{message}{location}")
107
+ if len(errors) >= MAX_LOG_ERRORS:
108
+ break
109
+
110
+ if not errors:
111
+ for line in lines:
112
+ if "LaTeX Error" in line or "Emergency stop" in line:
113
+ errors.append(line.strip())
114
+ if len(errors) >= MAX_LOG_ERRORS:
115
+ break
116
+ return errors
117
+
118
+
119
+ def resolve_command(command: str | None, root_tex: Path | None) -> list[str]:
120
+ """Turn a configured build command into an argv list.
121
+
122
+ Parsed with ``shlex`` and run without a shell, so pipes and ``&&`` are not
123
+ supported by design — point ``--build-cmd`` at a script if you need them.
124
+ """
125
+ if command:
126
+ argv = shlex.split(command)
127
+ if not argv:
128
+ raise BuildError("--build-cmd is empty")
129
+ return argv
130
+ if root_tex is None:
131
+ raise BuildError(
132
+ "No build command configured and no root .tex file found. "
133
+ "Pass --build-cmd, e.g. "
134
+ "--build-cmd 'latexmk -pdf -synctex=1 -interaction=nonstopmode main.tex'."
135
+ )
136
+ return default_build_command(root_tex)
137
+
138
+
139
+ async def run_build(argv: list[str], cwd: Path, log_path: Path | None = None) -> BuildResult:
140
+ """Run the build, returning its output plus any parsed LaTeX errors."""
141
+ try:
142
+ process = await asyncio.create_subprocess_exec(
143
+ *argv,
144
+ cwd=str(cwd),
145
+ stdout=asyncio.subprocess.PIPE,
146
+ stderr=asyncio.subprocess.STDOUT,
147
+ )
148
+ except FileNotFoundError as exc:
149
+ raise BuildError(f"Build command not found: {argv[0]}") from exc
150
+ except PermissionError as exc:
151
+ raise BuildError(f"Build command is not executable: {argv[0]}") from exc
152
+
153
+ timed_out = False
154
+ try:
155
+ stdout, _ = await asyncio.wait_for(process.communicate(), timeout=BUILD_TIMEOUT_SECONDS)
156
+ except asyncio.TimeoutError:
157
+ timed_out = True
158
+ process.kill()
159
+ stdout, _ = await process.communicate()
160
+
161
+ output = (stdout or b"").decode("utf-8", errors="replace")[-MAX_OUTPUT_CHARS:]
162
+ ok = not timed_out and process.returncode == 0
163
+
164
+ errors: list[str] = []
165
+ if not ok:
166
+ errors = parse_log_errors(output)
167
+ if not errors and log_path is not None and log_path.is_file():
168
+ errors = parse_log_errors(log_path.read_text(encoding="utf-8", errors="replace"))
169
+
170
+ return BuildResult(
171
+ ok=ok,
172
+ command=argv,
173
+ returncode=process.returncode,
174
+ output=output,
175
+ errors=errors,
176
+ timed_out=timed_out,
177
+ )