tex-mcp-web 0.8.2__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 (49) hide show
  1. tex_mcp_web/__init__.py +3 -0
  2. tex_mcp_web/agent/init.md +104 -0
  3. tex_mcp_web/cli.py +361 -0
  4. tex_mcp_web/comment_drafts.py +123 -0
  5. tex_mcp_web/comments.py +2281 -0
  6. tex_mcp_web/compiler.py +534 -0
  7. tex_mcp_web/completion.py +158 -0
  8. tex_mcp_web/config.py +366 -0
  9. tex_mcp_web/imaging.py +411 -0
  10. tex_mcp_web/mcp_client.py +249 -0
  11. tex_mcp_web/mcp_server.py +1375 -0
  12. tex_mcp_web/overleaf.py +477 -0
  13. tex_mcp_web/overleaf_git.py +384 -0
  14. tex_mcp_web/overleaf_live.py +565 -0
  15. tex_mcp_web/overleaf_ot.py +377 -0
  16. tex_mcp_web/overleaf_sync.py +1519 -0
  17. tex_mcp_web/page_token.py +62 -0
  18. tex_mcp_web/plugin_monitor.py +79 -0
  19. tex_mcp_web/server.py +3297 -0
  20. tex_mcp_web/static/ace/LICENSE +24 -0
  21. tex_mcp_web/static/ace/ace.js +23 -0
  22. tex_mcp_web/static/ace/ext-language_tools.js +8 -0
  23. tex_mcp_web/static/ace/ext-searchbox.js +8 -0
  24. tex_mcp_web/static/ace/keybinding-vim.js +8 -0
  25. tex_mcp_web/static/ace/mode-bibtex.js +8 -0
  26. tex_mcp_web/static/ace/mode-latex.js +8 -0
  27. tex_mcp_web/static/ace/mode-text.js +8 -0
  28. tex_mcp_web/static/ace/theme-one_dark.js +8 -0
  29. tex_mcp_web/static/ace/theme-textmate.js +8 -0
  30. tex_mcp_web/static/embedpdf/LICENSE +21 -0
  31. tex_mcp_web/static/embedpdf/LICENSE.pdfium +196 -0
  32. tex_mcp_web/static/embedpdf/browser-BKLM0ThC-CkSOgtCM.js +1 -0
  33. tex_mcp_web/static/embedpdf/direct-engine-BA2WfEti.js +1 -0
  34. tex_mcp_web/static/embedpdf/embedpdf-7TNsu-EA.js +3 -0
  35. tex_mcp_web/static/embedpdf/embedpdf.js +1 -0
  36. tex_mcp_web/static/embedpdf/pdfium.wasm +0 -0
  37. tex_mcp_web/static/index.html +347 -0
  38. tex_mcp_web/static/style.css +1368 -0
  39. tex_mcp_web/static/viewer.js +5379 -0
  40. tex_mcp_web/structure.py +338 -0
  41. tex_mcp_web/synctex.py +414 -0
  42. tex_mcp_web/terms.py +485 -0
  43. tex_mcp_web/translate.py +291 -0
  44. tex_mcp_web/watcher.py +332 -0
  45. tex_mcp_web-0.8.2.dist-info/METADATA +244 -0
  46. tex_mcp_web-0.8.2.dist-info/RECORD +49 -0
  47. tex_mcp_web-0.8.2.dist-info/WHEEL +4 -0
  48. tex_mcp_web-0.8.2.dist-info/entry_points.txt +4 -0
  49. tex_mcp_web-0.8.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,3 @@
