textfold 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. textfold/__init__.py +2 -0
  2. textfold/__main__.py +134 -0
  3. textfold/agents.py +344 -0
  4. textfold/biorxiv.py +147 -0
  5. textfold/bridges.py +115 -0
  6. textfold/build.py +123 -0
  7. textfold/engine.py +288 -0
  8. textfold/epmc.py +88 -0
  9. textfold/fetch.py +65 -0
  10. textfold/fold.py +135 -0
  11. textfold/github.py +91 -0
  12. textfold/jats.py +395 -0
  13. textfold/kinds.py +49 -0
  14. textfold/ladder.py +296 -0
  15. textfold/langs.py +52 -0
  16. textfold/links.py +68 -0
  17. textfold/llm.py +204 -0
  18. textfold/llm_openai.py +101 -0
  19. textfold/local.py +143 -0
  20. textfold/markdown.py +115 -0
  21. textfold/models.py +144 -0
  22. textfold/notes.py +298 -0
  23. textfold/office.py +879 -0
  24. textfold/omml.py +204 -0
  25. textfold/parse.py +381 -0
  26. textfold/pdf.py +85 -0
  27. textfold/pdfdoc.py +517 -0
  28. textfold/pipeline.py +70 -0
  29. textfold/providers.py +205 -0
  30. textfold/semantic.py +567 -0
  31. textfold/server.py +682 -0
  32. textfold/store.py +231 -0
  33. textfold/tokens.py +130 -0
  34. textfold/translate.py +228 -0
  35. textfold/web/app.css +259 -0
  36. textfold/web/app.html +46 -0
  37. textfold/web/app.js +805 -0
  38. textfold/web/i18n.js +330 -0
  39. textfold/web/reader.css +768 -0
  40. textfold/web/reader.js +3344 -0
  41. textfold/web/template.html +28 -0
  42. textfold/web/tutorial.js +501 -0
  43. textfold/web/vendor/fonts/OFL-Inter.txt +93 -0
  44. textfold/web/vendor/fonts/OFL-SourceSerif4.txt +93 -0
  45. textfold/web/vendor/fonts/fonts.css +172 -0
  46. textfold/web/vendor/fonts/inter-normal-cyrillic-ext.woff2 +0 -0
  47. textfold/web/vendor/fonts/inter-normal-cyrillic.woff2 +0 -0
  48. textfold/web/vendor/fonts/inter-normal-greek-ext.woff2 +0 -0
  49. textfold/web/vendor/fonts/inter-normal-greek.woff2 +0 -0
  50. textfold/web/vendor/fonts/inter-normal-latin-ext.woff2 +0 -0
  51. textfold/web/vendor/fonts/inter-normal-latin.woff2 +0 -0
  52. textfold/web/vendor/fonts/inter-normal-vietnamese.woff2 +0 -0
  53. textfold/web/vendor/fonts/sourceserif4-italic-cyrillic-ext.woff2 +0 -0
  54. textfold/web/vendor/fonts/sourceserif4-italic-cyrillic.woff2 +0 -0
  55. textfold/web/vendor/fonts/sourceserif4-italic-greek.woff2 +0 -0
  56. textfold/web/vendor/fonts/sourceserif4-italic-latin-ext.woff2 +0 -0
  57. textfold/web/vendor/fonts/sourceserif4-italic-latin.woff2 +0 -0
  58. textfold/web/vendor/fonts/sourceserif4-italic-vietnamese.woff2 +0 -0
  59. textfold/web/vendor/fonts/sourceserif4-normal-cyrillic-ext.woff2 +0 -0
  60. textfold/web/vendor/fonts/sourceserif4-normal-cyrillic.woff2 +0 -0
  61. textfold/web/vendor/fonts/sourceserif4-normal-greek.woff2 +0 -0
  62. textfold/web/vendor/fonts/sourceserif4-normal-latin-ext.woff2 +0 -0
  63. textfold/web/vendor/fonts/sourceserif4-normal-latin.woff2 +0 -0
  64. textfold/web/vendor/fonts/sourceserif4-normal-vietnamese.woff2 +0 -0
  65. textfold/web/vendor/katex/LICENSE +21 -0
  66. textfold/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  67. textfold/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  68. textfold/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  69. textfold/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  70. textfold/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  71. textfold/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  72. textfold/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  73. textfold/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  74. textfold/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  75. textfold/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  76. textfold/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  77. textfold/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  78. textfold/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  79. textfold/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  80. textfold/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  81. textfold/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  82. textfold/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  83. textfold/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  84. textfold/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  85. textfold/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  86. textfold/web/vendor/katex/katex.min.css +1 -0
  87. textfold/web/vendor/katex/katex.min.js +1 -0
  88. textfold/web/vendor/pdfjs/LICENSE.txt +177 -0
  89. textfold/web/vendor/pdfjs/pdf.min.mjs +28 -0
  90. textfold/web/vendor/pdfjs/pdf.worker.min.mjs +28 -0
  91. textfold/webpage.py +119 -0
  92. textfold/wiki.py +199 -0
  93. textfold-0.10.0.dist-info/METADATA +262 -0
  94. textfold-0.10.0.dist-info/RECORD +99 -0
  95. textfold-0.10.0.dist-info/WHEEL +5 -0
  96. textfold-0.10.0.dist-info/entry_points.txt +2 -0
  97. textfold-0.10.0.dist-info/licenses/LICENSE +202 -0
  98. textfold-0.10.0.dist-info/licenses/NOTICE +17 -0
  99. textfold-0.10.0.dist-info/top_level.txt +1 -0
