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 +5 -0
- texai/__main__.py +4 -0
- texai/agent.py +366 -0
- texai/build.py +177 -0
- texai/cli.py +152 -0
- texai/config.py +103 -0
- texai/events.py +105 -0
- texai/hunks.py +120 -0
- texai/models.py +102 -0
- texai/navigate.py +61 -0
- texai/paths.py +122 -0
- texai/prompt.py +80 -0
- texai/selection.py +90 -0
- texai/server.py +396 -0
- texai/snapshots.py +187 -0
- texai/static/api.js +41 -0
- texai/static/app.js +296 -0
- texai/static/chat.js +574 -0
- texai/static/index.html +94 -0
- texai/static/marks.js +193 -0
- texai/static/styles.css +551 -0
- texai/static/toast.js +20 -0
- texai/static/vendor/pdfjs/LICENSE +177 -0
- texai/static/vendor/pdfjs/VERSION +1 -0
- texai/static/vendor/pdfjs/pdf.mjs +24293 -0
- texai/static/vendor/pdfjs/pdf.worker.mjs +58272 -0
- texai/static/vendor/pdfjs/text_layer.css +127 -0
- texai/static/viewer.js +403 -0
- texai/synctex.py +326 -0
- texai/transcript.py +165 -0
- texai/turns.py +369 -0
- texai-0.1.0.dist-info/METADATA +338 -0
- texai-0.1.0.dist-info/RECORD +36 -0
- texai-0.1.0.dist-info/WHEEL +4 -0
- texai-0.1.0.dist-info/entry_points.txt +2 -0
- texai-0.1.0.dist-info/licenses/LICENSE +21 -0
texai/__init__.py
ADDED
texai/__main__.py
ADDED
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
|
+
)
|