1
+ """tex-mcp-web — agentic-first review tool for LaTeX papers."""
2
+
3
+ __version__ = "0.8.2"
@@ -0,0 +1,104 @@
1
+ # Set up a folder for tex review
2
+
3
+ The user said "do tex init", or `listen` found no config. Set up review for the current MCP
4
+ session. Read setup paths from `setup_info()`, not from the shell's current directory. Its
5
+ startup directory is where the config goes. Configuration discovery searches that directory
6
+ and its parents, never its subfolders. The discovered config and an existing binding may
7
+ differ. A successful binding is retained until the MCP process is restarted.
8
+
9
+ Resolve `dir` relative to the config's directory, and `main` relative to `dir`, or to the
10
+ config's directory when `dir` is unset. Init creates the config, not the paper. Confirm only
11
+ choices the user has not already authorized. For an explicitly requested setup for another
12
+ session, use that session's intended startup directory and report separately whether its
13
+ connection has been verified.
14
+
15
+ ## 1. Inspect (write nothing)
16
+
17
+ - Call `setup_info()` before connecting or writing, and read its startup directory,
18
+ discovered config, and existing binding.
19
+ - Run `tex-mcp-web config` without arguments from the startup directory and inspect the
20
+ selected path and values. If the config serves the intended project, reuse it and edit only
21
+ the settings the request requires. If a parent config serves another project, leave it
22
+ unchanged and propose a new config in the startup directory. If a config already exists in
23
+ the startup directory, edit that file rather than replacing it with init. A missing main
24
+ file alone does not establish that a config belongs to another project.
25
+ - Main file: `grep -l '\\documentclass' *.tex` in the paper's folder. One hit is the proposal.
26
+ Several are asked. No hit in the startup directory: ask where the paper is, and that folder
27
+ becomes `dir`.
28
+ - Port: the first free one from 8765 (`ss -ltn`, or `lsof -iTCP -sTCP:LISTEN -P -n` on macOS), also skipping the port in a
29
+ `.html-mcp-web.yaml` in this folder, since both tools default to 8765.
30
+ - Compiler: `command -v latexmk`. Missing: tell the user, and do not install a TeX distribution.
31
+ - References: a folder the user names that holds the papers theirs builds on, converted to
32
+ Markdown. Unnamed: leave it unset.
33
+ - Guideline: `setup_info()` lists the user's guidelines folder, with its absolute path and
34
+ entries. Mark an entry without GUIDELINE.md, which cannot be chosen. None is the default.
35
+ - Page token: off is the default. Every account on a machine can reach the page's port, so on
36
+ a machine other people log in to, such as a lab server, the page token keeps them out.
37
+
38
+ ## 2. Confirm
39
+
40
+ Show one summary, in the user's language, like:
41
+
42
+ config : <startup directory>/.tex-mcp-web.yaml
43
+ dir : paper2 (the paper's folder, relative to the config)
44
+ main : main.tex (in dir, the only file with \documentclass)
45
+ port : 8765
46
+ watch : *.tex *.bib -> 9 files (main.tex, sections/*.tex, refs.bib)
47
+ ignore : *_backup.tex
48
+ compiler : auto (latexmk)
49
+ references : ../related (the reference papers in Markdown, relative to the config)
50
+ guideline : my-field (writing rules the agent reads before editing)
51
+ page token : off (on: the page opens only from its link, for a machine others log in to)
52
+
53
+ Leave out `dir` when the paper is in the startup directory, `references` when the user named
54
+ no folder, and `guideline` when the user has none. Under guideline, list the user's
55
+ guidelines and none, each with one line on what it is for, read from its first lines, with
56
+ none as the default. Under page token, ask whether other people can log in to this machine:
57
+ on, the page opens only from a link with a token only this account can read, once per
58
+ browser, after which its plain address works as a bookmark there. Off, the plain address
59
+ opens it for anyone on the machine, as it does on a computer of one's own. Ask once. A choice
60
+ the user has already approved is not asked again.
61
+
62
+ ## 3. Write
63
+
64
+ Run init and subsequent config commands with the MCP startup directory as their working
65
+ directory, with the command that `listen()`'s missing-config error names. Changing the shell's
66
+ working directory does not retarget this MCP process.
67
+
68
+ tex-mcp-web --port <port> init --main <file>
69
+ tex-mcp-web config <dir|compiler|watch|ignore|references|guideline|page_token> <value> # only for changed fields
70
+
71
+ Continue only if init succeeds and the expected local config exists. Before changing any
72
+ setting, run `tex-mcp-web config` without arguments and confirm that it selects the intended
73
+ file. Stop if it selects another file. Inspect the resulting values after the changes.
74
+
75
+ In this setup, use `init --force` only after confirming that the existing config is in a
76
+ parent directory and creating a local config is authorized. It creates the local config
77
+ without changing the parent. Do not use it to replace an existing local config.
78
+
79
+ ## 4. Verify
80
+
81
+ Before reporting completion, confirm that configuration discovery selects the intended file,
82
+ `dir` resolves to the intended paper directory, and `main` resolves to an existing paper file.
83
+
84
+ Then check the current connection without a build. Call `guide()`, which connects this session
85
+ to its project, read every file in its read list in full, and then call `setup_info()`. Its
86
+ `bound_config_path` must be the intended config. A review server is shared only by the same config file, so this is the config the
87
+ review page serves. If it is bound to another config, restart the MCP server process through
88
+ the client and check again. A successful init or CLI config check alone does not establish
89
+ this.
90
+
91
+ Report setup as complete only after both the files and the current connection have been
92
+ verified. Otherwise report what was prepared and what remains unverified.
93
+
94
+ ## 5. Tell the user
95
+
96
+ Report setup as ready only after the current MCP connection passes the check. If restarting
97
+ the MCP process remains necessary, say that the files are prepared but this session is not
98
+ ready yet, and state the remaining action. Saying "do tex listen" starts the review page. With
99
+ the page token on, give the user the link `tex-mcp-web link` prints in the paper's folder, to
100
+ open once in each browser.
101
+ To follow an Overleaf project, the user presses Connect Overleaf in the page's top bar (in
102
+ the ⋯ menu on a phone) and follows what it asks. Tell them so in one line.
103
+ With the plugin, Claude Code started in the folder later listens on its own. Start listening
104
+ only when the user asks for it.
tex_mcp_web/cli.py ADDED
@@ -0,0 +1,361 @@
1
+ """tex-mcp-web command-line interface.
2
+
3
+ Subcommands:
4
+ serve run the viewer server on its own (default if no subcommand)
5
+ link print the page's address, with its token when page_token is on
6
+ init scaffold .tex-mcp-web.yaml
7
+ config get/set values in .tex-mcp-web.yaml
8
+ compile one-shot compile, print structured errors
9
+ goto scroll a running viewer
10
+ mcp run the MCP server (stdio transport) and serve the viewer
11
+
12
+ Comment management lives in the browser (for humans) and the MCP tools
13
+ (for the agent). The CLI deliberately does not expose comment commands
14
+ because nobody types ``tex-mcp-web comment add ...`` from a shell.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from .config import DEFAULT_PORT, create_config, find_config, load_config
27
+ from . import __version__
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Logging
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def _configure_logging(verbose: bool) -> None:
36
+ logging.basicConfig(
37
+ level=logging.DEBUG if verbose else logging.INFO,
38
+ format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
39
+ datefmt="%H:%M:%S",
40
+ )
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # serve
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def cmd_serve(args: argparse.Namespace) -> int:
49
+ from .server import run as run_server
50
+
51
+ cfg = load_config(main_file=getattr(args, "main", None))
52
+ port = args.port or cfg.port
53
+ print(
54
+ f"tex-mcp-web v{__version__} serving {cfg.main} at http://127.0.0.1:{port}"
55
+ + (" (open it from the link `tex-mcp-web link` prints)" if cfg.page_token else ""),
56
+ file=sys.stderr,
57
+ )
58
+ run_server(cfg, port=port)
59
+ return 0
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # link
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def cmd_link(args: argparse.Namespace) -> int:
68
+ """The page's address, to open or bookmark: with page_token on, the link with the token,
69
+ which a browser opens once and then keeps."""
70
+ from .page_token import link
71
+
72
+ cfg = load_config()
73
+ print(link(args.port or cfg.port, cfg.page_token))
74
+ return 0
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # init
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def cmd_init(args: argparse.Namespace) -> int:
83
+ existing = find_config()
84
+ if existing and not args.force:
85
+ # init writes here. A config found higher up is left alone by --force.
86
+ if existing.parent.resolve() == Path.cwd().resolve():
87
+ print(f"Config already exists here: {existing}", file=sys.stderr)
88
+ print("--force overwrites it.", file=sys.stderr)
89
+ else:
90
+ print(f"Config found in a parent folder: {existing}", file=sys.stderr)
91
+ print("--force writes a new config here, which takes precedence for this folder. "
92
+ "The parent is not changed.", file=sys.stderr)
93
+ return 1
94
+ path = create_config(main=args.main, port=args.port or DEFAULT_PORT)
95
+ print(f"Wrote {path}", file=sys.stderr)
96
+ return 0
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # config
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ _CONFIG_KEYS = ("main", "dir", "port", "compiler", "watch", "ignore", "references", "guideline", "page_token")
105
+
106
+
107
+ def cmd_config(args: argparse.Namespace) -> int:
108
+ import yaml
109
+
110
+ from .config import DEFAULT_CONFIG_NAME
111
+
112
+ path = find_config()
113
+ if path is None:
114
+ print(f"No {DEFAULT_CONFIG_NAME} found. `tex-mcp-web init` writes one here, "
115
+ "and `tex-mcp-web config dir <folder>` then names the paper's folder anywhere.", file=sys.stderr)
116
+ return 1
117
+ with open(path) as f:
118
+ data = yaml.safe_load(f) or {}
119
+
120
+ if args.key is None:
121
+ print(f"# {path}")
122
+ print(yaml.dump(data, default_flow_style=False, sort_keys=False), end="")
123
+ return 0
124
+
125
+ if args.key not in _CONFIG_KEYS:
126
+ print(f"unknown key {args.key!r}; one of: {', '.join(_CONFIG_KEYS)}", file=sys.stderr)
127
+ return 1
128
+
129
+ if args.value is None:
130
+ if args.key not in data:
131
+ print(f"{args.key} is not set in {path}", file=sys.stderr)
132
+ return 1
133
+ v = data[args.key]
134
+ print(",".join(v) if isinstance(v, list) else v)
135
+ return 0
136
+
137
+ value: object = args.value
138
+ if args.key == "port":
139
+ value = int(args.value)
140
+ elif args.key == "page_token":
141
+ if args.value not in ("true", "false"):
142
+ print("page_token is true or false", file=sys.stderr)
143
+ return 1
144
+ value = args.value == "true"
145
+ elif args.key in ("watch", "ignore"):
146
+ value = [p.strip() for p in args.value.split(",") if p.strip()]
147
+ elif args.key == "guideline":
148
+ from .config import Config, get_guideline_files
149
+
150
+ try:
151
+ get_guideline_files(Config.from_dict({"main": "main.tex", "guideline": args.value}))
152
+ except (OSError, ValueError) as error:
153
+ print(f"{error}. setup_info() lists the guidelines to choose from.", file=sys.stderr)
154
+ return 1
155
+ elif args.key == "compiler":
156
+ from .compiler import ALLOWED_COMPILERS
157
+
158
+ if args.value not in ALLOWED_COMPILERS:
159
+ print(
160
+ f"unknown compiler {args.value!r}; one of: {', '.join(sorted(ALLOWED_COMPILERS))}",
161
+ file=sys.stderr,
162
+ )
163
+ return 1
164
+ data[args.key] = value
165
+ with open(path, "w") as f:
166
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
167
+ print(f"{args.key} = {value} ({path})", file=sys.stderr)
168
+ return 0
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # compile (one-shot)
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def cmd_compile(args: argparse.Namespace) -> int:
177
+ from .compiler import compile_tex
178
+ from .config import get_main_file, get_watch_dir
179
+
180
+ cfg = load_config(main_file=args.main)
181
+ main = get_main_file(cfg)
182
+ watch_dir = get_watch_dir(cfg)
183
+
184
+ result = asyncio.run(compile_tex(main, compiler=cfg.compiler, work_dir=watch_dir))
185
+
186
+ if args.json:
187
+ print(json.dumps(_compile_result_dict(result), indent=2))
188
+ else:
189
+ if result.success:
190
+ print(f"compile succeeded in {result.duration_seconds:.2f}s")
191
+ else:
192
+ print(f"compile FAILED ({len(result.errors)} errors)", file=sys.stderr)
193
+ for err in result.errors:
194
+ loc = f"{err.file}:{err.line}" if err.line else err.file
195
+ print(f" ERROR {loc} {err.message}", file=sys.stderr)
196
+ if err.context:
197
+ for line in err.context:
198
+ print(f" {line}", file=sys.stderr)
199
+ for w in result.warnings:
200
+ loc = f"{w.file}:{w.line}" if w.line else w.file
201
+ print(f" warn {loc} {w.message}", file=sys.stderr)
202
+ return 0 if result.success else 1
203
+
204
+
205
+ def _compile_result_dict(result) -> dict:
206
+ import dataclasses
207
+
208
+ return {
209
+ "success": result.success,
210
+ "errors": [dataclasses.asdict(e) for e in result.errors],
211
+ "warnings": [dataclasses.asdict(w) for w in result.warnings],
212
+ "output_file": str(result.output_file) if result.output_file else None,
213
+ "duration_seconds": result.duration_seconds,
214
+ }
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # goto (requires running daemon)
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def cmd_goto(args: argparse.Namespace) -> int:
223
+ import urllib.request
224
+
225
+ from .mcp_server import parse_goto_target
226
+
227
+ cfg = load_config()
228
+ port = args.port or cfg.port
229
+ body = parse_goto_target(args.target, default_file=cfg.main)
230
+
231
+ from .page_token import headers
232
+
233
+ req = urllib.request.Request(
234
+ f"http://127.0.0.1:{port}/goto",
235
+ data=json.dumps(body).encode(),
236
+ headers={"Content-Type": "application/json", **headers()},
237
+ method="POST",
238
+ )
239
+ try:
240
+ with urllib.request.urlopen(req, timeout=5) as resp:
241
+ print(resp.read().decode())
242
+ return 0
243
+ except Exception as exc:
244
+ print(f"could not reach daemon at {port}: {exc}", file=sys.stderr)
245
+ return 2
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # mcp
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def cmd_mcp(args: argparse.Namespace) -> int:
254
+ if args.check:
255
+ from .mcp_server import check
256
+
257
+ tools = check(Path.cwd())
258
+ print(f"tex-mcp: {len(tools)} tools ({', '.join(tools)})")
259
+ return 0
260
+ from .mcp_server import main as mcp_main
261
+
262
+ mcp_main()
263
+ return 0
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Argument parsing
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ def build_parser() -> argparse.ArgumentParser:
272
+ parser = argparse.ArgumentParser(
273
+ prog="tex-mcp-web",
274
+ description=(
275
+ "Live PDF preview + review-style commenting for LaTeX papers, "
276
+ "designed for a coding agent as the author. Run with no arguments to "
277
+ "start the server."
278
+ ),
279
+ )
280
+ parser.add_argument("-v", "--verbose", action="store_true", help="DEBUG logging")
281
+ parser.add_argument("--port", type=int, help="HTTP port (default: from config)")
282
+
283
+ sub = parser.add_subparsers(dest="cmd")
284
+
285
+ p = sub.add_parser("serve", help="run the viewer server on its own (default)")
286
+ p.add_argument("--main", help="main .tex file (overrides config)")
287
+ p.set_defaults(func=cmd_serve)
288
+
289
+ p = sub.add_parser("link", help="print the page's address, with its token when page_token is on")
290
+ p.set_defaults(func=cmd_link)
291
+
292
+ p = sub.add_parser("init", help="scaffold .tex-mcp-web.yaml")
293
+ p.add_argument("--main", default="paper.tex")
294
+ p.add_argument("--force", action="store_true")
295
+ p.set_defaults(func=cmd_init)
296
+
297
+ p = sub.add_parser("config", help="get/set values in .tex-mcp-web.yaml")
298
+ p.add_argument("key", nargs="?", help=f"one of: {', '.join(_CONFIG_KEYS)}")
299
+ p.add_argument("value", nargs="?", help="new value (lists comma-separated); omit to print")
300
+ p.set_defaults(func=cmd_config)
301
+
302
+ p = sub.add_parser("compile", help="one-shot compile")
303
+ p.add_argument("--main", help="main .tex file (overrides config)")
304
+ p.add_argument("--json", action="store_true", help="JSON output")
305
+ p.set_defaults(func=cmd_compile)
306
+
307
+ p = sub.add_parser("goto", help="scroll a running viewer")
308
+ p.add_argument("target", help="section title, line number, file:line, or pN")
309
+ p.set_defaults(func=cmd_goto)
310
+
311
+ p = sub.add_parser("mcp", help="run the MCP server (stdio); serves the viewer too")
312
+ p.add_argument("--check", action="store_true",
313
+ help="build the server and list its tools instead of serving")
314
+ p.set_defaults(func=cmd_mcp)
315
+
316
+ return parser
317
+
318
+
319
+ def main(argv: list[str] | None = None) -> int:
320
+ parser = build_parser()
321
+ args = parser.parse_args(argv)
322
+ _configure_logging(getattr(args, "verbose", False))
323
+
324
+ if not getattr(args, "func", None):
325
+ args.main = None
326
+ return cmd_serve(args)
327
+ return args.func(args)
328
+
329
+
330
+ def _split_root_args(argv: list[str]) -> tuple[list[str], list[str]]:
331
+ # Root-parser flags must precede the subcommand in argparse.
332
+ root: list[str] = []
333
+ rest: list[str] = []
334
+ i = 0
335
+ while i < len(argv):
336
+ a = argv[i]
337
+ if a in ("-v", "--verbose") or a.startswith("--port="):
338
+ root.append(a)
339
+ elif a == "--port":
340
+ root.extend(argv[i : i + 2])
341
+ i += 1
342
+ else:
343
+ rest.append(a)
344
+ i += 1
345
+ return root, rest
346
+
347
+
348
+ def main_mcp(argv: list[str] | None = None) -> int:
349
+ """`tex-mcp` entry point: MCP stdio server."""
350
+ root, rest = _split_root_args(sys.argv[1:] if argv is None else argv)
351
+ return main([*root, "mcp", *rest])
352
+
353
+
354
+ def main_serve(argv: list[str] | None = None) -> int:
355
+ """`tex-web` entry point: web server."""
356
+ root, rest = _split_root_args(sys.argv[1:] if argv is None else argv)
357
+ return main([*root, "serve", *rest])
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(main())
@@ -0,0 +1,123 @@
1
+ """Server-owned, file-backed comment replies and rewrites. Only the Reply and Edit
2
+ blocks are editable."""
3
+
4
+ import contextlib
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import stat
11
+ import uuid
12
+
13
+
14
+ @contextlib.contextmanager
15
+ def _directory(root: Path, create: bool = False):
16
+ # Walk directory descriptors, never following a symlink, including ancestors.
17
+ root = root.absolute()
18
+ if ".." in root.parts:
19
+ raise ValueError("invalid draft directory")
20
+ fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
21
+ try:
22
+ for part in root.parts[1:]:
23
+ if create:
24
+ try:
25
+ os.mkdir(part, mode=0o700, dir_fd=fd)
26
+ except FileExistsError:
27
+ pass
28
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
29
+ os.close(fd)
30
+ fd = child
31
+ yield fd
32
+ finally:
33
+ os.close(fd)
34
+
35
+
36
+ def _read(fd: int, name: str) -> str:
37
+ source = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd)
38
+ with os.fdopen(source, "r", encoding="utf-8", newline="") as stream:
39
+ info = os.fstat(stream.fileno())
40
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
41
+ raise ValueError("draft must be a regular, unlinked file")
42
+ return stream.read()
43
+
44
+
45
+ def export(root: Path, comments: list[dict]) -> dict:
46
+ """Write the draft. Each comment gets an Edit block per entry the agent wrote,
47
+ holding its current text, and one empty Reply block; everything else is fixed."""
48
+ ids = [comment["id"] for comment in comments]
49
+ if not ids or len(ids) != len(set(ids)):
50
+ raise ValueError("comment_ids must be nonempty and unique")
51
+ key = uuid.uuid4().hex
52
+ segments = ["# Comment replies\n\nEdit only Reply and Edit blocks; keep their markers. "
53
+ "A Reply block left empty adds nothing; an Edit block left as it is changes nothing.\n"]
54
+ slots: list[list] = [] # ["reply", comment_id] or ["edit", comment_id, entry_id, original]
55
+ for comment in comments:
56
+ # The heading carries what the reader needs to answer: which thread, where it
57
+ # sits, and its state. Anchors, selectors and stamps are the store's business
58
+ # and the snapshot beside this file already holds what the reply is checked
59
+ # against, so none of them is written out for someone to read past.
60
+ source = comment["resolved_source"] if "resolved_source" in comment else None
61
+ where = (f" {source['file']}:{source['line_start']}-{source['line_end']}"
62
+ if source is not None else "")
63
+ segments[-1] += f"\n## {comment['id']}{where} [{comment['status']}]\n"
64
+ for entry in comment["thread"]:
65
+ # A human entry is there to be read; an agent's own entry is there to be
66
+ # rewritten, and its Edit block already holds the text, so neither is
67
+ # written twice.
68
+ if entry["author"] != "agent":
69
+ segments[-1] += f"\n{entry['author']} {entry['text']}\n"
70
+ continue
71
+ segments[-1] += f"\n### Edit {entry['id']}\n<!-- edit:{key}:{entry['id']} -->\n"
72
+ segments.append(f"\n<!-- /edit:{key}:{entry['id']} -->\n")
73
+ slots.append(["edit", comment["id"], entry["id"], entry["text"]])
74
+ segments[-1] += f"\n### Reply\n<!-- reply:{key}:{comment['id']} -->\n"
75
+ segments.append(f"\n<!-- /reply:{key}:{comment['id']} -->\n")
76
+ slots.append(["reply", comment["id"]])
77
+ # Between an opening marker line and its closing marker sits the block's content:
78
+ # the entry's current text for an Edit block, nothing yet for a Reply block.
79
+ body = ""
80
+ for index, segment in enumerate(segments):
81
+ body += segment
82
+ if index < len(slots) and slots[index][0] == "edit":
83
+ body += slots[index][3]
84
+ snapshot = {"ids": ids, "updated": [c["updated"] for c in comments], "segments": segments, "slots": slots}
85
+ root = root.absolute()
86
+ with _directory(root, create=True) as fd:
87
+ for suffix, text in ((".snapshot", json.dumps(snapshot, ensure_ascii=False)), (".md", body)):
88
+ target = os.open(key + suffix, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
89
+ with os.fdopen(target, "w", encoding="utf-8", newline="") as stream:
90
+ stream.write(text)
91
+ return {"path": str(root / (key + ".md")), "sha256": hashlib.sha256(body.encode("utf-8")).hexdigest(), "comment_ids": ids}
92
+
93
+
94
+ def load(root: Path, path: str) -> tuple[dict[str, str], list[tuple[str, str, str]], dict[str, str]]:
95
+ """Read a filled draft back: (replies by comment id, entry rewrites as (comment id,
96
+ entry id, text), expected updated stamps by comment id). Only changed blocks count."""
97
+ candidate = Path(path)
98
+ if ".." in candidate.parts or candidate.parent != root.absolute() or not re.fullmatch(r"[0-9a-f]{32}\.md", candidate.name):
99
+ raise ValueError("draft must be a server-created draft path")
100
+ try:
101
+ with _directory(root) as fd:
102
+ snapshot = json.loads(_read(fd, candidate.stem + ".snapshot"))
103
+ body = _read(fd, candidate.name)
104
+ # Exact immutable segments protect IDs, snapshot timestamps and thread text.
105
+ match = re.fullmatch("(.*?)".join(re.escape(s) for s in snapshot["segments"]), body, re.DOTALL)
106
+ if match is None or len(match.groups()) != len(snapshot["slots"]):
107
+ raise ValueError("malformed draft: edit only Reply and Edit blocks")
108
+ replies: dict[str, str] = {}
109
+ entry_edits: list[tuple[str, str, str]] = []
110
+ for slot, value in zip(snapshot["slots"], match.groups()):
111
+ if slot[0] == "reply":
112
+ if value.strip():
113
+ replies[slot[1]] = value.strip()
114
+ elif value.strip() != slot[3].strip():
115
+ if not value.strip():
116
+ raise ValueError(f"the Edit block for {slot[2]} must not be emptied")
117
+ entry_edits.append((slot[1], slot[2], value.strip()))
118
+ if not replies and not entry_edits:
119
+ raise ValueError("the draft holds no reply and no changed Edit block")
120
+ expected = dict(zip(snapshot["ids"], snapshot["updated"]))
121
+ return replies, entry_edits, expected
122
+ except (OSError, UnicodeError, KeyError, TypeError, IndexError, json.JSONDecodeError) as error:
123
+ raise ValueError("invalid or unregistered draft") from error