textfold/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """TextFold: zoom any text, from every word to a one-screen map."""
2
+ __version__ = "0.10.0"
textfold/__main__.py ADDED
@@ -0,0 +1,134 @@
1
+ """TextFold.
2
+
3
+ python -m textfold serve [--port 3017] the local app (landing, sidebar, reader)
4
+ python -m textfold <arxiv id or url> [--lang zh] ... generate from the command line, then export
5
+ python -m textfold notes.md [--lang zh] ... the same for a Markdown file
6
+ python -m textfold export <arxiv id> one self-contained HTML file in out/<id>/
7
+ python3 -m textfold get <link or file> [--level topic] [--section 3] [--json] a document at one level (for a person or an agent)
8
+ python3 -m textfold engine status | install | remove the PDF engine (MinerU, about 3.4 GB)
9
+ """
10
+ import argparse
11
+ import shutil
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from . import build, local, pipeline
17
+
18
+
19
+ def _say(*a):
20
+ print(*a, file=sys.stderr, flush=True)
21
+
22
+
23
+ def get(a):
24
+ """A document at one level, on stdout: written first when it has no levels yet (a model is called), unless
25
+ --no-generate. A PDF (or a Word, PowerPoint, Excel, EPUB or HTML file, a picture) goes through the PDF engine; a
26
+ Markdown or text file is read as it is."""
27
+ from . import engine, fold, models, office, pdfdoc, store
28
+ f = Path(a.ref).expanduser()
29
+ ext = engine.kind_of(f.name) if f.is_file() else None
30
+ if ext:
31
+ data = f.read_bytes()
32
+ pid = pdfdoc.pid_of(data, ext)
33
+ if not (store.pdir(pid) / "meta.json").exists() and not engine.where() and ext != "pdf" and office.reads(ext):
34
+ office.save(pid, f, f.name) # (the engine reads it more completely, when it is installed)
35
+ elif not (store.pdir(pid) / "meta.json").exists():
36
+ if not engine.where():
37
+ what = "PDF" if ext == "pdf" else "picture" if ext in engine.PICTURES else f"{engine.FILES[ext]} file"
38
+ _say(f"This {what} needs the PDF engine: python3 -m textfold engine install")
39
+ return 2
40
+ tier = engine.tier_for(f"x.{ext}", models.load_settings().get("pdf_tier") or "fast")
41
+ out = store.DATA / "uploads" / f"{pid}-{tier}"
42
+ src = f
43
+ if f.suffix.lower() != f".{ext}": # (an extension the engine knows by another: .tif)
44
+ src = store.DATA / "uploads" / f"{pid}.{ext}"
45
+ src.parent.mkdir(parents=True, exist_ok=True)
46
+ src.write_bytes(data)
47
+ engine.convert(src, out, tier, progress=lambda s, d, t: _say(f" converting {d}/{t}") if t else None, log=lambda line: None)
48
+ pdfdoc.store_pdf(pid, out, pdfdoc.read_output(out), f.name, tier)
49
+ (store.pdir(pid) / pdfdoc.kept_name(pid, src)).write_bytes(data)
50
+ shutil.rmtree(out, ignore_errors=True) # the engine's output (and its copy of the file), stored
51
+ if src != f:
52
+ src.unlink(missing_ok=True)
53
+ elif f.is_file():
54
+ pid = local.import_file(f)
55
+ else:
56
+ pid = store.resolve(a.ref)
57
+ try:
58
+ spec = None if a.no_generate else models.resolve(None)
59
+ except RuntimeError:
60
+ spec = None
61
+ try:
62
+ pipeline.generate(pid, a.lang, spec, progress=lambda s, d, t: _say(f" {s} {d}/{t}") if t and s != "done" else None, log=lambda line: None)
63
+ except RuntimeError as e: # fetched and parsed; its levels need a model: the local ones, said so
64
+ if "No model" not in str(e):
65
+ raise
66
+ _say("No model: the written levels are the author's first sentences (connect one with python3 -m textfold serve, Settings).")
67
+ d = fold.document(pid, a.lang)
68
+ if a.json:
69
+ import json
70
+ print(json.dumps(d, ensure_ascii=False, indent=1))
71
+ else:
72
+ print(fold.markdown(d, a.level, a.section), end="")
73
+ return 0
74
+
75
+
76
+ def engine_cmd(args):
77
+ from . import engine
78
+ what = args[0] if args else "status"
79
+ if what == "install":
80
+ engine.install(progress=lambda s, d, t: _say(f" {s} ({d}/{t})"), log=_say)
81
+ elif what == "remove":
82
+ engine.uninstall()
83
+ elif what != "status":
84
+ _say("python3 -m textfold engine status | install | remove")
85
+ return 2
86
+ st = engine.status()
87
+ print(f"PDF engine: {'installed' if st['installed'] else 'not installed'} (MinerU {st['version']})"
88
+ + (f", {st['bytes'] / 1e9:.1f} GB in {st['path']}" if st["installed"] else ""))
89
+ return 0
90
+
91
+
92
+ def main():
93
+ if sys.argv[1:2] == ["--version"]:
94
+ from . import __version__
95
+ return print(f"TextFold {__version__}")
96
+ if len(sys.argv) > 1 and sys.argv[1] == "serve":
97
+ ap = argparse.ArgumentParser(prog="python3 -m textfold serve")
98
+ ap.add_argument("--port", type=int, default=3017)
99
+ a = ap.parse_args(sys.argv[2:])
100
+ from .server import serve
101
+ return serve(a.port)
102
+ if len(sys.argv) > 1 and sys.argv[1] == "get":
103
+ from .fold import LEVELS
104
+ ap = argparse.ArgumentParser(prog="python3 -m textfold get", description="A document at one level: the map first, then the parts that matter, deeper.")
105
+ ap.add_argument("ref", help="a link (any web page, arXiv, PubMed Central, bioRxiv, Wikipedia, GitHub), an arXiv id, a DOI, or a file (.pdf, .docx, .pptx, .xlsx, .epub, .html, a picture, .md, .txt)")
106
+ ap.add_argument("--level", default="topic", choices=LEVELS, help="topic (a map of the sections) ... full (every word); default topic")
107
+ ap.add_argument("--section", help="one section: its number (3.2), its id (S3), or its place in the list (1 is the first)")
108
+ ap.add_argument("--lang", default="en", help="the language to read it in (en, zh, de, ...); default en")
109
+ ap.add_argument("--json", action="store_true", help="the whole document in the TextFold format (docs/format.md)")
110
+ ap.add_argument("--no-generate", action="store_true", help="call no model: the levels already written, or the local ones")
111
+ return sys.exit(get(ap.parse_args(sys.argv[2:])))
112
+ if len(sys.argv) > 1 and sys.argv[1] == "engine":
113
+ return sys.exit(engine_cmd(sys.argv[2:]))
114
+ if len(sys.argv) > 1 and sys.argv[1] == "export":
115
+ print(build.export(sys.argv[2]))
116
+ return
117
+ ap = argparse.ArgumentParser(prog="textfold", description="Generate a zoomable reader for an arXiv paper or a Markdown file.")
118
+ ap.add_argument("paper", help="arXiv id or URL, e.g. 2512.23916, or a Markdown file")
119
+ ap.add_argument("--lang", action="append", default=[], help="also generate this language (zh, ja, es, ...); repeatable")
120
+ ap.add_argument("--model", default="sonnet", help="model alias for `claude -p` (default: sonnet)")
121
+ ap.add_argument("--jobs", type=int, default=4, help="parallel model calls")
122
+ ap.add_argument("--force", action="store_true", help="regenerate (ignore what is stored and cached)")
123
+ a = ap.parse_args()
124
+ t0 = time.time()
125
+ ref = local.import_file(a.paper) if Path(a.paper).suffix.lower() in local.KINDS and Path(a.paper).is_file() else a.paper
126
+ pid = None
127
+ for lang in ["en"] + a.lang:
128
+ pid = pipeline.generate(ref, lang, a.model, force=a.force, jobs=a.jobs, log=print,
129
+ progress=lambda s, d, t: print(f" {s} {d}/{t}", end="\r"))
130
+ print(f"\n → {build.export(pid)} ({time.time() - t0:.0f}s)")
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
textfold/agents.py ADDED
@@ -0,0 +1,344 @@
1
+ """Coding agents already installed on this computer, ported from ThoughtDAG (runtime/agents/*.cjs).
2
+
3
+ The server scans for them at startup (and on demand): every mainstream agent CLI is looked for on PATH,
4
+ on the login shell's PATH, in the usual install directories and under the node version managers, as
5
+ ThoughtDAG's where.cjs does. Three can run a generation, the three ThoughtDAG drives:
6
+
7
+ claude-code claude -p, structured output by --json-schema models: fable, opus, sonnet, haiku
8
+ codex codex exec, structured output by --output-schema models: `codex app-server` model/list
9
+ pi pi -p --mode json, JSON asked for in the prompt models: `pi --mode rpc` get_available_models
10
+
11
+ The others are reported when found, but not driven yet. A model id is ThoughtDAG's:
12
+ `claude/claude-code/opus`, `codex/codex/<model>`, `pi/<provider>/<model>`.
13
+ Agents run here only as one-shot text generators: no tools, no session, a scratch working directory.
14
+ """
15
+ import glob
16
+ import json
17
+ import os
18
+ import re
19
+ import select
20
+ import subprocess
21
+ import tempfile
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ HOME = Path.home()
27
+ # runtime, label, binary, extra directories, can it run a generation here
28
+ REGISTRY = [
29
+ ("claude-code", "Claude Code", "claude", ["~/.claude/local"], True),
30
+ ("codex", "Codex", "codex", ["~/.codex/bin"], True),
31
+ ("pi", "Pi", "pi", ["~/.pi/bin"], True),
32
+ ("gemini", "Gemini CLI", "gemini", [], False),
33
+ ("qwen", "Qwen Code", "qwen", [], False),
34
+ ("opencode", "opencode", "opencode", ["~/.opencode/bin"], False),
35
+ ("cursor-agent", "Cursor Agent", "cursor-agent", [], False),
36
+ ("kimi", "Kimi CLI", "kimi", [], False),
37
+ ("amp", "Amp", "amp", [], False),
38
+ ("goose", "Goose", "goose", [], False),
39
+ ("crush", "Crush", "crush", [], False),
40
+ ("droid", "Factory Droid", "droid", ["~/.factory/bin"], False),
41
+ ("copilot", "GitHub Copilot CLI", "copilot", [], False),
42
+ ("iflow", "iFlow CLI", "iflow", [], False),
43
+ ("aider", "Aider", "aider", [], False),
44
+ ]
45
+ PREFIX = {"claude-code": "claude/", "codex": "codex/", "pi": "pi/"}
46
+ COMMON = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "~/.bun/bin", "~/.npm-global/bin", "~/.local/bin"]
47
+
48
+
49
+ def _version_key(p):
50
+ return [int(x) if x.isdigit() else 0 for x in re.findall(r"\d+", p)]
51
+
52
+
53
+ _login_path = None
54
+
55
+
56
+ def search_dirs():
57
+ """PATH, the login shell's PATH, common install dirs, version-manager dirs (ThoughtDAG where.cjs)."""
58
+ global _login_path
59
+ if _login_path is None:
60
+ shell = os.environ.get("SHELL", "/bin/zsh")
61
+ try:
62
+ cmd = [shell, "-l", "-c", "string join : $PATH"] if shell.endswith("fish") else [shell, "-ilc", 'printf "%s" "$PATH"']
63
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=4, stdin=subprocess.DEVNULL)
64
+ _login_path = (r.stdout.strip().splitlines() or [""])[-1]
65
+ except Exception:
66
+ _login_path = ""
67
+ dirs = os.environ.get("PATH", "").split(os.pathsep) + _login_path.split(":") + COMMON
68
+ managers = sorted(glob.glob(str(HOME / ".nvm/versions/node/*/bin")), key=_version_key, reverse=True)
69
+ for pattern in ["~/.fnm/node-versions/*/installation/bin", "~/Library/Application Support/fnm/node-versions/*/installation/bin",
70
+ "~/.volta/bin", "~/n/bin", "~/.nodenv/shims", "~/.asdf/shims", "~/.local/share/mise/shims", "~/.proto/bin",
71
+ "~/Library/pnpm", "~/.local/share/pnpm", "~/.yarn/bin", "~/.config/yarn/global/node_modules/.bin",
72
+ "~/.deno/bin", "~/.cargo/bin", "~/bin"]:
73
+ managers += sorted(glob.glob(os.path.expanduser(pattern)), key=_version_key, reverse=True)
74
+ out, seen = [], set()
75
+ for d in dirs + managers:
76
+ d = os.path.expanduser(d)
77
+ if d and d not in seen and os.path.isdir(d):
78
+ seen.add(d)
79
+ out.append(d)
80
+ return out
81
+
82
+
83
+ def child_env():
84
+ """Children get the widened PATH, so a node-based CLI finds node, and USER and LOGNAME even where the server was
85
+ started without them (launchd, cron, env -i): Claude Code finds its sign-in in the keychain under USER, and
86
+ without it says it is not logged in."""
87
+ env = {**os.environ, "PATH": os.pathsep.join(search_dirs())}
88
+ if not (env.get("USER") and env.get("LOGNAME")):
89
+ try:
90
+ import pwd # not on Windows, where neither is needed
91
+ name = pwd.getpwuid(os.getuid()).pw_name
92
+ except (ImportError, KeyError):
93
+ name = None
94
+ for k in ("USER", "LOGNAME"):
95
+ if name and not env.get(k):
96
+ env[k] = name
97
+ return env
98
+
99
+
100
+ def locate(binary, extra=()):
101
+ for d in [os.path.expanduser(x) for x in extra] + search_dirs():
102
+ f = os.path.join(d, binary)
103
+ if os.path.isfile(f) and os.access(f, os.X_OK):
104
+ return f
105
+ return None
106
+
107
+
108
+ def _run(args, timeout, **kw):
109
+ return subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=child_env(), stdin=subprocess.DEVNULL, **kw)
110
+
111
+
112
+ def _help_levels(path, flag, pattern):
113
+ """Effort or thinking levels as the CLI's own --help lists them."""
114
+ try:
115
+ text = _run([path, "--help"], 8).stdout
116
+ except Exception:
117
+ return []
118
+ m = re.search(pattern, text, re.S)
119
+ return [x.strip() for x in m.group(1).split(",") if x.strip()] if m else []
120
+
121
+
122
+ # ── catalogs ──────────────────────────────────────────────────────────
123
+ def _claude_models(path):
124
+ efforts = _help_levels(path, "--effort", r"--effort <level>.*?\(([^)]*)\)")
125
+ efforts = (["off"] + efforts) if efforts else [] # off: no thinking at all (llm.py sets the thinking budget to nothing)
126
+ # (the user's own effortLevel is not the default here: llm.py leaves the user settings out of a call)
127
+ return [{"id": f"claude/claude-code/{a}", "name": f"Claude Code · {a.capitalize()}", "runtime": "claude-code",
128
+ "model": a, "efforts": efforts, "defaultEffort": None}
129
+ for a in ["fable", "opus", "sonnet", "haiku"]]
130
+
131
+
132
+ def _rpc(args, requests, timeout=30, cwd=None):
133
+ """Write JSON lines, collect the replies whose id we asked for."""
134
+ p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
135
+ env=child_env(), cwd=cwd or str(HOME))
136
+ got = {}
137
+ try:
138
+ for r in requests:
139
+ p.stdin.write(json.dumps(r) + "\n")
140
+ p.stdin.flush()
141
+ want = {r["id"] for r in requests if "id" in r}
142
+ t0 = time.time()
143
+ while want - set(got) and time.time() - t0 < timeout:
144
+ ready, _, _ = select.select([p.stdout], [], [], 0.5)
145
+ if not ready:
146
+ continue
147
+ line = p.stdout.readline()
148
+ if not line:
149
+ break
150
+ try:
151
+ d = json.loads(line)
152
+ except ValueError:
153
+ continue
154
+ if d.get("id") in want:
155
+ got[d["id"]] = d
156
+ finally:
157
+ p.kill()
158
+ return got
159
+
160
+
161
+ def _codex_models(path):
162
+ got = _rpc([path, "app-server"], [
163
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize",
164
+ "params": {"clientInfo": {"name": "textfold", "title": "TextFold", "version": "0.4"}, "capabilities": None}},
165
+ {"jsonrpc": "2.0", "method": "initialized"},
166
+ {"jsonrpc": "2.0", "id": 2, "method": "model/list", "params": {}}])
167
+ rows = (got.get(2) or {}).get("result", {}).get("data", [])
168
+ home = Path(os.environ.get("CODEX_HOME", HOME / ".codex"))
169
+ m = re.search(r'^model_reasoning_effort\s*=\s*"([^"]+)"', (home / "config.toml").read_text(), re.M) if (home / "config.toml").exists() else None
170
+ out = []
171
+ for r in rows:
172
+ if r.get("hidden"):
173
+ continue
174
+ mid = r.get("model") or r.get("id")
175
+ efforts = [e.get("reasoningEffort") for e in r.get("supportedReasoningEfforts", []) if e.get("reasoningEffort")]
176
+ default = m.group(1) if m and m.group(1) in efforts else r.get("defaultReasoningEffort")
177
+ out.append({"id": f"codex/codex/{mid}", "name": f"Codex · {r.get('displayName') or mid}", "runtime": "codex", "model": mid,
178
+ "efforts": efforts, "defaultEffort": default, "isDefault": bool(r.get("isDefault"))})
179
+ return out
180
+
181
+
182
+ def _pi_models(path):
183
+ levels = _help_levels(path, "--thinking", r"--thinking <level>\s+Set thinking level:\s*([^\n]*)")
184
+ got = _rpc([path, "--mode", "rpc", "--no-session"], [{"id": "1", "type": "get_available_models"}])
185
+ data = (got.get("1") or {}).get("data")
186
+ rows = data.get("models", []) if isinstance(data, dict) else (data or [])
187
+ return [{"id": f"pi/{r['provider']}/{r['id']}", "name": f"Pi · {r.get('name') or r['id']}", "runtime": "pi",
188
+ "model": f"{r['provider']}/{r['id']}", "efforts": levels if r.get("reasoning") else [], "defaultEffort": None,
189
+ "vision": "image" in (r.get("input") or [])} for r in rows if r.get("provider") and r.get("id")]
190
+
191
+
192
+ CATALOG = {"claude-code": _claude_models, "codex": _codex_models, "pi": _pi_models}
193
+
194
+ # ── the scan ──────────────────────────────────────────────────────────
195
+ STATE = {"scanned_at": None, "scanning": False, "agents": []}
196
+ _lock = threading.Lock()
197
+
198
+
199
+ def scan(log=None):
200
+ """Find every registered agent, ask the runnable ones for their models. Safe to call from any thread."""
201
+ global _login_path
202
+ with _lock:
203
+ STATE["scanning"] = True
204
+ _login_path = None # a new install may have changed the login PATH
205
+ found, seen = [], {}
206
+ for runtime, label, binary, extra, runnable in REGISTRY:
207
+ # TEXTFOLD_NO_MODELS=1: none looked for (a test server, a CI run: nothing calls a model by accident)
208
+ path = None if os.environ.get("TEXTFOLD_NO_MODELS") else locate(binary, extra)
209
+ row = {"runtime": runtime, "label": label, "binary": binary, "path": path, "runnable": runnable,
210
+ "version": None, "models": [], "error": None}
211
+ if path:
212
+ real = os.path.realpath(path)
213
+ if real in seen: # one program under two names counts once
214
+ row.update(path=None, error=f"same program as {seen[real]}")
215
+ else:
216
+ seen[real] = label
217
+ try:
218
+ out = _run([path, "--version"], 8)
219
+ row["version"] = ((out.stdout or out.stderr).strip().splitlines() or [""])[0][:80]
220
+ except Exception as e:
221
+ row["error"] = str(e)[:200]
222
+ if runnable:
223
+ try:
224
+ row["models"] = CATALOG[runtime](path)
225
+ except Exception as e:
226
+ row["error"] = f"model list: {e}"[:200]
227
+ found.append(row)
228
+ STATE.update(agents=found, scanned_at=time.time(), scanning=False)
229
+ if log:
230
+ have = [f"{a['label']} {a['version'] or ''}".strip() + (f" ({len(a['models'])} models)" if a["models"] else "")
231
+ for a in found if a["path"]]
232
+ log("Agents found: " + (", ".join(have) or "none"))
233
+ return STATE
234
+
235
+
236
+ def models():
237
+ return [m for a in STATE["agents"] if a["path"] and a["runnable"] for m in a["models"]]
238
+
239
+
240
+ def runtime_of(model_id):
241
+ return next((rt for rt, p in PREFIX.items() if model_id.startswith(p)), None)
242
+
243
+
244
+ def path_of(runtime):
245
+ a = next((a for a in STATE["agents"] if a["runtime"] == runtime and a["path"]), None)
246
+ if a:
247
+ return a["path"]
248
+ binary, extra = next((r[2], r[3]) for r in REGISTRY if r[0] == runtime)
249
+ return locate(binary, extra)
250
+
251
+
252
+ # ── one-shot runs ─────────────────────────────────────────────────────
253
+ def _strict(schema):
254
+ """OpenAI-style structured output wants every object closed and every property required."""
255
+ if isinstance(schema, dict):
256
+ s = {k: _strict(v) for k, v in schema.items()}
257
+ if s.get("type") == "object" and "properties" in s:
258
+ s["additionalProperties"] = False
259
+ s["required"] = list(s["properties"])
260
+ return s
261
+ if isinstance(schema, list):
262
+ return [_strict(x) for x in schema]
263
+ return schema
264
+
265
+
266
+ def parse_json(text, schema=None):
267
+ """The reply's JSON object, asked for in the prompt: the first object that parses (and has the schema's required
268
+ keys), wherever it starts. A model that is not reasoning sometimes adds a line after the object, or repeats it."""
269
+ text, dec = text or "", json.JSONDecoder()
270
+ need = set((schema or {}).get("required", []))
271
+ first = None
272
+ for m in re.finditer(r"\{", text):
273
+ try:
274
+ obj, _ = dec.raw_decode(text, m.start())
275
+ except ValueError:
276
+ continue
277
+ if isinstance(obj, dict) and need <= set(obj):
278
+ return obj
279
+ first = first if first is not None else obj
280
+ if first is not None and not need:
281
+ return first
282
+ raise RuntimeError("no JSON in reply" if "{" not in text else "the reply's JSON does not parse")
283
+
284
+
285
+ def run_codex(model, prompt, schema, system, effort, timeout):
286
+ path = path_of("codex")
287
+ if not path:
288
+ raise RuntimeError("Codex was not found on this computer")
289
+ with tempfile.TemporaryDirectory() as tmp: # a scratch cwd: no repository, nothing to edit
290
+ sf, of = Path(tmp) / "schema.json", Path(tmp) / "out.txt"
291
+ sf.write_text(json.dumps(_strict(schema)))
292
+ args = [path, "exec", "--ephemeral", "--skip-git-repo-check", "-s", "read-only", "-m", model,
293
+ "--output-schema", str(sf), "-o", str(of)]
294
+ # off is "none" (gpt-5.5 takes it, and refuses "minimal"); a model that refuses "none" runs at "low"
295
+ for level in (["none", "low"] if effort == "off" else [effort]):
296
+ r = subprocess.run(args + (["-c", f"model_reasoning_effort={level}"] if level else []) + ["-"], input=f"{system}\n\n{prompt}",
297
+ capture_output=True, text=True, timeout=timeout, env=child_env(), cwd=tmp)
298
+ if not r.returncode and of.exists():
299
+ break
300
+ if r.returncode or not of.exists():
301
+ raise RuntimeError(f"codex exec exited {r.returncode}: {(r.stderr or r.stdout)[-800:]}")
302
+ return parse_json(of.read_text()), None
303
+
304
+
305
+ def run_pi(model, prompt, schema, system, effort, timeout):
306
+ path = path_of("pi")
307
+ if not path:
308
+ raise RuntimeError("Pi was not found on this computer")
309
+ args = [path, "-p", "--mode", "json", "--no-tools", "--no-session", "--no-context-files", "--no-extensions", "--no-skills",
310
+ "--no-prompt-templates", "--model", model, "--system-prompt", system]
311
+ if effort:
312
+ args += ["--thinking", effort]
313
+ body = f"{prompt}\n\nReply with one JSON object that matches this JSON Schema, and nothing else:\n{json.dumps(schema)}"
314
+ for attempt in (1, 2): # a reply whose JSON does not parse is asked for once more (it is a sample: the next one parses)
315
+ try:
316
+ return _pi_once(args, body, schema, timeout)
317
+ except ValueError:
318
+ if attempt == 2:
319
+ raise
320
+
321
+
322
+ def _pi_once(args, body, schema, timeout):
323
+ with tempfile.TemporaryDirectory() as tmp:
324
+ r = subprocess.run(args, input=body, capture_output=True, text=True, timeout=timeout, env=child_env(), cwd=tmp)
325
+ text, cost = None, None
326
+ for line in r.stdout.splitlines():
327
+ try:
328
+ d = json.loads(line)
329
+ except ValueError:
330
+ continue
331
+ if d.get("type") == "agent_end":
332
+ last = next((m for m in reversed(d.get("messages", [])) if m.get("role") == "assistant"), None)
333
+ if last:
334
+ text = "".join(c.get("text", "") for c in last.get("content", []) if c.get("type") == "text")
335
+ cost = ((last.get("usage") or {}).get("cost") or {}).get("total")
336
+ if last.get("stopReason") == "error" or last.get("errorMessage"):
337
+ raise RuntimeError(f"pi: {last.get('errorMessage') or 'error'}")
338
+ if text is None:
339
+ raise RuntimeError(f"pi exited {r.returncode}: {(r.stderr or r.stdout)[-800:]}")
340
+ try:
341
+ return parse_json(text, schema), cost
342
+ except RuntimeError as e:
343
+ raise ValueError(str(e))
344
+
textfold/biorxiv.py ADDED
@@ -0,0 +1,147 @@
1
+ """Preprints from bioRxiv and medRxiv, read from their own JATS XML.
2
+
3
+ Europe PMC holds the full text of only some of them (about 20,000 of bioRxiv's 350,000), so a preprint it does not
4
+ hold is read from the server itself: its API (api.biorxiv.org) gives each version's XML. The newest version's XML
5
+ can be the abstract alone while its full text is being prepared; then the newest one with a body is read.
6
+ Their XML draws tables and display formulas as pictures, which the page shows as such.
7
+
8
+ 10.1101/2024.01.15.575678 · 10.64898/2026.09.24.754278 (openRxiv's prefix since 2026)
9
+ https://www.biorxiv.org/content/10.64898/2026.09.24.754278v1 · the same on medrxiv.org
10
+
11
+ Stored as papers/biorxiv-<suffix>/ or papers/medrxiv-<suffix>/. A request for the XML and one for each image, with a
12
+ User-Agent of its own (the servers turn away the generic ones), and seven seconds apart, as their robots.txt asks
13
+ (Crawl-delay: 7); faster, their firewall shuts the door for a while. A paper with many pictures takes a minute or two.
14
+ """
15
+ import json
16
+ import re
17
+ import threading
18
+ import time
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+
23
+ from . import epmc, jats, local, store
24
+ from .fetch import UA
25
+
26
+ API = "https://api.biorxiv.org/details/{server}/{doi}"
27
+ SERVERS = ("biorxiv", "medrxiv")
28
+ PREFIXES = ("10.1101", "10.64898")
29
+ DOI = re.compile(r"\b(10\.(?:1101|64898)/[\w.]+?)(?:v\d+)?(?:\.full(?:-text|\.pdf)?|\.abstract|\.supplementary-material)?(?=$|[/?#\s])")
30
+ DELAY = 7 # seconds between requests to www.biorxiv.org and www.medrxiv.org (their robots.txt)
31
+ _pace = {"last": 0.0, "lock": threading.Lock()}
32
+ LICENSES = {"cc_by": "by/4.0", "cc_by_nc": "by-nc/4.0", "cc_by_nd": "by-nd/4.0", "cc_by_nc_nd": "by-nc-nd/4.0"}
33
+
34
+
35
+ def ref_of(ref):
36
+ """(server or None, DOI) for a bioRxiv or medRxiv link or DOI; (None, None) for anything else."""
37
+ ref = urllib.parse.unquote((ref or "").strip())
38
+ m = DOI.search(ref)
39
+ if not m:
40
+ return None, None
41
+ host = re.search(r"(biorxiv|medrxiv)\.org", ref, re.I)
42
+ return (host.group(1).lower() if host else None), m.group(1)
43
+
44
+
45
+ def pid_of(server, doi):
46
+ return f"{server}-{doi.split('/', 1)[1]}"
47
+
48
+
49
+ def _get(url, timeout=60):
50
+ if re.match(r"https?://(www\.)?(biorxiv|medrxiv)\.org/", url):
51
+ with _pace["lock"]:
52
+ time.sleep(max(0.0, _pace["last"] + DELAY - time.time()))
53
+ _pace["last"] = time.time()
54
+ with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
55
+ return r.read(), r.geturl()
56
+
57
+
58
+ def versions(server, doi):
59
+ data, _ = _get(API.format(server=server, doi=doi), 30)
60
+ return sorted(json.loads(data).get("collection") or [], key=lambda r: int(r.get("version") or 0))
61
+
62
+
63
+ def find(doi, server=None):
64
+ """The server that holds a DOI, and its versions; ValueError("no-fulltext") when neither does."""
65
+ for s in ([server] if server else SERVERS):
66
+ vs = versions(s, doi)
67
+ if vs:
68
+ return s, vs
69
+ raise ValueError("no-fulltext")
70
+
71
+
72
+ def image_resolver(content, folder, progress=None, total=0):
73
+ """A figure's or table's "F1.large.jpg" beside the XML (…/content/biorxiv/early/<date>/<suffix>/); any other
74
+ picture ("embed/graphic-6.gif") where the site keeps them (…/sites/default/files/highwire/biorxiv/early/…)."""
75
+ images = local.Images(folder, None, get=_get)
76
+
77
+ def resolve(src):
78
+ if progress:
79
+ progress("fetch", len(images.seen), total)
80
+ if src.startswith("embed/"):
81
+ src = re.sub(r"/content/(biorxiv|medrxiv)/", r"/sites/default/files/highwire/\1/", content) + "/" + src
82
+ elif not re.match(r"https?://", src):
83
+ src = content + "/" + src
84
+ return images.resolve(src)
85
+ return resolve
86
+
87
+
88
+ def _from_europepmc(pid, server, rec):
89
+ """The preprint as Europe PMC holds it (its full text converted from the PDF), stored under this preprint's id."""
90
+ name = "bioRxiv" if server == "biorxiv" else "medRxiv"
91
+ refused = RuntimeError(f"{name} refused this computer for now (its firewall), and Europe PMC holds no full text of this "
92
+ "preprint. Try again later.")
93
+ try:
94
+ eid = epmc.by_doi(rec["doi"])
95
+ return epmc.fetch(pid, eid, {"abs_url": f"https://www.{server}.org/content/{rec['doi']}v{rec.get('version', '')}",
96
+ "label": f"{name} {rec['doi']} · Europe PMC {eid.upper()}", "doi": rec["doi"]})
97
+ except (ValueError, RuntimeError): # no full text there either (some it lists are PDFs only, answered with a 500)
98
+ raise refused
99
+
100
+
101
+ def _has_body(xml):
102
+ """The XML of a version whose full text is not ready yet holds the abstract and an empty body."""
103
+ m = re.search(r"<body\b[^>]*>(.*?)</body>", xml, re.S)
104
+ return bool(m) and (len(re.findall(r"<sec\b", m.group(1))) > 0 or len(re.findall(r"<p\b", m.group(1))) >= 3)
105
+
106
+
107
+ def fetch(pid, progress=None):
108
+ """Store a preprint from bioRxiv or medRxiv (once). Its id: biorxiv-… or medrxiv-…. progress(stage, done, total)
109
+ follows the images."""
110
+ d = store.pdir(pid)
111
+ if (d / "meta.json").exists() and (d / "source.html").exists():
112
+ return pid
113
+ server, suffix = pid.split("-", 1)
114
+ found = None
115
+ for prefix in PREFIXES:
116
+ vs = versions(server, f"{prefix}/{suffix}")
117
+ if vs:
118
+ found = vs
119
+ break
120
+ if not found:
121
+ raise RuntimeError("no-fulltext")
122
+ for rec in reversed(found): # the newest version whose XML has a body
123
+ if not rec.get("jatsxml"):
124
+ continue
125
+ try:
126
+ xml, final = _get(rec["jatsxml"])
127
+ except urllib.error.HTTPError as e:
128
+ if e.code == 403: # the site's firewall turned this computer away: Europe PMC's copy, if it holds one
129
+ return _from_europepmc(pid, server, found[-1])
130
+ continue
131
+ xml = xml.decode("utf-8", "replace")
132
+ if _has_body(xml):
133
+ break
134
+ else:
135
+ raise RuntimeError("no-fulltext")
136
+ html, m = jats.to_html(xml)
137
+ lic = LICENSES.get(rec.get("license", ""))
138
+ name = "bioRxiv" if server == "biorxiv" else "medRxiv"
139
+ meta = {"id": pid, "version": f"v{rec.get('version', '')}", "title": m["title"] or rec.get("title", ""),
140
+ "authors": m["authors"] or [a.strip() for a in (rec.get("authors") or "").split(";") if a.strip()],
141
+ "date": rec.get("date", ""), "doi": rec["doi"],
142
+ "abs_url": f"https://www.{server}.org/content/{rec['doi']}v{rec.get('version', '')}", "html_url": "",
143
+ "license": m["license"] or (f"https://creativecommons.org/licenses/{lic}/" if lic else ""),
144
+ "source": {"kind": server, "name": rec["doi"]}, "label": f"{name} {rec['doi']}v{rec.get('version', '')}"}
145
+ content = re.sub(r"\.source\.xml$", "", final)
146
+ total = len(set(re.findall(r'<img src="([^"]+)"', html)))
147
+ return local.store_doc(pid, html, meta, "source.xml", xml, lambda folder: image_resolver(content, folder, progress, total))