@allansantos-dev/smart-tool 0.9.43 → 0.9.45
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.
- package/CHANGELOG.md +34 -0
- package/README.md +17 -2
- package/browser_control.py +71 -15
- package/client_hooks.py +1 -1
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/hook_decision.py +9 -1
- package/lazy_mcp_proxy.py +185 -0
- package/package.json +1 -1
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/smart_tool_daemon.py +68 -10
- package/tool_redirect.py +2 -1
- package/version.py +1 -1
- package/web_search_health.py +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.45 - beta
|
|
4
|
+
|
|
5
|
+
- The Playwright MCP server starts on first use. What the clients register is now `lazy_mcp_proxy.py` around
|
|
6
|
+
the real command: it answers `initialize`, `ping` and `tools/list` from the handshake the setup probe saved
|
|
7
|
+
(`playwright-mcp-handshake.json`) and starts the Node server on the first other request, replaying the client's
|
|
8
|
+
handshake to it, then passes everything through and kills the server's process tree when the client closes. Before, each
|
|
9
|
+
Claude session started its own Node server (about 97 MB of commit) whether or not it opened a browser. A missing or
|
|
10
|
+
broken cache fails loudly (exit 2, reason on stderr), never as an empty tool list; the heal at startup also checks the
|
|
11
|
+
real server's executable. Tests in `tests/test_lazy_mcp_proxy.py` and `tests/test_browser_control.py`.
|
|
12
|
+
- Web searches record who asked. Each provider call (`web-search-health.jsonl`) and each research record
|
|
13
|
+
(`research-metrics.jsonl`) carries `requester` (`client`, `mcp_session`, `session`, `project`, `source`): the PreToolUse
|
|
14
|
+
hook, which gets the Claude Code session and folder, now also matches `web_search` and `web_fetch`, and the MCP call
|
|
15
|
+
claims that note by tool and arguments. Without the hook (Codex, or a hook not yet updated) only the client and the MCP
|
|
16
|
+
session are known and `source` is `none`. A redirected native WebSearch/WebFetch is attributed from the hook payload,
|
|
17
|
+
and `integration-metrics.jsonl` carries the same field. The query text is stored nowhere new. Tests in
|
|
18
|
+
`tests/test_requester.py`. The startup heal also rebuilds a registration that still starts the Node server directly or
|
|
19
|
+
whose handshake cache is gone, and the proxy gives up on a real server silent for 60 s on its first `initialize`.
|
|
20
|
+
- The resident search browser's exit after 300 s without requests is now covered by a test that runs the real service
|
|
21
|
+
loop with a stub browser (`tests/test_browser_idle.py`); the value is unchanged.
|
|
22
|
+
|
|
23
|
+
## 0.9.44 - beta
|
|
24
|
+
|
|
25
|
+
- New tool `read_document`: a PDF, Word, PowerPoint or Excel file read as compact text, a range at a time. The answer opens
|
|
26
|
+
with the type, size, page, slide, sheet or part count and the estimated tokens of all the text, then the text of the
|
|
27
|
+
`range` asked for (`3`, `3-5`, `7-`, `-4`, `1,4-6`, a sheet name) up to `max_chars`, with a `next:` line to continue;
|
|
28
|
+
`info_only` returns just the facts. PDF pages are compacted (ligatures, hyphenated line ends, wrapped lines); a docx is
|
|
29
|
+
markdown (headings, lists, GFM tables, notes, headers) in parts of about 6,000 characters, since it has no fixed pages;
|
|
30
|
+
a slide carries its speaker notes; a sheet is one tab-separated line per row with cached formula values. A scanned PDF,
|
|
31
|
+
a password, a corrupt or hostile file and a range outside the document fail with the reason. Opt-in: the native Read is
|
|
32
|
+
not redirected and the tool is not loaded upfront (the server instructions say to load it with ToolSearch). No new
|
|
33
|
+
dependency; the extractors of the index now return their units (`pdf_pages`, `pptx_slides`, `xlsx_sheets`) and the
|
|
34
|
+
indexed text is unchanged. Sources and the measurement in `docs/research/2026-10-30-read-document.md`; tests in
|
|
35
|
+
`tests/test_read_document.py`.
|
|
36
|
+
|
|
3
37
|
## 0.9.43 - beta
|
|
4
38
|
|
|
5
39
|
- The session-start audit report (Claude Code and Codex) is also given after a compaction and after a fork, the two
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ A local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharpe
|
|
|
8
8
|
| `smart_scout` | Answers a question about the code with the lines that answer it: reads the candidate files inside Smart Tool and returns `path:lines` with the literal text, so the files never enter the agent's context; with a `scout_model`, a small model also writes a short answer and keeps only the lines that support it; a question about why something is as it is (why, reason, decided) also lists up to three decision notes from the notes the project keeps about its intent, as ids with one excerpt each |
|
|
9
9
|
| `web_search` / `web_search_result` | Web search across several sources in parallel, in the query language and in English, with a cache shared across sessions; `depth="research"` runs a multi-round research loop |
|
|
10
10
|
| `web_fetch` | Reads a known URL and returns only the answer to your prompt, written by a small model; pages are cached for 24 h. Pages over 60,000 characters are not cut: the model reads the parts closest to the prompt (needs an `embedding_model`) |
|
|
11
|
+
| `read_document` | Reads a PDF, Word, PowerPoint or Excel file as compact text, a range at a time: type, size, page/slide/sheet count and estimated tokens first, then only the pages, slides or sheets asked for (`range="3-5"`), so the file never enters the agent's context whole. Opt-in: the native Read is not redirected. Scanned PDFs fail with that reason (no OCR) |
|
|
11
12
|
| `project_manage` | Registers and indexes projects, shows coverage, code maps, duplicated functions and index storage; `graph` with `symbol` tells what changing a function touches: callers, calls, the tests that reach it and the files that import it |
|
|
12
13
|
|
|
13
14
|
Agents that must click, type or log in also get Microsoft's [Playwright MCP](https://github.com/microsoft/playwright-mcp),
|
|
@@ -16,7 +17,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
17
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
18
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
19
|
|
|
19
|
-
**Status:** 0.9.
|
|
20
|
+
**Status:** 0.9.45 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
21
|
|
|
21
22
|
## How it works
|
|
22
23
|
|
|
@@ -149,7 +150,21 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
149
150
|
`browser_run_code_unsafe`, which runs arbitrary JavaScript in the server process, is denied in the client
|
|
150
151
|
(`permissions.deny` in `~/.claude/settings.json`, `disabled_tools` in `~/.codex/config.toml`); if it cannot be
|
|
151
152
|
denied, the server is not registered. A `playwright` server you registered yourself is never touched. Remove it with
|
|
152
|
-
`claude mcp remove playwright -s user` / `codex mcp remove playwright`.
|
|
153
|
+
`claude mcp remove playwright -s user` / `codex mcp remove playwright`. The registered command is a small stdio
|
|
154
|
+
proxy (`lazy_mcp_proxy.py`, standard library only) that answers the handshake and the tool list from a cache the
|
|
155
|
+
probe writes and starts the Node server on the first tool call, so a session that never opens a browser does not
|
|
156
|
+
keep a 97 MB Node process; it ends with the session.
|
|
157
|
+
|
|
158
|
+
**Who asked for a web search**: every provider call in `~/.smart-tool/data/web-search-health.jsonl` and every
|
|
159
|
+
record of `research-metrics.jsonl` made for a `web_search` carries `requester`: `client`, `mcp_session` (first 12
|
|
160
|
+
characters of the daemon's session id), `session` and `project` (the Claude Code session id and folder) and `source`
|
|
161
|
+
(`hook`: the PreToolUse hook saw this call; `mcp-session`: an earlier call of the same MCP session was attributed;
|
|
162
|
+
`none`: only the client name is known, which is what Codex gives, since its hook does not cover MCP tools). The
|
|
163
|
+
query text is not stored there. The folder and session come from the installed hook: after an update, "Update hook"
|
|
164
|
+
on the setup page adds `web_search` and `web_fetch` to its matcher. A native WebSearch or WebFetch that the hook
|
|
165
|
+
redirects is attributed from the hook payload itself (`mcp_session` empty). `integration-metrics.jsonl` carries the
|
|
166
|
+
same `requester` on the web events. A `deep` crawl job writes no provider record, so it has none. The hook's note
|
|
167
|
+
waits up to 60 s for its MCP call; an identical query from two sessions at once can swap their notes.
|
|
153
168
|
|
|
154
169
|
The hook behaviors:
|
|
155
170
|
- **Redirect** (default): the call is not denied. Smart Tool answers it and the answer comes back as the output
|
package/browser_control.py
CHANGED
|
@@ -7,6 +7,10 @@ as the agent would: machines whose policy forbids it (corporate IT blocking remo
|
|
|
7
7
|
keep today's read-only behavior, and the reason is shown in the setup screen instead of an error. A server named
|
|
8
8
|
`playwright` the user already registered is left untouched.
|
|
9
9
|
|
|
10
|
+
The registered command is lazy_mcp_proxy.py around the real server: every session starts the registered servers, and
|
|
11
|
+
a Node process kept idle in each session that never opens a browser cost about 97 MB of commit each. The proxy answers
|
|
12
|
+
the handshake from the cache the probe writes (HANDSHAKE_PATH) and starts the real server on the first tool call.
|
|
13
|
+
|
|
10
14
|
Pages the agent opens are untrusted input. The server never exposes tools a page registers (WebMCP); every request
|
|
11
15
|
the browser makes carries AGENT_HEADER, which the daemon refuses, so a page cannot steer the agent into Smart Tool's
|
|
12
16
|
own setup page and its token (redirects included); `browser_run_code_unsafe` (arbitrary JavaScript in the server
|
|
@@ -18,6 +22,7 @@ import os
|
|
|
18
22
|
import queue
|
|
19
23
|
import re
|
|
20
24
|
import subprocess
|
|
25
|
+
import sys
|
|
21
26
|
import tempfile
|
|
22
27
|
import threading
|
|
23
28
|
import time
|
|
@@ -26,6 +31,7 @@ import tomllib
|
|
|
26
31
|
import atomic_io
|
|
27
32
|
import endpoint_sync
|
|
28
33
|
import install_runtime
|
|
34
|
+
import lazy_mcp_proxy
|
|
29
35
|
import node_locator
|
|
30
36
|
import paths
|
|
31
37
|
|
|
@@ -34,6 +40,8 @@ CLI = install_runtime.PLAYWRIGHT_MCP_CLI
|
|
|
34
40
|
STATUS_PATH = os.path.join(paths.DATA_DIR, "browser-control.json")
|
|
35
41
|
OUTPUT_DIR = os.path.join(paths.DATA_DIR, "playwright-mcp")
|
|
36
42
|
CONFIG_PATH = os.path.join(paths.DATA_DIR, "playwright-mcp.json")
|
|
43
|
+
HANDSHAKE_PATH = os.path.join(paths.DATA_DIR, "playwright-mcp-handshake.json")
|
|
44
|
+
PROXY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lazy_mcp_proxy.py")
|
|
37
45
|
AGENT_HEADER = "X-Smart-Tool-Agent-Browser"
|
|
38
46
|
OUTPUT_MAX_BYTES = 100 * 1024 * 1024
|
|
39
47
|
PROBE_TIMEOUT_S = 60
|
|
@@ -68,6 +76,15 @@ def server_command():
|
|
|
68
76
|
"--output-max-size", str(OUTPUT_MAX_BYTES)]
|
|
69
77
|
|
|
70
78
|
|
|
79
|
+
def lazy_command(command):
|
|
80
|
+
"""The command registered in the clients: lazy_mcp_proxy.py (console Python of this installation) around command,
|
|
81
|
+
the real server line, answering from HANDSHAKE_PATH until a tool call needs the real one."""
|
|
82
|
+
python = sys.executable
|
|
83
|
+
if os.path.basename(python).lower() == "pythonw.exe":
|
|
84
|
+
python = os.path.join(os.path.dirname(python), "python.exe")
|
|
85
|
+
return [python, PROXY, "--handshake", HANDSHAKE_PATH, "--", *command]
|
|
86
|
+
|
|
87
|
+
|
|
71
88
|
def write_config():
|
|
72
89
|
"""Writes the Playwright MCP config file that adds AGENT_HEADER to every request of the browser (navigations,
|
|
73
90
|
redirects, subresources; measured: no CORS preflight is added) and returns its path."""
|
|
@@ -77,14 +94,16 @@ def write_config():
|
|
|
77
94
|
return CONFIG_PATH
|
|
78
95
|
|
|
79
96
|
|
|
80
|
-
def probe(command, timeout=PROBE_TIMEOUT_S):
|
|
97
|
+
def probe(command, timeout=PROBE_TIMEOUT_S, handshake_path=None):
|
|
81
98
|
"""Starts the server over stdio, opens about:blank and closes it, as an agent's first call would.
|
|
82
|
-
Returns None when the browser opened, else the reason it did not (a policy block, a crash, a timeout).
|
|
99
|
+
Returns None when the browser opened, else the reason it did not (a policy block, a crash, a timeout). With
|
|
100
|
+
handshake_path, a successful probe also saves the server's initialize result and tool list there, the cache
|
|
101
|
+
lazy_mcp_proxy answers from."""
|
|
83
102
|
with tempfile.TemporaryDirectory(prefix="st-browser-probe-", ignore_cleanup_errors=True) as workdir:
|
|
84
|
-
return _probe(command, workdir, timeout)
|
|
103
|
+
return _probe(command, workdir, timeout, handshake_path)
|
|
85
104
|
|
|
86
105
|
|
|
87
|
-
def _probe(command, workdir, timeout):
|
|
106
|
+
def _probe(command, workdir, timeout, handshake_path=None):
|
|
88
107
|
"""probe() inside workdir, the server's working folder."""
|
|
89
108
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
90
109
|
try:
|
|
@@ -118,14 +137,20 @@ def _probe(command, workdir, timeout):
|
|
|
118
137
|
return message["result"]
|
|
119
138
|
|
|
120
139
|
try:
|
|
121
|
-
call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
122
|
-
|
|
140
|
+
initialized = call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
141
|
+
"clientInfo": {"name": "smart-tool-probe", "version": "1"}})
|
|
123
142
|
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
|
143
|
+
tools = call(4, "tools/list", {}).get("tools") if handshake_path else None
|
|
124
144
|
result = call(2, "tools/call", {"name": "browser_navigate", "arguments": {"url": "about:blank"}})
|
|
125
145
|
if result.get("isError"):
|
|
126
146
|
text = " ".join(part.get("text", "") for part in result.get("content") or [])
|
|
127
147
|
return f"browser did not open: {' '.join(text.split())[:300]}"
|
|
128
148
|
call(3, "tools/call", {"name": "browser_close", "arguments": {}})
|
|
149
|
+
if handshake_path:
|
|
150
|
+
if not tools:
|
|
151
|
+
return "the server listed no tools"
|
|
152
|
+
os.makedirs(os.path.dirname(handshake_path), exist_ok=True)
|
|
153
|
+
atomic_io.write_secret_text(handshake_path, json.dumps({"initialize": initialized, "tools": tools}))
|
|
129
154
|
return None
|
|
130
155
|
except (OSError, RuntimeError, TimeoutError) as exc:
|
|
131
156
|
return str(exc)
|
|
@@ -158,8 +183,9 @@ def registered():
|
|
|
158
183
|
return found
|
|
159
184
|
|
|
160
185
|
|
|
161
|
-
def
|
|
162
|
-
"""
|
|
186
|
+
def registered_executables(client):
|
|
187
|
+
"""Executables the client's `playwright` server needs: the registered command and, behind lazy_mcp_proxy, the real
|
|
188
|
+
server's too; an empty list when it is not registered."""
|
|
163
189
|
try:
|
|
164
190
|
if client == "claude":
|
|
165
191
|
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
@@ -168,9 +194,32 @@ def registered_command(client):
|
|
|
168
194
|
with open(_codex_config(), encoding="utf-8") as stream:
|
|
169
195
|
entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
|
|
170
196
|
except (OSError, ValueError, AttributeError):
|
|
171
|
-
return
|
|
197
|
+
return []
|
|
172
198
|
command = entry.get("command") if isinstance(entry, dict) else None
|
|
173
|
-
|
|
199
|
+
if not isinstance(command, str):
|
|
200
|
+
return []
|
|
201
|
+
args = entry.get("args") if isinstance(entry.get("args"), list) else []
|
|
202
|
+
real = args[args.index("--") + 1] if "--" in args and args.index("--") + 1 < len(args) else None
|
|
203
|
+
return [command] + ([real] if isinstance(real, str) else [])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _lazy_registration_usable(client):
|
|
207
|
+
"""Whether the client's registration goes through lazy_mcp_proxy and the handshake cache it answers from is
|
|
208
|
+
readable: a registration of an older version (the Node server directly) or a lost cache both need ensure."""
|
|
209
|
+
try:
|
|
210
|
+
if client == "claude":
|
|
211
|
+
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
212
|
+
entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
|
|
213
|
+
else:
|
|
214
|
+
with open(_codex_config(), encoding="utf-8") as stream:
|
|
215
|
+
entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
|
|
216
|
+
args = entry.get("args") or []
|
|
217
|
+
if PROXY not in args:
|
|
218
|
+
return False
|
|
219
|
+
lazy_mcp_proxy.load_handshake(HANDSHAKE_PATH)
|
|
220
|
+
except (OSError, ValueError, AttributeError, RuntimeError):
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
174
223
|
|
|
175
224
|
|
|
176
225
|
def heal(log=None):
|
|
@@ -179,9 +228,10 @@ def heal(log=None):
|
|
|
179
228
|
{client: ensure's outcome} for the clients refreshed."""
|
|
180
229
|
refreshed = {}
|
|
181
230
|
for client in ("claude", "codex"):
|
|
182
|
-
|
|
231
|
+
executables = registered_executables(client)
|
|
183
232
|
ours = status().get(client, {}).get("state") in ("registered", "undenied")
|
|
184
|
-
if ours and
|
|
233
|
+
if ours and executables and not (all(node_locator.exists(path) for path in executables)
|
|
234
|
+
and _lazy_registration_usable(client)):
|
|
185
235
|
refreshed[client] = ensure(client, log)
|
|
186
236
|
return refreshed
|
|
187
237
|
|
|
@@ -302,6 +352,12 @@ def _record(client, state, reason="", log=None):
|
|
|
302
352
|
return data[client]
|
|
303
353
|
|
|
304
354
|
|
|
355
|
+
def _clip(text, limit=300):
|
|
356
|
+
"""text within limit characters, keeping its start and its end: a failed CLI call names the long command first and
|
|
357
|
+
the CLI's own message last."""
|
|
358
|
+
return text if len(text) <= limit else text[:limit // 3] + " ... " + text[-(limit - limit // 3 - 5):]
|
|
359
|
+
|
|
360
|
+
|
|
305
361
|
def ensure(client, log=None):
|
|
306
362
|
"""Registers Playwright MCP in the client, or refreshes the registration Smart Tool made before (Node, Chromium
|
|
307
363
|
and install paths change between versions). A `playwright` server the user registered is kept. When the browser
|
|
@@ -315,7 +371,7 @@ def ensure(client, log=None):
|
|
|
315
371
|
return _record(client, "kept", "a playwright MCP server you registered is kept")
|
|
316
372
|
try:
|
|
317
373
|
command = server_command()
|
|
318
|
-
problem = probe(command)
|
|
374
|
+
problem = probe(command, handshake_path=HANDSHAKE_PATH)
|
|
319
375
|
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
320
376
|
problem = str(exc)
|
|
321
377
|
try:
|
|
@@ -325,7 +381,7 @@ def ensure(client, log=None):
|
|
|
325
381
|
if ours and client == "claude":
|
|
326
382
|
set_claude_deny(False)
|
|
327
383
|
return _record(client, "unavailable", problem[:300], log)
|
|
328
|
-
endpoint_sync.run_cli(register_command(client, command))
|
|
384
|
+
endpoint_sync.run_cli(register_command(client, lazy_command(command)))
|
|
329
385
|
if not registered().get(client):
|
|
330
386
|
return _record(client, "unavailable", "the client CLI finished, but the server did not appear in its "
|
|
331
387
|
"configuration", log)
|
|
@@ -342,5 +398,5 @@ def ensure(client, log=None):
|
|
|
342
398
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
343
399
|
# Our registration still in place after a failure (its removal failed) stays ours, flagged, never "kept".
|
|
344
400
|
state = "undenied" if ours and registered().get(client) else "unavailable"
|
|
345
|
-
return _record(client, state, str(exc)
|
|
401
|
+
return _record(client, state, _clip(str(exc)), log)
|
|
346
402
|
return _record(client, "registered")
|
package/client_hooks.py
CHANGED
|
@@ -29,7 +29,7 @@ HOME = os.path.expanduser("~")
|
|
|
29
29
|
PREVIEW_TTL_S = 600
|
|
30
30
|
CLIENTS = {
|
|
31
31
|
"claude": {"label": "Claude Code", "names": {"claude-code"}, "files": [os.path.join(endpoint_sync.claude_dir(), "settings.json")],
|
|
32
|
-
"docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|PowerShell|WebSearch|WebFetch|Edit|MultiEdit|Write|mcp__smart-tool__smart_search", "http": True,
|
|
32
|
+
"docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|PowerShell|WebSearch|WebFetch|Edit|MultiEdit|Write|mcp__smart-tool__smart_search|mcp__smart-tool__web_search|mcp__smart-tool__web_fetch", "http": True,
|
|
33
33
|
"post_matcher": "Grep|Bash|PowerShell|WebFetch|WebSearch"},
|
|
34
34
|
"codex": {"label": "Codex", "prefix": "codex", "files": [os.path.join(endpoint_sync.codex_dir(), "hooks.json"), os.path.join(endpoint_sync.codex_dir(), "config.toml")],
|
|
35
35
|
"docs": "https://learn.chatgpt.com/docs/hooks", "event": "PreToolUse", "matcher": "Bash|apply_patch",
|
package/document_read.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""read_document: PDF, Word, PowerPoint and Excel files read as compact text, one page, slide or sheet range at a time.
|
|
2
|
+
|
|
3
|
+
The agent gets the file facts first (type, size, pages, slides or sheets, estimated tokens) and then only the range it
|
|
4
|
+
asks for, so a 200-page PDF is never loaded whole. Extraction is the one of document_text (pypdf in an isolated
|
|
5
|
+
subprocess for PDF; zipfile and ElementTree with the shared guards for docx, pptx and xlsx); this module adds the unit
|
|
6
|
+
split, the markdown shape, the compaction of PDF line wrapping and the range arithmetic."""
|
|
7
|
+
import collections
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import unicodedata
|
|
13
|
+
|
|
14
|
+
import document_text
|
|
15
|
+
|
|
16
|
+
KINDS = {'.pdf': 'pdf', '.docx': 'docx', '.pptx': 'pptx', '.xlsx': 'xlsx'}
|
|
17
|
+
UNIT_NAMES = {'pdf': 'page', 'docx': 'part', 'pptx': 'slide', 'xlsx': 'sheet'}
|
|
18
|
+
DEFAULT_MAX_CHARS = 20_000
|
|
19
|
+
MAX_CHARS_LIMIT = 100_000
|
|
20
|
+
DOCX_PART_CHARS = 6_000
|
|
21
|
+
CHARS_PER_TOKEN = 4
|
|
22
|
+
CACHE_DOCUMENTS = 4
|
|
23
|
+
LONG_LINE = 60
|
|
24
|
+
|
|
25
|
+
_cache = collections.OrderedDict()
|
|
26
|
+
_cache_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Unit:
|
|
30
|
+
"""One readable unit of a document: a PDF page, a docx part, a slide or a sheet."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, number, text, name=''):
|
|
33
|
+
self.number = number
|
|
34
|
+
self.text = text
|
|
35
|
+
self.name = name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Document:
|
|
39
|
+
"""A document split into units, with the facts the metadata block reports."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path, kind, size, units, facts):
|
|
42
|
+
self.path = path
|
|
43
|
+
self.kind = kind
|
|
44
|
+
self.size = size
|
|
45
|
+
self.units = units
|
|
46
|
+
self.facts = facts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_tokens(text):
|
|
50
|
+
"""Token estimate of a text: characters divided by four, rounded up (a rule of thumb, not a tokenizer count).
|
|
51
|
+
|
|
52
|
+
Parameters: text. Returns: the estimate as an int."""
|
|
53
|
+
return math.ceil(len(text) / CHARS_PER_TOKEN)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compact_pdf_text(text):
|
|
57
|
+
"""PDF page text with the layout noise taken out: ligatures and compatibility forms normalised, a hyphen at a line
|
|
58
|
+
end joined to the word, a wrapped line (long, no closing punctuation, next one starting in lower case) joined to the
|
|
59
|
+
next, spaces collapsed and blank-line runs reduced to one.
|
|
60
|
+
|
|
61
|
+
Parameters: text (one page as pypdf returns it). Returns: the compact text."""
|
|
62
|
+
text = unicodedata.normalize('NFKC', text).replace('\r\n', '\n').replace('\r', '\n')
|
|
63
|
+
text = re.sub(r'(\w)-\n(?=[a-zà-ÿ])', r'\1', text)
|
|
64
|
+
lines = [re.sub(r'[ \t]+', ' ', line).strip() for line in text.split('\n')]
|
|
65
|
+
joined = []
|
|
66
|
+
for line in lines:
|
|
67
|
+
previous = joined[-1] if joined else ''
|
|
68
|
+
if (previous and len(previous) >= LONG_LINE and line[:1].islower()
|
|
69
|
+
and not previous.endswith(('.', ':', ';', '!', '?', ')', '|'))):
|
|
70
|
+
joined[-1] = previous + ' ' + line
|
|
71
|
+
else:
|
|
72
|
+
joined.append(line)
|
|
73
|
+
return re.sub(r'\n{3,}', '\n\n', '\n'.join(joined)).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pdf_units(data):
|
|
77
|
+
"""Units and facts of a PDF, one unit per page; raises DocumentError for a password, a corrupt file or a file with no
|
|
78
|
+
text layer at all (scanned pages are not read, there is no OCR)."""
|
|
79
|
+
pages, count = document_text.pdf_pages(data, document_text.PDF_TIMEOUT_S, document_text.PDF_MAX_PAGES,
|
|
80
|
+
document_text.PDF_MAX_CHARS)
|
|
81
|
+
if not any(pages):
|
|
82
|
+
raise document_text.DocumentError(
|
|
83
|
+
f'PDF of {count} pages has no text layer (scanned pages are not read; there is no OCR).')
|
|
84
|
+
units = [Unit(number, compact_pdf_text(page) or '(no text on this page)') for number, page in enumerate(pages, 1)]
|
|
85
|
+
facts = {'pages': count, 'pages without text': sum(1 for page in pages if not page)}
|
|
86
|
+
if count > len(pages):
|
|
87
|
+
facts['not read'] = f'pages {len(pages) + 1}-{count} (over the {document_text.PDF_MAX_PAGES} page or ' \
|
|
88
|
+
f'{document_text.PDF_MAX_CHARS:,} character cap)'
|
|
89
|
+
return units, facts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _docx_units(data):
|
|
93
|
+
"""Units and facts of a docx: the markdown blocks grouped in parts of about DOCX_PART_CHARS characters, a block never
|
|
94
|
+
split (a docx has no fixed pages, they depend on the renderer)."""
|
|
95
|
+
parts, current, size = [], [], 0
|
|
96
|
+
for block in document_text.docx_blocks(data):
|
|
97
|
+
if current and size + len(block) > DOCX_PART_CHARS:
|
|
98
|
+
parts.append('\n'.join(current))
|
|
99
|
+
current, size = [], 0
|
|
100
|
+
current.append(block)
|
|
101
|
+
size += len(block) + 1
|
|
102
|
+
parts.append('\n'.join(current))
|
|
103
|
+
return [Unit(number, text) for number, text in enumerate(parts, 1)], {
|
|
104
|
+
'parts': len(parts), 'note': f'a docx has no fixed pages; a part is about {DOCX_PART_CHARS:,} characters'}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pptx_units(data):
|
|
108
|
+
"""Units and facts of a pptx: one unit per slide under `[slide N]`, the speaker notes under `[notes N]`."""
|
|
109
|
+
units = []
|
|
110
|
+
for number, body, notes in document_text.pptx_slides(data):
|
|
111
|
+
lines = [f'[slide {number}]'] + (body or ['(no text)']) + ([f'[notes {number}]'] + notes if notes else [])
|
|
112
|
+
units.append(Unit(number, '\n'.join(lines)))
|
|
113
|
+
return units, {'slides': len(units), 'slides without text': sum(1 for u in units if '\n(no text)' in u.text)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _xlsx_units(data):
|
|
117
|
+
"""Units and facts of an xlsx: one unit per sheet under `[sheet NAME]`, then one tab-separated line per row."""
|
|
118
|
+
units = []
|
|
119
|
+
for number, (name, rows) in enumerate(document_text.xlsx_sheets(data), 1):
|
|
120
|
+
units.append(Unit(number, '\n'.join([f'[sheet {name}]'] + (rows or ['(no text)'])), name))
|
|
121
|
+
return units, {'sheet names': ', '.join(f'{u.number}={u.name}' for u in units)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_BUILDERS = {'pdf': _pdf_units, 'docx': _docx_units, 'pptx': _pptx_units, 'xlsx': _xlsx_units}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load(path):
|
|
128
|
+
"""The document at `path`, split into units. Parsed documents are kept for a few calls (keyed by path, size and
|
|
129
|
+
modification time), so reading page after page parses the file once.
|
|
130
|
+
|
|
131
|
+
Parameters: path (absolute path of a .pdf, .docx, .pptx or .xlsx file). Returns: a Document. Raises: ValueError for a
|
|
132
|
+
relative path, a missing file, an unsupported type or a file over the size limit of its type; DocumentError with a
|
|
133
|
+
readable reason for a file the extractor refuses (corrupt, encrypted, hostile, scanned, without text)."""
|
|
134
|
+
if not isinstance(path, str) or not path.strip():
|
|
135
|
+
raise ValueError("read_document requires 'path'")
|
|
136
|
+
if not os.path.isabs(path):
|
|
137
|
+
raise ValueError(f'read_document needs an absolute path, got {path!r}')
|
|
138
|
+
extension = os.path.splitext(path)[1].lower()
|
|
139
|
+
if extension not in KINDS:
|
|
140
|
+
raise ValueError(f"read_document reads {', '.join(KINDS)} files; {extension or 'this file'} is not supported "
|
|
141
|
+
'(use the normal Read for plain text; legacy .doc, .xls and .ppt and macro files are not read)')
|
|
142
|
+
try:
|
|
143
|
+
info = os.stat(path)
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
raise ValueError(f'cannot read {path}: {exc.strerror or type(exc).__name__}') from None
|
|
146
|
+
if not os.path.isfile(path):
|
|
147
|
+
raise ValueError(f'{path} is not a file')
|
|
148
|
+
if info.st_size > document_text.size_limit(path):
|
|
149
|
+
raise ValueError(f'{path} is {info.st_size / 1048576:.1f} MiB, over the '
|
|
150
|
+
f'{document_text.size_limit(path) // 1048576} MiB limit of a {extension} file')
|
|
151
|
+
key = (os.path.normcase(path), info.st_size, info.st_mtime_ns)
|
|
152
|
+
with _cache_lock:
|
|
153
|
+
if key in _cache:
|
|
154
|
+
_cache.move_to_end(key)
|
|
155
|
+
return _cache[key]
|
|
156
|
+
with open(path, 'rb') as stream:
|
|
157
|
+
data = stream.read()
|
|
158
|
+
kind = KINDS[extension]
|
|
159
|
+
units, facts = _BUILDERS[kind](data)
|
|
160
|
+
document = Document(path, kind, info.st_size, units, facts)
|
|
161
|
+
with _cache_lock:
|
|
162
|
+
_cache[key] = document
|
|
163
|
+
while len(_cache) > CACHE_DOCUMENTS:
|
|
164
|
+
_cache.popitem(last=False)
|
|
165
|
+
return document
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_range(spec, document):
|
|
169
|
+
"""Unit numbers selected by a range text: `3`, `3-5`, `7-` (to the end), `-4` (the first four), a comma list of
|
|
170
|
+
those (`1,4-6`) and, for a workbook, a sheet name (case-insensitive).
|
|
171
|
+
|
|
172
|
+
Parameters: spec (the text), document (a Document). Returns: the sorted list of unit numbers. Raises: ValueError
|
|
173
|
+
naming the valid range for a malformed or out-of-range spec or an unknown sheet name."""
|
|
174
|
+
count = len(document.units)
|
|
175
|
+
unit = UNIT_NAMES[document.kind]
|
|
176
|
+
chosen = set()
|
|
177
|
+
for part in (piece.strip() for piece in spec.split(',')):
|
|
178
|
+
if document.kind == 'xlsx':
|
|
179
|
+
named = [u.number for u in document.units if u.name.lower() == part.lower()]
|
|
180
|
+
if named:
|
|
181
|
+
chosen.update(named)
|
|
182
|
+
continue
|
|
183
|
+
found = re.fullmatch(r'(\d*)\s*(-?)\s*(\d*)', part)
|
|
184
|
+
if not part or not found or not (found.group(1) or found.group(3)):
|
|
185
|
+
raise ValueError(f'range {spec!r} is not valid: use a {unit} number, a span like 3-5, 7- or -4, or a comma list'
|
|
186
|
+
+ ('; a sheet name also works' if document.kind == 'xlsx' else ''))
|
|
187
|
+
first, dash, last = found.groups()
|
|
188
|
+
if not dash and last:
|
|
189
|
+
raise ValueError(f'range {spec!r} is not valid: use a {unit} number, a span like 3-5, 7- or -4, or a comma list')
|
|
190
|
+
low = int(first) if first else 1
|
|
191
|
+
high = int(last) if last else (count if dash else low)
|
|
192
|
+
if low < 1 or high > count or low > high:
|
|
193
|
+
raise ValueError(f'range {spec!r} is outside the document: it has {count} {unit}s (1-{count})')
|
|
194
|
+
chosen.update(range(low, high + 1))
|
|
195
|
+
return sorted(chosen)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _spans(numbers):
|
|
199
|
+
"""Compact text of unit numbers: `1-3, 7`."""
|
|
200
|
+
spans, start, previous = [], None, None
|
|
201
|
+
for number in numbers + [None]:
|
|
202
|
+
if start is None:
|
|
203
|
+
start = previous = number
|
|
204
|
+
elif number is not None and number == previous + 1:
|
|
205
|
+
previous = number
|
|
206
|
+
else:
|
|
207
|
+
spans.append(str(start) if start == previous else f'{start}-{previous}')
|
|
208
|
+
start = previous = number
|
|
209
|
+
return ', '.join(spans)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def header(document):
|
|
213
|
+
"""The metadata block that opens every answer: path, type, size, units and the estimated tokens of the whole text.
|
|
214
|
+
|
|
215
|
+
Parameters: document. Returns: the block as text."""
|
|
216
|
+
total = sum(estimate_tokens(unit.text) for unit in document.units)
|
|
217
|
+
lines = [f'document: {document.path}',
|
|
218
|
+
f'type: {document.kind} | size: {document.size / 1048576:.2f} MiB | '
|
|
219
|
+
f'{UNIT_NAMES[document.kind]}s: {len(document.units)} | est. tokens (all text): {total:,}']
|
|
220
|
+
lines += [f'{key}: {value}' for key, value in document.facts.items()
|
|
221
|
+
if value and key != UNIT_NAMES[document.kind] + 's']
|
|
222
|
+
return '\n'.join(lines)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def read(path, range_spec=None, max_chars=DEFAULT_MAX_CHARS, info_only=False):
|
|
226
|
+
"""The answer of read_document: the metadata block, then the chosen units as markdown, a note naming the next range
|
|
227
|
+
when the text was cut.
|
|
228
|
+
|
|
229
|
+
Parameters: path (see load), range_spec (see parse_range; None reads from the first unit up to max_chars),
|
|
230
|
+
max_chars (cap of the text returned, 1000-MAX_CHARS_LIMIT; a single unit over the cap is cut at a line with a
|
|
231
|
+
notice), info_only (only the metadata block). Returns: the text. Raises: as load and parse_range, ValueError for a
|
|
232
|
+
max_chars out of bounds."""
|
|
233
|
+
if isinstance(max_chars, bool) or not isinstance(max_chars, int) or not 1000 <= max_chars <= MAX_CHARS_LIMIT:
|
|
234
|
+
raise ValueError(f'max_chars must be an integer from 1000 to {MAX_CHARS_LIMIT}')
|
|
235
|
+
document = load(path)
|
|
236
|
+
head = header(document)
|
|
237
|
+
if info_only:
|
|
238
|
+
return head
|
|
239
|
+
numbers = parse_range(range_spec, document) if range_spec else list(range(1, len(document.units) + 1))
|
|
240
|
+
unit = UNIT_NAMES[document.kind]
|
|
241
|
+
body, shown, size, cut = [], [], 0, None
|
|
242
|
+
for number in numbers:
|
|
243
|
+
text = document.units[number - 1].text
|
|
244
|
+
label = f'[{unit} {number}]\n' if document.kind in ('pdf', 'docx') else ''
|
|
245
|
+
piece = label + text
|
|
246
|
+
if shown and size + len(piece) > max_chars:
|
|
247
|
+
break
|
|
248
|
+
if len(piece) > max_chars:
|
|
249
|
+
lines = piece.split('\n')
|
|
250
|
+
kept, used = [], 0
|
|
251
|
+
for line in lines:
|
|
252
|
+
if used + len(line) + 1 > max_chars and kept:
|
|
253
|
+
break
|
|
254
|
+
kept.append(line)
|
|
255
|
+
used += len(line) + 1
|
|
256
|
+
piece = '\n'.join(kept)
|
|
257
|
+
cut = (f'{unit} {number} cut after {len(kept)} of {len(lines)} lines; raise max_chars '
|
|
258
|
+
f'(up to {MAX_CHARS_LIMIT:,}) to read the rest')
|
|
259
|
+
body.append(piece)
|
|
260
|
+
shown.append(number)
|
|
261
|
+
size += len(piece) + 1
|
|
262
|
+
if cut:
|
|
263
|
+
break
|
|
264
|
+
returned = '\n\n'.join(body)
|
|
265
|
+
notes = [f'returned: {unit}s {_spans(shown)} (est. {estimate_tokens(returned):,} tokens)']
|
|
266
|
+
remaining = [number for number in numbers if number > shown[-1]]
|
|
267
|
+
if cut:
|
|
268
|
+
notes.append(cut)
|
|
269
|
+
if remaining:
|
|
270
|
+
notes.append(f'next: range="{remaining[0]}-{remaining[-1]}"' if not range_spec
|
|
271
|
+
else f'not returned: {unit}s {_spans(remaining)} (max_chars); ask for them with range')
|
|
272
|
+
return '\n'.join([head] + notes + ['---', returned])
|