@allansantos-dev/smart-tool 0.9.41 → 0.9.42
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 +19 -0
- package/README.md +9 -5
- package/client_hooks.py +94 -31
- package/document_text.py +274 -47
- package/hooks/session_audit.py +1 -1
- package/index_profile.py +1 -1
- package/package.json +1 -1
- package/scout.py +1 -1
- package/setup_ui.py +23 -15
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.42 - beta
|
|
4
|
+
|
|
5
|
+
- Presentations and workbooks of the project are searchable. `.pptx` text comes out per slide under `[slide N]`, with the
|
|
6
|
+
speaker notes under `[notes N]`; `.xlsx` per sheet under `[sheet NAME]`, one tab-separated line per row, cached formula
|
|
7
|
+
values (nothing is calculated), shared and inline strings resolved. They go through the same zip and XML guards as
|
|
8
|
+
`.docx` and `.pdf` (10 MiB, 2000 entries, no encryption, no DOCTYPE or ENTITY, 8 MiB of XML, 1 MiB of text), plus 100,000
|
|
9
|
+
rows and 16,384 columns for a workbook; a corrupt, hostile or oversized file is skipped with the reason and the rest of the
|
|
10
|
+
project is indexed. `document_text.VERSION` is 3: `.docx` and `.pdf` already indexed are read once more. Measurement
|
|
11
|
+
before and after in `docs/research/data/office-probe-*.json`; tests in `tests/test_office_index.py`. A CSV of 1 MiB is
|
|
12
|
+
indexed and searched in 4 ms; one of 20 MiB is refused as `over size limit` in the index stats, without stalling.
|
|
13
|
+
- The session-start audit report works in Codex. The Setup audit page turns on the SessionStart hook for Claude Code or
|
|
14
|
+
Codex (preview, confirm, backup, remove only ours); Codex runs a new hook only after you approve it in `/hooks`, so the page
|
|
15
|
+
shows "installed, waiting for your approval in /hooks" until the approval is found in `config.toml`, and the first-search
|
|
16
|
+
audit line keeps showing until then. Research with the primary sources in
|
|
17
|
+
`docs/research/2026-10-27-codex-sessionstart-hook.md`; tests in `tests/test_codex_audit_hook.py`.
|
|
18
|
+
- Review fixes: a workbook cell reference past column XFD, a negative shared-string index, a corrupt deflate stream and
|
|
19
|
+
a newline in a sheet name no longer escape as `MemoryError`, `zlib.error`, a wrong text or a forged `[sheet ...]` line;
|
|
20
|
+
the tests of the Codex hook ignore `CODEX_HOME` and `CLAUDE_CONFIG_DIR` so they never write outside their temporary home.
|
|
21
|
+
|
|
3
22
|
## 0.9.41 - beta
|
|
4
23
|
|
|
5
24
|
- Far fewer calls to the embedding and rerank models. Measured on a real machine (24 h): 8303 embedding and 1473 rerank
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
18
|
|
|
19
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.42 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
|
@@ -191,7 +191,10 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
191
191
|
|
|
192
192
|
**Dependency check on edit**: an edit of `package.json`, `requirements*.txt` or `go.mod` that adds or changes a dependency lists the OSV advisory IDs of up to 5 of them (exact version, or the lower bound of `^`, `~`, `~=` and `>=`), answered from a 6 h disk cache or one 2.5 s batch call that sends only package names and versions; when OSV cannot answer, the reminder says the check was skipped and the edit goes on. `dependency_mode` in `config.json`: `warn` (default) or `off`.
|
|
193
193
|
|
|
194
|
-
**PDFs** inside a project are indexed as text with `[page N]` markers (20 MiB, 300 pages and 20 s per file; a scanned, password-protected or corrupt PDF is skipped with the reason), so `smart_search` returns the page that holds a passage.
|
|
194
|
+
**PDFs** inside a project are indexed as text with `[page N]` markers (20 MiB, 300 pages and 20 s per file; a scanned, password-protected or corrupt PDF is skipped with the reason), so `smart_search` returns the page that holds a passage. **Presentations and workbooks** (`.pptx`, `.xlsx`) are indexed the
|
|
195
|
+
same way: text of each slide under `[slide N]` with the speaker notes under `[notes N]`, each sheet under `[sheet NAME]` with one
|
|
196
|
+
tab-separated line per row (cached formula values; 100,000 rows or 1 MiB of text at most). Older formats (`.ppt`, `.xls`,
|
|
197
|
+
`.xlsm`) are not read.
|
|
195
198
|
|
|
196
199
|
**Block patterns waiting for you** are shown in one approval panel: 8 per page, the ones that would have blocked
|
|
197
200
|
the most logged calls first, a filter over pattern, reason and example, and bulk accept or reject of the selected
|
|
@@ -232,9 +235,10 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
232
235
|
folder (or the nearest audited parent) without auditing again, and fails with what to do when none is stored. On
|
|
233
236
|
its first `smart_search` of a session, and once per folder, the agent also gets one short line (under 100 tokens)
|
|
234
237
|
when the stored report holds errors or warnings; a clean report adds nothing. Optionally, **Report at session
|
|
235
|
-
start
|
|
236
|
-
exact SessionStart entry, adds it to `~/.claude/settings.json` only after you
|
|
237
|
-
off** removes only that entry.
|
|
238
|
+
start** on the Setup audit page (Claude Code or Codex) moves that line to the start of every new session: it
|
|
239
|
+
previews the exact SessionStart entry, adds it to `~/.claude/settings.json` or `~/.codex/hooks.json` only after you
|
|
240
|
+
confirm (backup first), and **Turn off** removes only that entry. Codex runs a new hook only after you approve it in
|
|
241
|
+
`/hooks`; until then the page shows "waiting for your approval" and the search keeps repeating the line. It is off by default; while it is on, the search no longer repeats the line. The
|
|
238
242
|
hook script (`hooks/session_audit.py`) only reads the stored report from the running service and prints nothing
|
|
239
243
|
when the service is down.
|
|
240
244
|
4. **Local models** (optional): if the mcp-memory embedding sidecar is installed on the machine, its local embedder
|
package/client_hooks.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import secrets
|
|
4
5
|
import shutil
|
|
5
6
|
import sys
|
|
6
7
|
import time
|
|
8
|
+
import tomllib
|
|
7
9
|
|
|
8
10
|
import daemon_launcher
|
|
9
11
|
import endpoint_sync
|
|
@@ -14,6 +16,9 @@ AUDIT_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hooks",
|
|
|
14
16
|
AUDIT_MARKER = "session_audit.py"
|
|
15
17
|
AUDIT_MATCHER = "startup|resume|clear"
|
|
16
18
|
AUDIT_TIMEOUT_S = 10
|
|
19
|
+
AUDIT_CLIENTS = ("claude", "codex")
|
|
20
|
+
AUDIT_ON = "current"
|
|
21
|
+
AUDIT_WAITING = "waiting_approval"
|
|
17
22
|
HTTP_PATH = "/hooks/pretool"
|
|
18
23
|
POST_PATH = "/hooks/posttool"
|
|
19
24
|
# A redirected shell search waits for its answer in the PreToolUse (smart_tool_daemon.HOOK_SHELL_BUDGET_S).
|
|
@@ -282,9 +287,16 @@ def _audit_entry():
|
|
|
282
287
|
"timeout": AUDIT_TIMEOUT_S}]}
|
|
283
288
|
|
|
284
289
|
|
|
285
|
-
def
|
|
286
|
-
"""
|
|
287
|
-
|
|
290
|
+
def _audit_client(client):
|
|
291
|
+
"""The client's config spec; only Claude Code and Codex fire a SessionStart hook whose output the model reads."""
|
|
292
|
+
if client not in AUDIT_CLIENTS:
|
|
293
|
+
raise ValueError(f"The session-start audit report is only available for Claude Code or Codex, not {client!r}.")
|
|
294
|
+
return CLIENTS[client]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _audit_plan(client="claude"):
|
|
298
|
+
"""(path, data, groups) of the client's hooks file with our SessionStart entry already removed from groups."""
|
|
299
|
+
path = os.path.realpath(_audit_client(client)["files"][0])
|
|
288
300
|
try:
|
|
289
301
|
with open(path, encoding="utf-8") as stream:
|
|
290
302
|
data = json.load(stream)
|
|
@@ -299,10 +311,52 @@ def _audit_plan():
|
|
|
299
311
|
return path, data, groups
|
|
300
312
|
|
|
301
313
|
|
|
302
|
-
def
|
|
303
|
-
"""
|
|
304
|
-
|
|
305
|
-
|
|
314
|
+
def _codex_hash(event, group, index=0):
|
|
315
|
+
"""Codex's approval hash (`sha256:` of the key-sorted compact JSON of the normalized hook identity, as in
|
|
316
|
+
openai/codex hooks/src/engine/discovery.rs `hook_hash`) of the handler at `index` in a hooks.json group."""
|
|
317
|
+
handler = group["hooks"][index]
|
|
318
|
+
normalized = {"type": handler["type"], "command": handler["command"], "timeout": handler.get("timeout", 600),
|
|
319
|
+
"async": bool(handler.get("async", False))}
|
|
320
|
+
if handler.get("statusMessage") is not None:
|
|
321
|
+
normalized["statusMessage"] = handler["statusMessage"]
|
|
322
|
+
identity = {"event_name": event, "hooks": [normalized]}
|
|
323
|
+
if group.get("matcher") is not None:
|
|
324
|
+
identity["matcher"] = group["matcher"]
|
|
325
|
+
text = json.dumps(identity, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
326
|
+
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _codex_approved(groups):
|
|
330
|
+
"""Our SessionStart handler is trusted in Codex's config.toml: the entry keyed by hooks.json path, event, group
|
|
331
|
+
and handler index holds the hash of the definition as it is now (a missing or different hash means /hooks has not
|
|
332
|
+
approved it, so Codex does not run it). An unreadable config.toml raises ValueError."""
|
|
333
|
+
spec = CLIENTS["codex"]
|
|
334
|
+
try:
|
|
335
|
+
with open(spec["files"][1], encoding="utf-8") as stream:
|
|
336
|
+
hooks = tomllib.loads(stream.read()).get("hooks")
|
|
337
|
+
states = hooks.get("state") if isinstance(hooks, dict) else None
|
|
338
|
+
states = states if isinstance(states, dict) else {}
|
|
339
|
+
except FileNotFoundError:
|
|
340
|
+
return False
|
|
341
|
+
except tomllib.TOMLDecodeError as exc:
|
|
342
|
+
raise ValueError(f"{spec['files'][1]} is not valid TOML: {exc}")
|
|
343
|
+
paths = {spec["files"][0], os.path.realpath(spec["files"][0])}
|
|
344
|
+
for group_index, group in enumerate(groups):
|
|
345
|
+
if not _ours(group, _audit_hook):
|
|
346
|
+
continue
|
|
347
|
+
handler_index = next(i for i, hook in enumerate(group["hooks"]) if _audit_hook(hook))
|
|
348
|
+
for path in paths:
|
|
349
|
+
trusted = (states.get(f"{path}:session_start:{group_index}:{handler_index}") or {}).get("trusted_hash")
|
|
350
|
+
if trusted == _codex_hash("session_start", group, handler_index):
|
|
351
|
+
return True
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def audit_state(client="claude"):
|
|
356
|
+
"""The SessionStart audit hook in the client's hooks file: `current` (our entry equals this version's and, in
|
|
357
|
+
Codex, was approved in /hooks), `waiting_approval` (Codex only: written but not approved, so it does not run yet),
|
|
358
|
+
`outdated` (an entry of ours that differs) or `missing`."""
|
|
359
|
+
path = _audit_client(client)["files"][0]
|
|
306
360
|
try:
|
|
307
361
|
with open(path, encoding="utf-8") as stream:
|
|
308
362
|
data = json.load(stream)
|
|
@@ -312,52 +366,61 @@ def audit_state():
|
|
|
312
366
|
found = [group for group in groups if _ours(group, _audit_hook)] if isinstance(groups, list) else []
|
|
313
367
|
if not found:
|
|
314
368
|
return "missing"
|
|
315
|
-
|
|
369
|
+
if _audit_entry() not in found:
|
|
370
|
+
return "outdated"
|
|
371
|
+
return "current" if client != "codex" or _codex_approved(groups) else AUDIT_WAITING
|
|
316
372
|
|
|
317
373
|
|
|
318
374
|
def audit_hook_installed(client):
|
|
319
|
-
"""The
|
|
320
|
-
counts as off here; `audit_state` raises for it on the setup screen."""
|
|
321
|
-
if client
|
|
375
|
+
"""The session-start audit report is on and running (Codex: approved in /hooks), so a search need not repeat it.
|
|
376
|
+
An unreadable config file counts as off here; `audit_state` raises for it on the setup screen."""
|
|
377
|
+
if client not in AUDIT_CLIENTS:
|
|
322
378
|
return False
|
|
323
379
|
try:
|
|
324
|
-
return audit_state() == "current"
|
|
380
|
+
return audit_state(client) == "current"
|
|
325
381
|
except (OSError, ValueError):
|
|
326
382
|
return False
|
|
327
383
|
|
|
328
384
|
|
|
329
|
-
def
|
|
330
|
-
"""
|
|
331
|
-
|
|
385
|
+
def _audit_after(client):
|
|
386
|
+
"""{after_install: ...} for a client whose new hooks need the user's approval, else nothing."""
|
|
387
|
+
note = CLIENTS[client].get("after_install")
|
|
388
|
+
return {"after_install": note} if note else {}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def audit_preview(client="claude"):
|
|
392
|
+
"""The change that turns the SessionStart audit report on in the client, with a token for `audit_install`."""
|
|
393
|
+
path, _data, _groups = _audit_plan(client)
|
|
332
394
|
entry = _audit_entry()
|
|
333
|
-
if audit_state()
|
|
395
|
+
if audit_state(client) in (AUDIT_ON, AUDIT_WAITING):
|
|
334
396
|
return {"installed": True, "file": path, "change": None}
|
|
335
397
|
token = secrets.token_urlsafe(12)
|
|
336
|
-
_PREVIEWS[token] = {"client": "audit", "at": time.time()}
|
|
398
|
+
_PREVIEWS[token] = {"client": f"audit:{client}", "at": time.time()}
|
|
337
399
|
return {"installed": False, "file": path, "event": "SessionStart", "change": entry, "token": token,
|
|
338
|
-
"expires_s": PREVIEW_TTL_S}
|
|
400
|
+
"expires_s": PREVIEW_TTL_S, **_audit_after(client)}
|
|
339
401
|
|
|
340
402
|
|
|
341
|
-
def audit_install(token, confirm):
|
|
342
|
-
"""Writes the SessionStart audit entry into
|
|
343
|
-
(10 min) and confirm=True. Returns {installed, changed, file, backup}
|
|
403
|
+
def audit_install(token, confirm, client="claude"):
|
|
404
|
+
"""Writes the SessionStart audit entry into the client's hooks file with a backup; needs the `audit_preview` token
|
|
405
|
+
(10 min) and confirm=True. Returns {installed, changed, file, backup}, plus `after_install` in Codex: the hook
|
|
406
|
+
runs only after the user approves it in /hooks."""
|
|
344
407
|
planned = _PREVIEWS.pop(token or "", None)
|
|
345
|
-
if confirm is not True or not planned or planned["client"] != "audit" or time.time() - planned["at"] > PREVIEW_TTL_S:
|
|
408
|
+
if confirm is not True or not planned or planned["client"] != f"audit:{client}" or time.time() - planned["at"] > PREVIEW_TTL_S:
|
|
346
409
|
raise ValueError("Turning the audit report on requires the preview token (valid for 10 min) and confirm=true.")
|
|
347
|
-
path, data, groups = _audit_plan()
|
|
348
|
-
if audit_state()
|
|
410
|
+
path, data, groups = _audit_plan(client)
|
|
411
|
+
if audit_state(client) in (AUDIT_ON, AUDIT_WAITING):
|
|
349
412
|
return {"installed": True, "changed": False, "file": path}
|
|
350
413
|
groups.append(_audit_entry())
|
|
351
|
-
backup = _write_checked(path, data, lambda: audit_state()
|
|
352
|
-
return {"installed": True, "changed": True, "file": path, "backup": backup}
|
|
414
|
+
backup = _write_checked(path, data, lambda: audit_state(client) in (AUDIT_ON, AUDIT_WAITING), "The audit hook")
|
|
415
|
+
return {"installed": True, "changed": True, "file": path, "backup": backup, **_audit_after(client)}
|
|
353
416
|
|
|
354
417
|
|
|
355
|
-
def audit_remove():
|
|
356
|
-
"""Takes only our SessionStart entry out of
|
|
357
|
-
path, data, groups = _audit_plan()
|
|
358
|
-
if audit_state() == "missing":
|
|
418
|
+
def audit_remove(client="claude"):
|
|
419
|
+
"""Takes only our SessionStart entry out of the client's hooks file (after a backup); nothing else is touched."""
|
|
420
|
+
path, data, groups = _audit_plan(client)
|
|
421
|
+
if audit_state(client) == "missing":
|
|
359
422
|
return {"installed": False, "changed": False, "file": path}
|
|
360
423
|
if not groups:
|
|
361
424
|
del data["hooks"]["SessionStart"]
|
|
362
|
-
backup = _write_checked(path, data, lambda: audit_state() == "missing", "The removal")
|
|
425
|
+
backup = _write_checked(path, data, lambda: audit_state(client) == "missing", "The removal")
|
|
363
426
|
return {"installed": False, "changed": True, "file": path, "backup": backup}
|
package/document_text.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
"""Extração local de texto DOCX e PDF, sem Office, execução de macros ou acesso externo."""
|
|
1
|
+
"""Extração local de texto DOCX, PPTX, XLSX e PDF, sem Office, execução de macros ou acesso externo."""
|
|
2
|
+
import contextlib
|
|
2
3
|
import io
|
|
3
4
|
import json
|
|
5
|
+
import posixpath
|
|
4
6
|
import re
|
|
5
7
|
import subprocess
|
|
6
8
|
import sys
|
|
7
9
|
import zipfile
|
|
8
10
|
import xml.etree.ElementTree as ET
|
|
11
|
+
import zlib
|
|
9
12
|
|
|
10
|
-
VERSION =
|
|
13
|
+
VERSION = 3
|
|
11
14
|
PDF_MAX_BYTES = 20 * 1024 * 1024
|
|
12
15
|
PDF_TIMEOUT_S = 20
|
|
13
16
|
PDF_MAX_PAGES = 300
|
|
@@ -15,6 +18,8 @@ PDF_MAX_CHARS = 1_000_000
|
|
|
15
18
|
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
16
19
|
MAX_XML_BYTES = 8 * 1024 * 1024
|
|
17
20
|
MAX_TEXT_BYTES = 1024 * 1024
|
|
21
|
+
XLSX_MAX_ROWS = 100_000
|
|
22
|
+
XLSX_MAX_COLUMNS = 16_384
|
|
18
23
|
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
|
19
24
|
|
|
20
25
|
|
|
@@ -37,53 +42,264 @@ class PdfUnreadable(DocumentError):
|
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
class _DocumentTreeBuilder(ET.TreeBuilder):
|
|
45
|
+
label = 'DOCX'
|
|
46
|
+
|
|
40
47
|
def doctype(self, name, pubid, system):
|
|
41
48
|
# O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
|
|
42
|
-
raise DocumentError('
|
|
49
|
+
raise DocumentError(f'{self.label} contains disallowed entities.')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Package:
|
|
53
|
+
"""An open OOXML zip: the parts it holds and a guarded parser that charges every part to one XML budget."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, archive, entries, label):
|
|
56
|
+
self.archive = archive
|
|
57
|
+
self.entries = entries
|
|
58
|
+
self.label = label
|
|
59
|
+
self.xml_bytes = 0
|
|
60
|
+
|
|
61
|
+
def parse(self, name):
|
|
62
|
+
info = self.entries[name]
|
|
63
|
+
if info.flag_bits & 1 or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
|
64
|
+
raise DocumentError(f'Encrypted {self.label} or unsupported compression.')
|
|
65
|
+
if info.file_size > MAX_XML_BYTES - self.xml_bytes:
|
|
66
|
+
raise DocumentError(f'{self.label} XML content over the limit.')
|
|
67
|
+
with self.archive.open(info) as stream:
|
|
68
|
+
raw = stream.read(MAX_XML_BYTES - self.xml_bytes + 1)
|
|
69
|
+
self.xml_bytes += len(raw)
|
|
70
|
+
if self.xml_bytes > MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
|
|
71
|
+
raise DocumentError(f'{self.label} contains excessive XML or disallowed entities.')
|
|
72
|
+
builder = _DocumentTreeBuilder()
|
|
73
|
+
builder.label = self.label
|
|
74
|
+
return ET.fromstring(raw, parser=ET.XMLParser(target=builder))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextlib.contextmanager
|
|
78
|
+
def _open_package(data, label, required):
|
|
79
|
+
"""The one place that opens an OOXML zip (docx, pptx, xlsx) with the guards: archive size, entry count, a required
|
|
80
|
+
part, no encryption, one XML budget, no DOCTYPE or ENTITY, and every low-level failure turned into a DocumentError.
|
|
81
|
+
|
|
82
|
+
Parameters: data (zip bytes), label (`DOCX`, `PPTX` or `XLSX`, used in the reasons), required (a part name, or a
|
|
83
|
+
predicate over the part names, that must exist). Yields: a _Package. Raises: DocumentError with a readable reason."""
|
|
84
|
+
if len(data) > MAX_ARCHIVE_BYTES:
|
|
85
|
+
raise DocumentError(f'{label} over the 10 MiB limit.')
|
|
86
|
+
try:
|
|
87
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
88
|
+
listing = archive.infolist()
|
|
89
|
+
entries = {info.filename: info for info in listing}
|
|
90
|
+
present = required(entries) if callable(required) else required in entries
|
|
91
|
+
if len(listing) > 2000 or not present:
|
|
92
|
+
raise DocumentError(f'Invalid {label} or excessive structure.')
|
|
93
|
+
yield _Package(archive, entries, label)
|
|
94
|
+
except (zipfile.BadZipFile, ET.ParseError, RuntimeError, NotImplementedError, OSError, zlib.error, EOFError) as exc:
|
|
95
|
+
raise DocumentError(f'Could not extract text from this {label}.') from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Lines(list):
|
|
99
|
+
"""Output lines of an extractor; `add` refuses to grow past MAX_TEXT_BYTES of UTF-8 text."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, label):
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.label = label
|
|
104
|
+
self.size = 0
|
|
105
|
+
|
|
106
|
+
def add(self, *lines):
|
|
107
|
+
for line in lines:
|
|
108
|
+
self.size += len(line.encode('utf-8')) + 1
|
|
109
|
+
if self.size > MAX_TEXT_BYTES:
|
|
110
|
+
raise DocumentError(f'Text extracted from {self.label} exceeds 1 MiB.')
|
|
111
|
+
self.append(line)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _local(tag):
|
|
115
|
+
return tag.rsplit('}', 1)[-1]
|
|
43
116
|
|
|
44
117
|
|
|
45
118
|
def extract_docx(data):
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
"""Text of a .docx: body, headers, footers, footnotes and endnotes, one line per non-empty paragraph.
|
|
120
|
+
|
|
121
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
122
|
+
corrupt, encrypted or oversized package, or one with a DOCTYPE or entity."""
|
|
123
|
+
with _open_package(data, 'DOCX', 'word/document.xml') as package:
|
|
124
|
+
names = ['word/document.xml'] + sorted(name for name in package.entries if re.fullmatch(
|
|
125
|
+
r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml', name))
|
|
126
|
+
paragraphs = _Lines('DOCX')
|
|
127
|
+
for name in names:
|
|
128
|
+
root = package.parse(name)
|
|
129
|
+
namespace = W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
|
|
130
|
+
for paragraph in root.iter(namespace + 'p'):
|
|
131
|
+
def parts(node):
|
|
132
|
+
pending = list(reversed(node))
|
|
133
|
+
while pending:
|
|
134
|
+
child = pending.pop()
|
|
135
|
+
if child.tag == namespace + 'p':
|
|
136
|
+
continue
|
|
137
|
+
if child.tag == namespace + 't':
|
|
138
|
+
yield child.text or ''
|
|
139
|
+
elif child.tag == namespace + 'tab':
|
|
140
|
+
yield '\t'
|
|
141
|
+
elif child.tag in (namespace + 'br', namespace + 'cr'):
|
|
142
|
+
yield '\n'
|
|
143
|
+
else:
|
|
144
|
+
pending.extend(reversed(child))
|
|
145
|
+
text = ''.join(parts(paragraph))
|
|
146
|
+
if text.strip():
|
|
147
|
+
paragraphs.add(text)
|
|
148
|
+
return ('\n'.join(paragraphs) + '\n' if paragraphs else '').encode('utf-8')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _drawing_paragraphs(root):
|
|
152
|
+
"""Non-empty paragraphs (`a:p`) of a slide or notes part, from the `a:t` runs; the slide-number field is not text."""
|
|
153
|
+
lines = []
|
|
154
|
+
for paragraph in root.iter():
|
|
155
|
+
if _local(paragraph.tag) != 'p':
|
|
156
|
+
continue
|
|
157
|
+
pending = list(reversed(paragraph))
|
|
158
|
+
pieces = []
|
|
159
|
+
while pending:
|
|
160
|
+
child = pending.pop()
|
|
161
|
+
kind = _local(child.tag)
|
|
162
|
+
if kind == 'fld' and child.get('type', '').startswith('slidenum'):
|
|
163
|
+
continue
|
|
164
|
+
if kind == 't':
|
|
165
|
+
pieces.append(child.text or '')
|
|
166
|
+
elif kind == 'br':
|
|
167
|
+
pieces.append('\n')
|
|
168
|
+
elif kind != 'p':
|
|
169
|
+
pending.extend(reversed(child))
|
|
170
|
+
text = ''.join(pieces)
|
|
171
|
+
if text.strip():
|
|
172
|
+
lines.append(text)
|
|
173
|
+
return lines
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def extract_pptx(data):
|
|
177
|
+
"""Text of a .pptx: each slide under `[slide N]`, then its speaker notes under `[notes N]`, in slide order.
|
|
178
|
+
|
|
179
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
180
|
+
corrupt, encrypted, oversized or hostile package, or a deck without any text."""
|
|
181
|
+
def has_slides(names):
|
|
182
|
+
return any(re.fullmatch(r'ppt/slides/slide\d+\.xml', name) for name in names)
|
|
183
|
+
|
|
184
|
+
with _open_package(data, 'PPTX', has_slides) as package:
|
|
185
|
+
slides = sorted((int(m.group(1)), name) for name in package.entries
|
|
186
|
+
if (m := re.fullmatch(r'ppt/slides/slide(\d+)\.xml', name)))
|
|
187
|
+
lines = _Lines('PPTX')
|
|
188
|
+
for number, name in slides:
|
|
189
|
+
body = _drawing_paragraphs(package.parse(name))
|
|
190
|
+
if body:
|
|
191
|
+
lines.add(f'[slide {number}]', *body)
|
|
192
|
+
notes_name = f'ppt/notesSlides/notesSlide{number}.xml'
|
|
193
|
+
notes = _drawing_paragraphs(package.parse(notes_name)) if notes_name in package.entries else []
|
|
194
|
+
if notes:
|
|
195
|
+
lines.add(f'[notes {number}]', *notes)
|
|
196
|
+
if not lines:
|
|
197
|
+
raise DocumentError('PPTX has no text.')
|
|
198
|
+
return ('\n'.join(lines) + '\n').encode('utf-8')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _shared_strings(package):
|
|
202
|
+
if 'xl/sharedStrings.xml' not in package.entries:
|
|
203
|
+
return []
|
|
204
|
+
strings = []
|
|
205
|
+
for item in package.parse('xl/sharedStrings.xml'):
|
|
206
|
+
if _local(item.tag) != 'si':
|
|
207
|
+
continue
|
|
208
|
+
pending = list(reversed(item))
|
|
209
|
+
pieces = []
|
|
210
|
+
while pending:
|
|
211
|
+
child = pending.pop()
|
|
212
|
+
kind = _local(child.tag)
|
|
213
|
+
if kind == 't':
|
|
214
|
+
pieces.append(child.text or '')
|
|
215
|
+
elif kind != 'rPh':
|
|
216
|
+
pending.extend(reversed(child))
|
|
217
|
+
strings.append(''.join(pieces))
|
|
218
|
+
return strings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _column_index(ref):
|
|
222
|
+
letters = re.match(r'[A-Za-z]+', ref or '')
|
|
223
|
+
if not letters:
|
|
224
|
+
return None
|
|
225
|
+
index = 0
|
|
226
|
+
for char in letters.group().upper():
|
|
227
|
+
index = index * 26 + ord(char) - 64
|
|
228
|
+
return index - 1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _sheet_rows(root, strings):
|
|
232
|
+
"""Tab-joined text of each non-empty row of a worksheet; an empty cell keeps the columns after it aligned."""
|
|
233
|
+
for row in root.iter():
|
|
234
|
+
if _local(row.tag) != 'row':
|
|
235
|
+
continue
|
|
236
|
+
values = []
|
|
237
|
+
for cell in row:
|
|
238
|
+
if _local(cell.tag) != 'c':
|
|
239
|
+
continue
|
|
240
|
+
kind = cell.get('t')
|
|
241
|
+
value = ''
|
|
242
|
+
if kind == 'inlineStr':
|
|
243
|
+
value = ''.join(node.text or '' for node in cell.iter() if _local(node.tag) == 't')
|
|
244
|
+
else:
|
|
245
|
+
raw = next((node.text or '' for node in cell if _local(node.tag) == 'v'), '')
|
|
246
|
+
if kind == 's':
|
|
247
|
+
position = int(raw) if raw.strip().isdigit() else -1
|
|
248
|
+
if not 0 <= position < len(strings):
|
|
249
|
+
raise DocumentError('Could not extract text from this XLSX.')
|
|
250
|
+
value = strings[position]
|
|
251
|
+
else:
|
|
252
|
+
value = raw
|
|
253
|
+
column = _column_index(cell.get('r'))
|
|
254
|
+
if column is None:
|
|
255
|
+
column = len(values)
|
|
256
|
+
if column >= XLSX_MAX_COLUMNS:
|
|
257
|
+
raise DocumentError(f'XLSX over {XLSX_MAX_COLUMNS} columns.')
|
|
258
|
+
values += [''] * (column - len(values))
|
|
259
|
+
if column == len(values):
|
|
260
|
+
values.append(value)
|
|
261
|
+
else:
|
|
262
|
+
values[column] = value
|
|
263
|
+
if any(value.strip() for value in values):
|
|
264
|
+
yield '\t'.join(values)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_xlsx(data):
|
|
268
|
+
"""Text of an .xlsx: each sheet, in workbook order, under `[sheet NAME]`, then one line per row with the cells
|
|
269
|
+
separated by tabs. Shared strings are resolved; numbers and inline strings are kept; a formula contributes its cached
|
|
270
|
+
value, nothing is calculated.
|
|
271
|
+
|
|
272
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
273
|
+
corrupt, encrypted, oversized or hostile package, a workbook over XLSX_MAX_ROWS rows or MAX_TEXT_BYTES of text, or
|
|
274
|
+
one without any text."""
|
|
275
|
+
with _open_package(data, 'XLSX', 'xl/workbook.xml') as package:
|
|
276
|
+
workbook = package.parse('xl/workbook.xml')
|
|
277
|
+
targets = {}
|
|
278
|
+
if 'xl/_rels/workbook.xml.rels' in package.entries:
|
|
279
|
+
for rel in package.parse('xl/_rels/workbook.xml.rels'):
|
|
280
|
+
target = rel.get('Target', '')
|
|
281
|
+
targets[rel.get('Id')] = posixpath.normpath(target.lstrip('/') if target.startswith('/')
|
|
282
|
+
else posixpath.join('xl', target))
|
|
283
|
+
strings = _shared_strings(package)
|
|
284
|
+
lines = _Lines('XLSX')
|
|
285
|
+
rows = 0
|
|
286
|
+
for sheet in (node for node in workbook.iter() if _local(node.tag) == 'sheet'):
|
|
287
|
+
rid = next((value for key, value in sheet.attrib.items() if _local(key) == 'id'), None)
|
|
288
|
+
part = targets.get(rid)
|
|
289
|
+
if part not in package.entries:
|
|
290
|
+
raise DocumentError('Invalid XLSX or excessive structure.')
|
|
291
|
+
header = f"[sheet {' '.join(sheet.get('name', '').split())}]"
|
|
292
|
+
for line in _sheet_rows(package.parse(part), strings):
|
|
293
|
+
rows += 1
|
|
294
|
+
if rows > XLSX_MAX_ROWS:
|
|
295
|
+
raise DocumentError(f'XLSX over {XLSX_MAX_ROWS} rows.')
|
|
296
|
+
if header:
|
|
297
|
+
lines.add(header)
|
|
298
|
+
header = ''
|
|
299
|
+
lines.add(line)
|
|
300
|
+
if not lines:
|
|
301
|
+
raise DocumentError('XLSX has no text.')
|
|
302
|
+
return ('\n'.join(lines) + '\n').encode('utf-8')
|
|
87
303
|
|
|
88
304
|
|
|
89
305
|
PDF_SCRIPT = """
|
|
@@ -150,11 +366,15 @@ def extract_pdf(data):
|
|
|
150
366
|
return (text + "\n").encode("utf-8", "replace")
|
|
151
367
|
|
|
152
368
|
|
|
369
|
+
_EXTENSIONS = ('.docx', '.pptx', '.xlsx', '.pdf')
|
|
370
|
+
|
|
371
|
+
|
|
153
372
|
def is_extracted(path):
|
|
154
373
|
"""Whether the file type goes through a text extractor before indexing.
|
|
155
374
|
|
|
156
|
-
Parameters: path (any file name or path). Returns: True for `.docx` and `.pdf`, case-insensitive
|
|
157
|
-
|
|
375
|
+
Parameters: path (any file name or path). Returns: True for `.docx`, `.pptx`, `.xlsx` and `.pdf`, case-insensitive;
|
|
376
|
+
macro and binary formats (`.xlsm`, `.xlsb`, `.xls`, `.ppt`, `.pptm`) are not extracted."""
|
|
377
|
+
return str(path).lower().endswith(_EXTENSIONS)
|
|
158
378
|
|
|
159
379
|
|
|
160
380
|
def size_limit(path):
|
|
@@ -169,7 +389,14 @@ def extract(path, data):
|
|
|
169
389
|
|
|
170
390
|
Parameters: path (decides the type, see is_extracted), data (file bytes). Returns: UTF-8 text bytes. Raises:
|
|
171
391
|
DocumentError with a readable reason."""
|
|
172
|
-
|
|
392
|
+
name = str(path).lower()
|
|
393
|
+
if name.endswith('.pdf'):
|
|
394
|
+
return extract_pdf(data)
|
|
395
|
+
if name.endswith('.pptx'):
|
|
396
|
+
return extract_pptx(data)
|
|
397
|
+
if name.endswith('.xlsx'):
|
|
398
|
+
return extract_xlsx(data)
|
|
399
|
+
return extract_docx(data)
|
|
173
400
|
|
|
174
401
|
|
|
175
402
|
def location_kind(path):
|
package/hooks/session_audit.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook of Claude Code (opt-in, from the setup screen): forwards the payload to the Smart Tool daemon
|
|
2
|
+
"""SessionStart hook of Claude Code and Codex (opt-in, from the setup screen): forwards the payload to the Smart Tool daemon
|
|
3
3
|
(POST /hooks/session-audit) and prints its answer, the stored setup audit notice of the session folder as additional
|
|
4
4
|
context. Never starts the daemon or an audit: daemon down or any error means empty output, so a session start is
|
|
5
5
|
never delayed or blocked.
|
package/index_profile.py
CHANGED
|
@@ -11,7 +11,7 @@ PROFILE_VERSION = 2
|
|
|
11
11
|
KINDS = ("code", "test", "doc")
|
|
12
12
|
RETRY_AFTER_S = 3600
|
|
13
13
|
MAX_DIRS = 90
|
|
14
|
-
PROSE = {"md", "txt", "docx", "pdf", "rst", "adoc"}
|
|
14
|
+
PROSE = {"md", "txt", "docx", "pptx", "xlsx", "pdf", "rst", "adoc"}
|
|
15
15
|
# Data and markup inside a documentation, design or data-dump folder are reports and mockups, not code (GitHub Linguist
|
|
16
16
|
# treats docs/ as documentation; a design/ HTML or a probe's JSON result pushed hook_decision.py out of the code block).
|
|
17
17
|
DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.42",
|
|
4
4
|
"description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Allan Santos",
|
package/scout.py
CHANGED
|
@@ -102,7 +102,7 @@ def is_intent_question(question):
|
|
|
102
102
|
|
|
103
103
|
def disk_lines(root, path, start, end):
|
|
104
104
|
"""`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
|
|
105
|
-
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf)."""
|
|
105
|
+
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf, pptx, xlsx)."""
|
|
106
106
|
if document_text.location_kind(path) != "source_line":
|
|
107
107
|
return None
|
|
108
108
|
data, encoding = indexer.read_indexable(root, path)
|
package/setup_ui.py
CHANGED
|
@@ -325,21 +325,25 @@ def handle_integration_save(body):
|
|
|
325
325
|
def handle_audit(body):
|
|
326
326
|
"""Setup audit panel: `index` lists the registered projects with their last audit summary; `report` returns the
|
|
327
327
|
last stored report of one of them; `run` audits it now (MCP servers contacted unless probe is false). Only
|
|
328
|
-
registered project folders are accepted. The opt-in session-start report in Claude Code
|
|
328
|
+
registered project folders are accepted. The opt-in session-start report in Claude Code or Codex (`client`, default
|
|
329
|
+
claude; Codex runs it only after /hooks approval, state `waiting_approval`): `hook_state`,
|
|
329
330
|
`hook_preview` (the exact entry and a token), `hook_install` (with that token, after a backup) and `hook_remove`
|
|
330
331
|
(only Smart Tool's entry, after a backup)."""
|
|
331
332
|
if not isinstance(body, dict):
|
|
332
333
|
raise SetupError("Invalid request.")
|
|
333
334
|
roots = sorted({p["root"] for p in project_store.all_projects() if p.get("root")}, key=str.casefold)
|
|
334
335
|
try:
|
|
336
|
+
client = body.get("client", "claude")
|
|
335
337
|
if body.get("action") == "hook_state":
|
|
336
|
-
|
|
338
|
+
state = client_hooks.audit_state(client)
|
|
339
|
+
detail = {"detail": "installed, waiting for your approval in /hooks"} if state == client_hooks.AUDIT_WAITING else {}
|
|
340
|
+
return {"state": state, "file": client_hooks.CLIENTS[client]["files"][0], **detail}
|
|
337
341
|
if body.get("action") == "hook_preview":
|
|
338
|
-
return client_hooks.audit_preview()
|
|
342
|
+
return client_hooks.audit_preview(client)
|
|
339
343
|
if body.get("action") == "hook_install":
|
|
340
|
-
return client_hooks.audit_install(body.get("token"), True)
|
|
344
|
+
return client_hooks.audit_install(body.get("token"), True, client)
|
|
341
345
|
if body.get("action") == "hook_remove":
|
|
342
|
-
return client_hooks.audit_remove()
|
|
346
|
+
return client_hooks.audit_remove(client)
|
|
343
347
|
if body.get("action") == "index":
|
|
344
348
|
stored = setup_audit.index()
|
|
345
349
|
found = {root: stored[setup_audit.index_key(root)] for root in roots if setup_audit.index_key(root) in stored}
|
|
@@ -809,8 +813,9 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
809
813
|
<header class="page-head"><h2 id="h-audit" tabindex="-1">Setup audit</h2>
|
|
810
814
|
<p>What Claude Code and Codex load for a project, and where they collide: hooks deciding on the same tool at once, hooks or MCP servers that do not work, tools offered twice, skills and instructions one agent never sees. It runs on its own for the registered projects and never changes anything.</p></header>
|
|
811
815
|
<div class="card">
|
|
812
|
-
<div class="card-head"><h3>Report at session start
|
|
813
|
-
<
|
|
816
|
+
<div class="card-head"><h3>Report at session start</h3><span class="pill" id="audit-hook-pill" data-tone="busy">Checking</span></div>
|
|
817
|
+
<div class="field"><label for="sel-audit-hook-client">Client</label><select id="sel-audit-hook-client"><option value="claude">Claude Code</option><option value="codex">Codex</option></select></div>
|
|
818
|
+
<p class="lede">Off by default. When on, every new session starts with one short line about the stored audit of its folder (errors and warnings only, nothing runs). It adds a SessionStart entry to <span class="mono" id="audit-hook-file"></span>, written after a backup, and turning it off removes only that entry. Codex runs a new hook only after you approve it in /hooks. Without it, the agent gets the same line once, on its first search.</p>
|
|
814
819
|
<div class="actions"><button type="button" class="primary" id="btn-audit-hook-on" hidden>Turn on</button><button type="button" id="btn-audit-hook-off" hidden>Turn off</button></div>
|
|
815
820
|
<div id="audit-hook-confirm" hidden>
|
|
816
821
|
<p class="hint" id="audit-hook-where"></p>
|
|
@@ -1886,14 +1891,17 @@ async function loadAudit() {
|
|
|
1886
1891
|
}
|
|
1887
1892
|
renderOverview();
|
|
1888
1893
|
}
|
|
1894
|
+
function auditHookClient() { return $("sel-audit-hook-client").value; }
|
|
1895
|
+
$("sel-audit-hook-client").addEventListener("change", () => { setMsg("audit-hook-msg", "", ""); loadAuditHook(); });
|
|
1889
1896
|
async function loadAuditHook() {
|
|
1890
1897
|
try {
|
|
1891
|
-
const hook = await post("/setup/audit", { action: "hook_state" });
|
|
1898
|
+
const hook = await post("/setup/audit", { action: "hook_state", client: auditHookClient() });
|
|
1892
1899
|
$("audit-hook-file").textContent = hook.file;
|
|
1893
|
-
const on = hook.state === "current";
|
|
1900
|
+
const on = hook.state === "current" || hook.state === "waiting_approval";
|
|
1894
1901
|
const pillEl = $("audit-hook-pill");
|
|
1895
|
-
pillEl.textContent = on ? "On" : hook.state === "outdated" ? "Outdated" : "Off";
|
|
1896
|
-
pillEl.dataset.tone =
|
|
1902
|
+
pillEl.textContent = hook.state === "waiting_approval" ? "Waiting for approval in /hooks" : on ? "On" : hook.state === "outdated" ? "Outdated" : "Off";
|
|
1903
|
+
pillEl.dataset.tone = hook.state === "waiting_approval" || hook.state === "outdated" ? "warn" : on ? "ok" : "idle";
|
|
1904
|
+
if (hook.detail) setMsg("audit-hook-msg", "", hook.detail + ".");
|
|
1897
1905
|
$("btn-audit-hook-on").hidden = on;
|
|
1898
1906
|
$("btn-audit-hook-on").textContent = hook.state === "outdated" ? "Update" : "Turn on";
|
|
1899
1907
|
$("btn-audit-hook-off").hidden = hook.state === "missing";
|
|
@@ -1904,7 +1912,7 @@ async function loadAuditHook() {
|
|
|
1904
1912
|
$("btn-audit-hook-on").addEventListener("click", async () => {
|
|
1905
1913
|
setMsg("audit-hook-msg", "", "Preparing the change…");
|
|
1906
1914
|
try {
|
|
1907
|
-
const plan = await post("/setup/audit", { action: "hook_preview" });
|
|
1915
|
+
const plan = await post("/setup/audit", { action: "hook_preview", client: auditHookClient() });
|
|
1908
1916
|
setMsg("audit-hook-msg", "", "");
|
|
1909
1917
|
$("audit-hook-where").textContent = "Adds this entry under " + plan.event + " in " + plan.file + ".";
|
|
1910
1918
|
$("audit-hook-preview").textContent = JSON.stringify(plan.change, null, 2);
|
|
@@ -1912,10 +1920,10 @@ $("btn-audit-hook-on").addEventListener("click", async () => {
|
|
|
1912
1920
|
$("btn-audit-hook-write").onclick = async () => {
|
|
1913
1921
|
$("btn-audit-hook-write").disabled = true;
|
|
1914
1922
|
try {
|
|
1915
|
-
const result = await post("/setup/audit", { action: "hook_install", token: plan.token });
|
|
1923
|
+
const result = await post("/setup/audit", { action: "hook_install", token: plan.token, client: auditHookClient() });
|
|
1916
1924
|
$("audit-hook-confirm").hidden = true;
|
|
1917
1925
|
await loadAuditHook();
|
|
1918
|
-
setMsg("audit-hook-msg", "ok", "Turned on. Backup: " + (result.backup || "none, the file was new") + ".");
|
|
1926
|
+
setMsg("audit-hook-msg", "ok", "Turned on. Backup: " + (result.backup || "none, the file was new") + "." + (result.after_install ? " " + result.after_install : ""));
|
|
1919
1927
|
} catch (error) {
|
|
1920
1928
|
setMsg("audit-hook-msg", "error", error.message);
|
|
1921
1929
|
} finally {
|
|
@@ -1929,7 +1937,7 @@ $("btn-audit-hook-on").addEventListener("click", async () => {
|
|
|
1929
1937
|
$("btn-audit-hook-cancel").addEventListener("click", () => { $("audit-hook-confirm").hidden = true; });
|
|
1930
1938
|
$("btn-audit-hook-off").addEventListener("click", async () => {
|
|
1931
1939
|
try {
|
|
1932
|
-
const result = await post("/setup/audit", { action: "hook_remove" });
|
|
1940
|
+
const result = await post("/setup/audit", { action: "hook_remove", client: auditHookClient() });
|
|
1933
1941
|
await loadAuditHook();
|
|
1934
1942
|
setMsg("audit-hook-msg", "ok", "Turned off. Backup: " + (result.backup || "none") + ".");
|
|
1935
1943
|
} catch (error) {
|
package/version.py
CHANGED