@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 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.43 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
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
@@ -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
- "clientInfo": {"name": "smart-tool-probe", "version": "1"}})
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 registered_command(client):
162
- """Executable the client's `playwright` server is registered with, or None when it is not registered."""
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 None
197
+ return []
172
198
  command = entry.get("command") if isinstance(entry, dict) else None
173
- return command if isinstance(command, str) else None
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
- command = registered_command(client)
231
+ executables = registered_executables(client)
183
232
  ours = status().get(client, {}).get("state") in ("registered", "undenied")
184
- if ours and command and not node_locator.exists(command):
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)[:300], log)
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",
@@ -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])