@allansantos-dev/smart-tool 0.9.38 → 0.9.40
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 +31 -0
- package/README.md +11 -4
- package/config.py +11 -0
- package/dependency_notice.py +176 -0
- package/document_text.py +114 -3
- package/dossier.py +5 -3
- package/freshness.py +62 -23
- package/hook_decision.py +39 -11
- package/index_profile.py +1 -1
- package/indexer.py +6 -6
- package/package.json +1 -1
- package/scout.py +16 -3
- package/setup_audit.py +46 -4
- package/setup_ui.py +5 -3
- package/smart_tool_daemon.py +10 -4
- package/tray.py +7 -1
- package/version.py +1 -1
- package/web_fetch.py +7 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.40 - beta
|
|
4
|
+
|
|
5
|
+
- PDFs of the project are searchable. `smart_search` indexes the text layer of a `.pdf` inside the project scope and
|
|
6
|
+
returns the page (`[page N]`) with the passage. The extractor is the one `web_fetch` already used, now in
|
|
7
|
+
`document_text`: `pypdf` in an isolated subprocess killed after 20 s, at most 20 MiB, 300 pages and 1,000,000
|
|
8
|
+
characters per file. A scanned PDF (no text layer), a password-protected one, a corrupt one or one that overruns the
|
|
9
|
+
deadline is skipped with the reason in the index stats and the rest of the project is indexed as usual. The secret,
|
|
10
|
+
minified and long-line guards apply to the extracted text. `document_text.VERSION` is 2: every `.docx` already indexed
|
|
11
|
+
is read once more. Tests in `tests/test_pdf_index.py`; measurement before and after in
|
|
12
|
+
`docs/research/data/pdf-probe-*.json`.
|
|
13
|
+
- Editing `package.json` warns about known-vulnerable dependencies. Added or changed entries of `dependencies`,
|
|
14
|
+
`devDependencies`, `peerDependencies` and `optionalDependencies` (at most 5 per edit) are checked against OSV with one
|
|
15
|
+
`querybatch` call (2.5 s timeout, answers cached on disk for 6 h); the edit reminder lists the advisory IDs per
|
|
16
|
+
`package@version`, or says `dependency check skipped: <reason>` when OSV cannot be reached, and the edit goes on.
|
|
17
|
+
Only package names and versions leave the machine. `dependency_mode` in `config.json` (`warn` by default, `off`)
|
|
18
|
+
turns it off. Tests in `tests/test_dependency_notice.py`; measurement in `docs/research/data/dependency-probe-*.json`.
|
|
19
|
+
- A scheduled setup audit that finds new errors or warnings raises one Windows toast per project with an "Open the
|
|
20
|
+
audit" button; the first audit, manual runs and info findings stay silent (`tests/test_audit_toast.py`).
|
|
21
|
+
- `smart_scout` answers a question about intent (why, reason, decided) with up to three decision notes from the project
|
|
22
|
+
notes next to the code lines (`tests/test_scout.py`).
|
|
23
|
+
- The edit reminder gives the command that runs the tests calling the edited function (`tests/test_edit_review_cache.py`).
|
|
24
|
+
|
|
25
|
+
## 0.9.39 - beta
|
|
26
|
+
|
|
27
|
+
- Two-letter technical terms (`ui`, `db`, `id`) count again in the shared `focus_terms`, used by `smart_search
|
|
28
|
+
mode=dossier` and `smart_scout`. Two-letter English and Portuguese function words (`is`, `of`, `de`, `em`, ...) are
|
|
29
|
+
dropped like the question words. Tests in `tests/test_dossier.py`.
|
|
30
|
+
- The npm registry and OSV checks of `freshness` no longer raise. `npm_registry_evidence` read a local `version` before
|
|
31
|
+
assigning it (UnboundLocalError on every call) and `npm_advisories` shadowed the `version` module with its parameter;
|
|
32
|
+
both return evidence or `None` as documented. Tests in `tests/test_npm_evidence.py`.
|
|
33
|
+
|
|
3
34
|
## 0.9.38 - beta
|
|
4
35
|
|
|
5
36
|
- One definition of a search term. `smart_search mode=dossier` now takes its terms from `dossier.focus_terms`, the
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharpe
|
|
|
5
5
|
| Tool | What it does |
|
|
6
6
|
|---|---|
|
|
7
7
|
| `smart_search` / `smart_search_result` | Semantic + lexical search over an indexed project, results split into code, tests and docs, with the likeliest functions per chunk |
|
|
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 |
|
|
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
11
|
| `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 |
|
|
@@ -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.40 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
|
@@ -181,13 +181,18 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
181
181
|
`@override`) functions are exempt, as in pydocstyle, eslint-plugin-jsdoc and Checkstyle, and a JSDoc above a set of
|
|
182
182
|
TypeScript overloads documents the implementation:
|
|
183
183
|
- **Remind** (default): the edit runs and the agent is told which touched functions have no docstring, and which
|
|
184
|
-
documented ones changed their signature, with their callers
|
|
184
|
+
documented ones changed their signature, with their callers, the tests calling them and the command that runs those tests
|
|
185
|
+
(from the code graph, only when it is already cached: the hook never waits for an analysis).
|
|
185
186
|
- **Require**: an edit that leaves a touched function without a docstring is blocked until it adds one.
|
|
186
187
|
- **Off**: no checks.
|
|
187
188
|
|
|
188
189
|
A documented function edited only in its body is not mentioned. `project_manage` with `action=docs` lists the
|
|
189
190
|
public functions still without a docstring, for documenting a project that started without them.
|
|
190
191
|
|
|
192
|
+
**Dependency check on edit**: an edit of `package.json` 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
|
+
|
|
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.
|
|
195
|
+
|
|
191
196
|
**Block patterns waiting for you** are shown in one approval panel: 8 per page, the ones that would have blocked
|
|
192
197
|
the most logged calls first, a filter over pattern, reason and example, and bulk accept or reject of the selected
|
|
193
198
|
rows; each row opens to its reason and example.
|
|
@@ -219,7 +224,9 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
219
224
|
two minutes after it starts and then every 30 minutes (MCP servers shared by several projects are contacted once
|
|
220
225
|
and their result reused for 6 hours; Run now always contacts them again), and the setup screen's **Setup audit**
|
|
221
226
|
panel shows each project's last report: findings by severity, what is new, what was resolved, the tokens each MCP
|
|
222
|
-
server's tools cost and what was not verified.
|
|
227
|
+
server's tools cost and what was not verified. When a scheduled run finds new errors or warnings, a Windows
|
|
228
|
+
notification from the tray names the folder and up to three of them, once per finding, with a button that opens the
|
|
229
|
+
panel; manual runs and info findings stay silent.
|
|
223
230
|
|
|
224
231
|
The agent hears about it without asking for it. `setup_audit` with `mode=last` returns the stored report for the
|
|
225
232
|
folder (or the nearest audited parent) without auditing again, and fails with what to do when none is stored. On
|
package/config.py
CHANGED
|
@@ -37,6 +37,7 @@ DEFAULT_CONFIG = {
|
|
|
37
37
|
"hook_mode": "redirect",
|
|
38
38
|
"doc_mode": "remind",
|
|
39
39
|
"duplicate_mode": "warn",
|
|
40
|
+
"dependency_mode": "warn",
|
|
40
41
|
"auto_update": True,
|
|
41
42
|
"pattern_proposals": "on",
|
|
42
43
|
"contact": "",
|
|
@@ -61,6 +62,9 @@ DOC_MODES = ("require", "remind", "off")
|
|
|
61
62
|
# Edit hook on functions that copy one already indexed (exact or near-identical body): warn = let the edit run and
|
|
62
63
|
# point to the existing function; off = say nothing. Never blocks: near matches are leads, not defects.
|
|
63
64
|
DUPLICATE_MODES = ("warn", "off")
|
|
65
|
+
# Edit hook on package.json: warn = let the edit run and list the added or changed dependencies that have OSV advisories;
|
|
66
|
+
# off = say nothing and make no network call.
|
|
67
|
+
DEPENDENCY_MODES = ("warn", "off")
|
|
64
68
|
# Router model reviewing, in the background, searches the deterministic redirect rule let run: on = propose block
|
|
65
69
|
# patterns for the user to accept in the setup screen; off = no review.
|
|
66
70
|
PATTERN_PROPOSAL_MODES = ("on", "off")
|
|
@@ -85,6 +89,13 @@ def duplicate_mode(cfg=None):
|
|
|
85
89
|
return value
|
|
86
90
|
|
|
87
91
|
|
|
92
|
+
def dependency_mode(cfg=None):
|
|
93
|
+
value = (cfg if cfg is not None else load_config()).get("dependency_mode") or DEFAULT_CONFIG["dependency_mode"]
|
|
94
|
+
if value not in DEPENDENCY_MODES:
|
|
95
|
+
raise ValueError(f"Invalid dependency_mode ({value!r}) in {CONFIG_PATH}: use warn or off.")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
88
99
|
def pattern_proposals(cfg=None):
|
|
89
100
|
"""on: the router model reviews searches the redirect rule let run and proposes block patterns for the user to
|
|
90
101
|
accept; off: no review."""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Vulnerable-dependency notice for an edit of package.json, built before the edit runs.
|
|
2
|
+
|
|
3
|
+
Compares the dependency tables of the file before and after the edit, asks OSV (one `querybatch` call, short timeout)
|
|
4
|
+
about the added or changed ones only, and answers from a disk cache of (package, version) -> advisory IDs for 6 hours.
|
|
5
|
+
Only package names and versions leave the machine. The caller (`hook_decision`) turns a `DependencyCheckError` into
|
|
6
|
+
"dependency check skipped: <reason>" and lets the edit go on.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import atomic_io
|
|
14
|
+
import freshness
|
|
15
|
+
import paths
|
|
16
|
+
|
|
17
|
+
CACHE_PATH = os.path.join(paths.DATA_DIR, "dependency_advisories.json")
|
|
18
|
+
CACHE_TTL_S = 6 * 3600
|
|
19
|
+
OSV_TIMEOUT_S = 2.5
|
|
20
|
+
MAX_PACKAGES = 5
|
|
21
|
+
MAX_IDS_SHOWN = 5
|
|
22
|
+
TABLES = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
|
|
23
|
+
PREFIX = "Smart Tool dependencies: "
|
|
24
|
+
_SPECIFIER_RE = re.compile(r"(\^|~|>=|>|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?"
|
|
25
|
+
r"((?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DependencyCheckError(RuntimeError):
|
|
29
|
+
"""The check could not run (OSV unreachable or answering badly, unreadable cache, current file not JSON)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tables(text):
|
|
33
|
+
"""The (name, specifier) pairs of every dependency table in a package.json text.
|
|
34
|
+
|
|
35
|
+
Parameters: text (str) file content.
|
|
36
|
+
Returns: dict (name, specifier) -> None, in file order; None when the text is not a JSON object.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(text)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
return None
|
|
44
|
+
pairs = {}
|
|
45
|
+
for table in TABLES:
|
|
46
|
+
entries = data.get(table)
|
|
47
|
+
if isinstance(entries, dict):
|
|
48
|
+
pairs.update({(name, spec): None for name, spec in entries.items() if isinstance(spec, str)})
|
|
49
|
+
return pairs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def changed_dependencies(before, after):
|
|
53
|
+
"""Dependencies added or whose specifier changed between two package.json texts.
|
|
54
|
+
|
|
55
|
+
Parameters: before (str or None) text before the edit, None when the file did not exist; after (str) text after.
|
|
56
|
+
Returns: list of (name, specifier); empty when `after` is not valid JSON (the user is mid-edit).
|
|
57
|
+
Raises: DependencyCheckError when `before` exists but is not valid JSON.
|
|
58
|
+
"""
|
|
59
|
+
now = _tables(after)
|
|
60
|
+
if now is None:
|
|
61
|
+
return []
|
|
62
|
+
if before is None or not before.strip():
|
|
63
|
+
old = {}
|
|
64
|
+
else:
|
|
65
|
+
old = _tables(before)
|
|
66
|
+
if old is None:
|
|
67
|
+
raise DependencyCheckError("current package.json is not valid JSON")
|
|
68
|
+
return [pair for pair in now if pair not in old]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def queried_version(specifier):
|
|
72
|
+
"""The version to ask OSV about for a specifier.
|
|
73
|
+
|
|
74
|
+
Parameters: specifier (str) the value in the dependency table.
|
|
75
|
+
Returns: str, the exact version or the lower bound of a ^, ~, >= or > range; None when the specifier cannot be
|
|
76
|
+
reduced safely (tags, `*`, URLs, file:, git, workspace:, npm: aliases, x-ranges, compound ranges).
|
|
77
|
+
"""
|
|
78
|
+
match = _SPECIFIER_RE.fullmatch(specifier.strip())
|
|
79
|
+
if not match:
|
|
80
|
+
return None
|
|
81
|
+
operator, major, minor, patch, tail = match.groups()
|
|
82
|
+
parts = [major, minor, patch]
|
|
83
|
+
if operator in ("^", "~", ">=", ">"):
|
|
84
|
+
return ".".join(part or "0" for part in parts) + (tail or "")
|
|
85
|
+
if None in parts:
|
|
86
|
+
return None
|
|
87
|
+
return ".".join(parts) + (tail or "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_cache():
|
|
91
|
+
"""The cache as {"name@version": {"ids": [...], "at": epoch}}; empty when the file does not exist.
|
|
92
|
+
|
|
93
|
+
Raises: DependencyCheckError when the file exists and is unreadable, not JSON or not an object (never reset).
|
|
94
|
+
"""
|
|
95
|
+
if not os.path.exists(CACHE_PATH):
|
|
96
|
+
return {}
|
|
97
|
+
try:
|
|
98
|
+
with open(CACHE_PATH, encoding="utf-8") as stream:
|
|
99
|
+
data = json.load(stream)
|
|
100
|
+
except (OSError, ValueError) as exc:
|
|
101
|
+
raise DependencyCheckError(f"advisory cache {CACHE_PATH} is unreadable: {exc}") from exc
|
|
102
|
+
if not isinstance(data, dict):
|
|
103
|
+
raise DependencyCheckError(f"advisory cache {CACHE_PATH} is not a JSON object")
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fresh_ids(entry, now):
|
|
108
|
+
"""The cached IDs of an entry, or None when it is missing, malformed or older than CACHE_TTL_S."""
|
|
109
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("ids"), list) or not isinstance(entry.get("at"), (int, float)):
|
|
110
|
+
return None
|
|
111
|
+
return entry["ids"] if now - entry["at"] < CACHE_TTL_S else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _advisories(queries):
|
|
115
|
+
"""Advisory IDs for (name, version) pairs, from the cache and one OSV batch for the rest.
|
|
116
|
+
|
|
117
|
+
Parameters: queries (list of (name, version)).
|
|
118
|
+
Returns: tuple (list of IDs or None per query in order, str cache-write problem or "").
|
|
119
|
+
Raises: DependencyCheckError when the cache is corrupt or OSV cannot answer.
|
|
120
|
+
"""
|
|
121
|
+
cache, now = _load_cache(), time.time()
|
|
122
|
+
found = {query: _fresh_ids(cache.get("@".join(query)), now) for query in queries}
|
|
123
|
+
missing = [query for query in queries if found[query] is None]
|
|
124
|
+
if not missing:
|
|
125
|
+
return [found[query] for query in queries], ""
|
|
126
|
+
try:
|
|
127
|
+
answered = freshness.npm_advisories_batch(missing, OSV_TIMEOUT_S)
|
|
128
|
+
except RuntimeError as exc:
|
|
129
|
+
raise DependencyCheckError(str(exc)) from exc
|
|
130
|
+
for query, ids in zip(missing, answered):
|
|
131
|
+
found[query] = ids
|
|
132
|
+
if ids is not None:
|
|
133
|
+
cache["@".join(query)] = {"ids": ids, "at": now}
|
|
134
|
+
cache = {key: entry for key, entry in cache.items() if _fresh_ids(entry, now) is not None}
|
|
135
|
+
try:
|
|
136
|
+
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
|
137
|
+
atomic_io.write_secret_text(CACHE_PATH, json.dumps(cache, indent=1))
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
return [found[query] for query in queries], f"advisory cache not saved: {exc}"
|
|
140
|
+
return [found[query] for query in queries], ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def notice(before, after):
|
|
144
|
+
"""The dependency notice for one package.json edit.
|
|
145
|
+
|
|
146
|
+
Parameters: before (str or None) file text before the edit; after (str) text after it.
|
|
147
|
+
Returns: str, "Smart Tool dependencies: ..." lines for the changed dependencies that have advisories or could not
|
|
148
|
+
be verified, "" when there is nothing to say.
|
|
149
|
+
Raises: DependencyCheckError when the check could not run.
|
|
150
|
+
"""
|
|
151
|
+
changed = changed_dependencies(before, after)
|
|
152
|
+
handled, extra = changed[:MAX_PACKAGES], len(changed) - MAX_PACKAGES
|
|
153
|
+
lines, queries = {}, []
|
|
154
|
+
for name, specifier in handled:
|
|
155
|
+
version = queried_version(specifier) if freshness.NPM_NAME_RE.fullmatch(name) else None
|
|
156
|
+
if version is None:
|
|
157
|
+
lines[(name, specifier)] = f"`{name}@{specifier}` specifier not verified."
|
|
158
|
+
else:
|
|
159
|
+
queries.append((name, specifier, version))
|
|
160
|
+
problem = ""
|
|
161
|
+
if queries:
|
|
162
|
+
ids, problem = _advisories([(name, version) for name, _specifier, version in queries])
|
|
163
|
+
for (name, specifier, version), advisories in zip(queries, ids):
|
|
164
|
+
label = f"`{name}@{specifier}`" + ("" if version == specifier else f" (checked {version})")
|
|
165
|
+
if advisories is None:
|
|
166
|
+
lines[(name, specifier)] = f"{label} advisory check incomplete, not verified."
|
|
167
|
+
elif advisories:
|
|
168
|
+
shown = ", ".join(advisories[:MAX_IDS_SHOWN])
|
|
169
|
+
more = f" and {len(advisories) - MAX_IDS_SHOWN} more" if len(advisories) > MAX_IDS_SHOWN else ""
|
|
170
|
+
lines[(name, specifier)] = f"{label} has advisory IDs {shown}{more}."
|
|
171
|
+
parts = [lines[pair] for pair in handled if pair in lines]
|
|
172
|
+
if extra > 0:
|
|
173
|
+
parts.append(f"{extra} more not checked.")
|
|
174
|
+
if problem and parts:
|
|
175
|
+
parts.append(f"({problem})")
|
|
176
|
+
return PREFIX + " ".join(parts) if parts else ""
|
package/document_text.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
"""Extração local de texto DOCX, sem Office, execução de macros ou acesso externo."""
|
|
1
|
+
"""Extração local de texto DOCX e PDF, sem Office, execução de macros ou acesso externo."""
|
|
2
2
|
import io
|
|
3
|
+
import json
|
|
3
4
|
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
4
7
|
import zipfile
|
|
5
8
|
import xml.etree.ElementTree as ET
|
|
6
9
|
|
|
7
|
-
VERSION =
|
|
10
|
+
VERSION = 2
|
|
11
|
+
PDF_MAX_BYTES = 20 * 1024 * 1024
|
|
12
|
+
PDF_TIMEOUT_S = 20
|
|
13
|
+
PDF_MAX_PAGES = 300
|
|
14
|
+
PDF_MAX_CHARS = 1_000_000
|
|
8
15
|
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
9
16
|
MAX_XML_BYTES = 8 * 1024 * 1024
|
|
10
17
|
MAX_TEXT_BYTES = 1024 * 1024
|
|
@@ -15,6 +22,20 @@ class DocumentError(ValueError):
|
|
|
15
22
|
pass
|
|
16
23
|
|
|
17
24
|
|
|
25
|
+
class PdfTimeout(DocumentError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PdfEncrypted(DocumentError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PdfUnreadable(DocumentError):
|
|
34
|
+
def __init__(self, message, detail):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.detail = detail
|
|
37
|
+
|
|
38
|
+
|
|
18
39
|
class _DocumentTreeBuilder(ET.TreeBuilder):
|
|
19
40
|
def doctype(self, name, pubid, system):
|
|
20
41
|
# O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
|
|
@@ -65,5 +86,95 @@ def extract_docx(data):
|
|
|
65
86
|
raise DocumentError('Could not extract text from this DOCX.') from exc
|
|
66
87
|
|
|
67
88
|
|
|
89
|
+
PDF_SCRIPT = """
|
|
90
|
+
import io, json, sys
|
|
91
|
+
import pypdf
|
|
92
|
+
reader = pypdf.PdfReader(io.BytesIO(sys.stdin.buffer.read()))
|
|
93
|
+
if reader.is_encrypted and reader.decrypt("") == pypdf.PasswordType.NOT_DECRYPTED:
|
|
94
|
+
print(json.dumps({"encrypted": True}))
|
|
95
|
+
sys.exit()
|
|
96
|
+
limit, budget = int(sys.argv[1]), int(sys.argv[2])
|
|
97
|
+
pages, size = [], 0
|
|
98
|
+
for page in reader.pages[:limit]:
|
|
99
|
+
pages.append((page.extract_text() or "").strip()[:budget - size])
|
|
100
|
+
size += len(pages[-1])
|
|
101
|
+
if size >= budget:
|
|
102
|
+
break
|
|
103
|
+
print(json.dumps({"pages": pages, "count": len(reader.pages)}))
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_pdf(data, timeout, max_pages, max_chars):
|
|
108
|
+
"""Text of a PDF (pypdf, pure Python), page by page, with `[page N]` markers. A hostile PDF can make pypdf parse for
|
|
109
|
+
minutes, so it runs in an isolated subprocess (`-I`) killed at the deadline.
|
|
110
|
+
|
|
111
|
+
Parameters: data (PDF bytes), timeout (seconds before the subprocess is killed), max_pages, max_chars (cap on the
|
|
112
|
+
text read, over all pages). Returns: the marked text, with a `[pages A-B not read]` line when a cap cut it; empty
|
|
113
|
+
pages are left out. Raises: PdfTimeout, PdfEncrypted (a password is needed), PdfUnreadable (corrupt; `detail` has
|
|
114
|
+
the last line pypdf reported), all DocumentError."""
|
|
115
|
+
try:
|
|
116
|
+
done = subprocess.run([sys.executable, "-I", "-c", PDF_SCRIPT, str(max_pages), str(max_chars)],
|
|
117
|
+
input=data, capture_output=True, timeout=timeout,
|
|
118
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
raise PdfTimeout(f"PDF not read in {timeout:.0f}s.") from None
|
|
121
|
+
if done.returncode:
|
|
122
|
+
detail = (done.stderr.decode("utf-8", "replace").strip().splitlines() or ["no output"])[-1]
|
|
123
|
+
raise PdfUnreadable("PDF could not be read.", detail)
|
|
124
|
+
try:
|
|
125
|
+
result = json.loads(done.stdout)
|
|
126
|
+
if result.get("encrypted"):
|
|
127
|
+
raise PdfEncrypted("PDF protected by a password.")
|
|
128
|
+
pages, count = result["pages"], result["count"]
|
|
129
|
+
except (ValueError, KeyError, AttributeError) as exc:
|
|
130
|
+
if isinstance(exc, PdfEncrypted):
|
|
131
|
+
raise
|
|
132
|
+
raise PdfUnreadable("PDF could not be read.", f"unexpected extractor output ({type(exc).__name__})") from None
|
|
133
|
+
text = "\n\n".join(f"[page {number}]\n{page}" for number, page in enumerate(pages, 1) if page)
|
|
134
|
+
if count > len(pages):
|
|
135
|
+
text += f"\n\n[pages {len(pages) + 1}-{count} not read]"
|
|
136
|
+
return text
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_pdf(data):
|
|
140
|
+
"""Indexable text of a PDF through run_pdf with the indexer's deadline and caps.
|
|
141
|
+
|
|
142
|
+
Parameters: data (PDF bytes). Returns: UTF-8 bytes of the marked text. Raises: DocumentError with a readable reason
|
|
143
|
+
for a timeout, a password, a corrupt file or a PDF without a text layer (scanned; there is no OCR)."""
|
|
144
|
+
try:
|
|
145
|
+
text = run_pdf(data, PDF_TIMEOUT_S, PDF_MAX_PAGES, PDF_MAX_CHARS)
|
|
146
|
+
except PdfUnreadable as exc:
|
|
147
|
+
raise PdfUnreadable(f"PDF could not be read ({exc.detail})"[:200], exc.detail) from None
|
|
148
|
+
if "[page " not in text:
|
|
149
|
+
raise DocumentError("PDF has no text layer (scanned pages are not read).")
|
|
150
|
+
return (text + "\n").encode("utf-8", "replace")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_extracted(path):
|
|
154
|
+
"""Whether the file type goes through a text extractor before indexing.
|
|
155
|
+
|
|
156
|
+
Parameters: path (any file name or path). Returns: True for `.docx` and `.pdf`, case-insensitive."""
|
|
157
|
+
return str(path).lower().endswith(('.docx', '.pdf'))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def size_limit(path):
|
|
161
|
+
"""Largest file size, in bytes, the extractor of this type accepts.
|
|
162
|
+
|
|
163
|
+
Parameters: path of an extracted type (see is_extracted). Returns: the byte limit of its extractor."""
|
|
164
|
+
return PDF_MAX_BYTES if str(path).lower().endswith('.pdf') else MAX_ARCHIVE_BYTES
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def extract(path, data):
|
|
168
|
+
"""Text of an extracted-type file.
|
|
169
|
+
|
|
170
|
+
Parameters: path (decides the type, see is_extracted), data (file bytes). Returns: UTF-8 text bytes. Raises:
|
|
171
|
+
DocumentError with a readable reason."""
|
|
172
|
+
return extract_pdf(data) if str(path).lower().endswith('.pdf') else extract_docx(data)
|
|
173
|
+
|
|
174
|
+
|
|
68
175
|
def location_kind(path):
|
|
69
|
-
|
|
176
|
+
"""Kind of line numbers a result of this file carries.
|
|
177
|
+
|
|
178
|
+
Parameters: path. Returns: `extracted_text_line` (lines of the extracted text, not of the file) for extracted
|
|
179
|
+
types, else `source_line`."""
|
|
180
|
+
return 'extracted_text_line' if is_extracted(path) else 'source_line'
|
package/dossier.py
CHANGED
|
@@ -16,7 +16,7 @@ TITLE_CHARS = 80
|
|
|
16
16
|
MAX_FILE_BYTES = 512 * 1024
|
|
17
17
|
BACKLOG_FILES = ("docs/BACKLOG.md", "BACKLOG.md")
|
|
18
18
|
INSTRUCTION_FILES = (("CLAUDE.md", "claude_md"), (".claude/CLAUDE.md", "claude_md"), ("AGENTS.md", "agents_md"))
|
|
19
|
-
_WORD = re.compile(r"[\w]{
|
|
19
|
+
_WORD = re.compile(r"[\w]{2,}", re.UNICODE)
|
|
20
20
|
_STOPWORDS = {
|
|
21
21
|
"the", "and", "for", "how", "what", "where", "which", "does", "with", "from", "that", "this", "are", "when", "who",
|
|
22
22
|
"why", "into", "used", "use", "uses", "its", "has", "have", "code", "project", "file", "files",
|
|
@@ -24,13 +24,15 @@ _STOPWORDS = {
|
|
|
24
24
|
"pela", "ele", "ela", "isso", "esse", "essa", "este", "esta", "fica", "ficam", "usa", "usado", "usada", "sobre",
|
|
25
25
|
"entre", "depois", "antes", "quando", "cada", "seu", "sua", "seus", "suas", "não", "sem", "mais", "código",
|
|
26
26
|
"projeto", "arquivo", "arquivos", "decide", "funciona",
|
|
27
|
+
"is", "in", "to", "of", "on", "at", "by", "an", "as", "it", "or", "be", "do", "if", "so", "we", "no", "up", "me", "my",
|
|
28
|
+
"he", "us", "am", "de", "da", "em", "na", "os", "um", "se", "ao", "ou", "eu", "te", "é", "já", "só",
|
|
27
29
|
}
|
|
28
30
|
_UNIT_START = re.compile(r"#{1,6}\s|[-*]\s")
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def focus_terms(*texts):
|
|
32
|
-
"""Casefolded content words of the texts (
|
|
33
|
-
order: what a window is scored by."""
|
|
34
|
+
"""Casefolded content words of the texts (2+ characters, common question and function words dropped), camelCase
|
|
35
|
+
split, in order: what a window is scored by."""
|
|
34
36
|
found = []
|
|
35
37
|
for text in texts:
|
|
36
38
|
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", text or "")
|
package/freshness.py
CHANGED
|
@@ -62,44 +62,83 @@ def npm_registry_evidence(package):
|
|
|
62
62
|
try:
|
|
63
63
|
with urllib.request.urlopen(request, timeout=7) as response:
|
|
64
64
|
data = json.load(response)
|
|
65
|
-
|
|
66
|
-
if not isinstance(
|
|
65
|
+
latest = data.get("version") if isinstance(data, dict) else None
|
|
66
|
+
if not isinstance(latest, str) or not latest or len(latest) > 80:
|
|
67
67
|
return None
|
|
68
68
|
return {"title": f"npm registry: {package}", "url": url,
|
|
69
|
-
"snippet": f"Latest version published on the npm registry: {
|
|
70
|
-
"tier": "npm_registry", "npm_package": package, "npm_version":
|
|
69
|
+
"snippet": f"Latest version published on the npm registry: {latest}",
|
|
70
|
+
"tier": "npm_registry", "npm_package": package, "npm_version": latest}
|
|
71
71
|
except Exception:
|
|
72
72
|
return None
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
NPM_NAME_RE = re.compile(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _osv_query(package, package_version):
|
|
79
|
+
"""One OSV query object: the package (npm) and the version asked about."""
|
|
80
|
+
return {"package": {"name": package, "ecosystem": "npm"}, "version": package_version}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _osv_request(endpoint, payload):
|
|
84
|
+
"""The POST request to an OSV endpoint (`query` or `querybatch`) with the product's headers and User-Agent."""
|
|
85
|
+
return urllib.request.Request(
|
|
86
|
+
"https://api.osv.dev/v1/" + endpoint, data=json.dumps(payload).encode("utf-8"), method="POST",
|
|
84
87
|
headers={"Content-Type": "application/json", "Accept": "application/json",
|
|
85
88
|
"User-Agent": version.user_agent(config.load_config().get("contact"))},
|
|
86
89
|
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _osv_ids(data):
|
|
93
|
+
"""Sorted advisory IDs of one OSV result object; None when it is incomplete (next_page_token) or malformed, since
|
|
94
|
+
neither confirms the absence of a record."""
|
|
95
|
+
if isinstance(data, dict) and data.get("next_page_token"):
|
|
96
|
+
return None # Página incompleta não confirma ausência de aviso novo.
|
|
97
|
+
rows = data.get("vulns", []) if isinstance(data, dict) else None
|
|
98
|
+
if not isinstance(rows, list):
|
|
99
|
+
return None
|
|
100
|
+
ids = [row.get("id") for row in rows if isinstance(row, dict)]
|
|
101
|
+
if any(not isinstance(advisory, str) or not advisory for advisory in ids):
|
|
102
|
+
return None
|
|
103
|
+
return sorted(set(ids))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def npm_advisories(package, package_version):
|
|
107
|
+
"""IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
|
|
108
|
+
if not package or not package_version or not NPM_NAME_RE.fullmatch(package):
|
|
109
|
+
return None
|
|
110
|
+
request = _osv_request("query", _osv_query(package, package_version))
|
|
87
111
|
try:
|
|
88
112
|
with urllib.request.urlopen(request, timeout=7) as response:
|
|
89
|
-
|
|
90
|
-
if isinstance(data, dict) and data.get("next_page_token"):
|
|
91
|
-
return None # Página incompleta não confirma ausência de aviso novo.
|
|
92
|
-
rows = data.get("vulns", []) if isinstance(data, dict) else None
|
|
93
|
-
if not isinstance(rows, list):
|
|
94
|
-
return None
|
|
95
|
-
ids = [row.get("id") for row in rows if isinstance(row, dict)]
|
|
96
|
-
if any(not isinstance(advisory, str) or not advisory for advisory in ids):
|
|
97
|
-
return None
|
|
98
|
-
return sorted(set(ids))
|
|
113
|
+
return _osv_ids(json.load(response))
|
|
99
114
|
except Exception:
|
|
100
115
|
return None
|
|
101
116
|
|
|
102
117
|
|
|
118
|
+
def npm_advisories_batch(packages, timeout):
|
|
119
|
+
"""Advisory IDs for each (package, version) of `packages` from one OSV `querybatch` call.
|
|
120
|
+
|
|
121
|
+
Parameters: packages (list of (name, version)); timeout (float) seconds for the request.
|
|
122
|
+
Returns: list in the same order, each the sorted ID list or None when that package's answer is incomplete
|
|
123
|
+
(next_page_token) or malformed, so it is unverified and not clean.
|
|
124
|
+
Raises: RuntimeError when the service cannot be reached, answers badly or answers a different number of results.
|
|
125
|
+
"""
|
|
126
|
+
if any(not NPM_NAME_RE.fullmatch(name) or not package_version for name, package_version in packages):
|
|
127
|
+
raise ValueError(f"Invalid npm package query: {packages!r}")
|
|
128
|
+
request = _osv_request("querybatch", {"queries": [_osv_query(name, package_version)
|
|
129
|
+
for name, package_version in packages]})
|
|
130
|
+
try:
|
|
131
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
132
|
+
data = json.load(response)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise RuntimeError(f"OSV querybatch failed: {exc}") from exc
|
|
135
|
+
results = data.get("results") if isinstance(data, dict) else None
|
|
136
|
+
if not isinstance(results, list) or len(results) != len(packages):
|
|
137
|
+
raise RuntimeError(f"OSV querybatch answered {len(results) if isinstance(results, list) else 'no'} results "
|
|
138
|
+
f"for {len(packages)} queries")
|
|
139
|
+
return [_osv_ids(result) for result in results]
|
|
140
|
+
|
|
141
|
+
|
|
103
142
|
def npm_osv_evidence(package, version):
|
|
104
143
|
ids = npm_advisories(package, version)
|
|
105
144
|
if ids is None:
|
package/hook_decision.py
CHANGED
|
@@ -12,9 +12,11 @@ chamada não dá para montar. Nenhuma decisão aqui é sobre periculosidade, só
|
|
|
12
12
|
"""
|
|
13
13
|
import os
|
|
14
14
|
|
|
15
|
+
import affected_tests
|
|
15
16
|
import code_graph
|
|
16
17
|
import code_impact
|
|
17
18
|
import config
|
|
19
|
+
import dependency_notice
|
|
18
20
|
import doc_check
|
|
19
21
|
import duplicates
|
|
20
22
|
import edit_preview
|
|
@@ -182,7 +184,16 @@ def _impact_line(root, rel, name):
|
|
|
182
184
|
counts = found["counts"]
|
|
183
185
|
tests = ", ".join(f"{t['function']} ({t['path']})" for t in found["tests"])
|
|
184
186
|
calls = f"; tests calling it: {tests}" if tests else ""
|
|
185
|
-
return f" {counts['callers']} caller(s){calls}; project_manage affected_tests
|
|
187
|
+
return (f" {counts['callers']} caller(s){calls}{_run_command(root, found['tests'])}; project_manage affected_tests "
|
|
188
|
+
"lists every test to run after the edit.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_command(root, tests):
|
|
192
|
+
"""`; run them: <command>` for the test files of `tests`, one command per runner and folder, or "" when no runner
|
|
193
|
+
is known for them."""
|
|
194
|
+
commands = affected_tests._commands(root, sorted({t["path"] for t in tests})) if tests else []
|
|
195
|
+
runnable = [f"cd {c['cwd']} && {c['command']}" if c["cwd"] != "." else c["command"] for c in commands if c["command"]]
|
|
196
|
+
return f"; run them: {' ; '.join(runnable)}" if runnable else ""
|
|
186
197
|
|
|
187
198
|
|
|
188
199
|
def _doc_lines(findings):
|
|
@@ -215,19 +226,35 @@ def _duplicate_lines(findings):
|
|
|
215
226
|
+ " If the copy is intentional (moving the function, a deliberate fork), go ahead.")
|
|
216
227
|
|
|
217
228
|
|
|
218
|
-
def _edit_review(payload, doc_mode, duplicate_mode):
|
|
229
|
+
def _edit_review(payload, doc_mode, duplicate_mode, dependency_mode):
|
|
219
230
|
"""Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
|
|
220
231
|
docs and files outside a project are left out, as in the docs coverage): missing ones deny the edit in require mode and become a reminder in remind mode; a
|
|
221
232
|
documented function whose signature changed always gets a reminder. Copies of indexed functions follow
|
|
222
|
-
duplicate_mode and only add context, never deny.
|
|
233
|
+
duplicate_mode and only add context, never deny. An edit of a package.json follows dependency_mode: advisories
|
|
234
|
+
of the added or changed dependencies are added as context, never a denial."""
|
|
223
235
|
with code_graph.pending_scope():
|
|
224
|
-
return _edit_review_scoped(payload, doc_mode, duplicate_mode)
|
|
236
|
+
return _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode)
|
|
225
237
|
|
|
226
238
|
|
|
227
|
-
def
|
|
239
|
+
def _dependency_part(before, after):
|
|
240
|
+
"""The dependency notice of one package.json edit, or the "dependency check skipped" line when it cannot run.
|
|
241
|
+
|
|
242
|
+
Parameters: before (str or None) text before the edit; after (str) text after it.
|
|
243
|
+
Returns: str, empty when there is nothing to say.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
return dependency_notice.notice(before, after)
|
|
247
|
+
except dependency_notice.DependencyCheckError as exc:
|
|
248
|
+
return f"{dependency_notice.PREFIX}dependency check skipped: {exc}"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode):
|
|
228
252
|
"""`_edit_review` inside one `code_graph.pending_scope`, so the pending-changes scan runs once per review."""
|
|
229
|
-
doc_findings, duplicate_findings, failures = [], [], []
|
|
253
|
+
doc_findings, duplicate_findings, failures, dependency_parts = [], [], [], []
|
|
230
254
|
for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
|
|
255
|
+
if dependency_mode != "off" and os.path.basename(path) == "package.json":
|
|
256
|
+
dependency_parts += [part for part in (_dependency_part(before, after),) if part]
|
|
257
|
+
continue
|
|
231
258
|
if not doc_check.supported(path):
|
|
232
259
|
continue
|
|
233
260
|
root = _project_of(path)
|
|
@@ -242,7 +269,7 @@ def _edit_review_scoped(payload, doc_mode, duplicate_mode):
|
|
|
242
269
|
except (ValueError, RuntimeError) as exc:
|
|
243
270
|
failures.append(f"Smart Tool duplicates check skipped: {exc}")
|
|
244
271
|
parts = ([_doc_lines(doc_findings)] if doc_findings else []) + \
|
|
245
|
-
([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures
|
|
272
|
+
([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures + dependency_parts
|
|
246
273
|
if not parts:
|
|
247
274
|
return {}
|
|
248
275
|
message = " ".join(parts)
|
|
@@ -253,16 +280,17 @@ def _edit_review_scoped(payload, doc_mode, duplicate_mode):
|
|
|
253
280
|
|
|
254
281
|
def decide(payload, client, web_route, jobs):
|
|
255
282
|
"""Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
|
|
256
|
-
(warn | off), the other tools follow hook_mode (redirect | advise | off). In redirect mode the Smart Tool call that
|
|
283
|
+
(warn | off) and dependency_mode (warn | off), the other tools follow hook_mode (redirect | advise | off). In redirect mode the Smart Tool call that
|
|
257
284
|
replaces a redirected one starts in `jobs` under the call's tool_use_id (see `_redirect`); only a search whose
|
|
258
285
|
query cannot be built, or a client that sends no tool_use_id, is denied with the exact call to make, and never in a
|
|
259
286
|
subagent whose tool list has no Smart Tool MCP tool: it has nothing to switch to."""
|
|
260
287
|
cfg = config.load_config()
|
|
261
288
|
if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
|
|
262
|
-
doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
|
|
263
|
-
|
|
289
|
+
doc_mode, duplicate_mode, dependency_mode = (config.doc_mode(cfg), config.duplicate_mode(cfg),
|
|
290
|
+
config.dependency_mode(cfg))
|
|
291
|
+
if doc_mode == "off" and duplicate_mode == "off" and dependency_mode == "off":
|
|
264
292
|
return {}
|
|
265
|
-
return _edit_review(payload, doc_mode, duplicate_mode)
|
|
293
|
+
return _edit_review(payload, doc_mode, duplicate_mode, dependency_mode)
|
|
266
294
|
if isinstance(payload, dict) and str(payload.get("tool_name")).startswith(f"mcp__{SMART_TOOL_SERVER}__"):
|
|
267
295
|
if payload["tool_name"] == SMART_SEARCH_TOOL:
|
|
268
296
|
router.CLIENT.set(client)
|
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", "rst", "adoc"}
|
|
14
|
+
PROSE = {"md", "txt", "docx", "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/indexer.py
CHANGED
|
@@ -129,7 +129,7 @@ def read_indexable(root, rel_path):
|
|
|
129
129
|
size = os.path.getsize(full)
|
|
130
130
|
if name.endswith(".svg") and size > MAX_SVG_INDEXABLE_BYTES:
|
|
131
131
|
return None, SKIP_GRAPHIC
|
|
132
|
-
limit = document_text.
|
|
132
|
+
limit = document_text.size_limit(name) if document_text.is_extracted(name) else MAX_INDEXABLE_BYTES
|
|
133
133
|
if size > limit:
|
|
134
134
|
return None, SKIP_TOO_LARGE
|
|
135
135
|
with open(full, "rb") as f:
|
|
@@ -138,9 +138,9 @@ def read_indexable(root, rel_path):
|
|
|
138
138
|
return None, SKIP_UNREADABLE
|
|
139
139
|
if len(data) > limit:
|
|
140
140
|
return None, SKIP_TOO_LARGE
|
|
141
|
-
if
|
|
141
|
+
if document_text.is_extracted(name):
|
|
142
142
|
try:
|
|
143
|
-
data = document_text.
|
|
143
|
+
data = document_text.extract(name, data)
|
|
144
144
|
except document_text.DocumentError as exc:
|
|
145
145
|
return None, str(exc)
|
|
146
146
|
prefix = data[:_SNIFF_BYTES]
|
|
@@ -507,7 +507,7 @@ def _stat_candidate(root, rel):
|
|
|
507
507
|
return None, SKIP_UNREADABLE
|
|
508
508
|
except OSError:
|
|
509
509
|
return None, SKIP_UNREADABLE
|
|
510
|
-
if stat.st_size > (document_text.
|
|
510
|
+
if stat.st_size > (document_text.size_limit(name) if document_text.is_extracted(name) else MAX_INDEXABLE_BYTES):
|
|
511
511
|
return None, SKIP_TOO_LARGE
|
|
512
512
|
if name.endswith(".svg") and stat.st_size > MAX_SVG_INDEXABLE_BYTES:
|
|
513
513
|
return None, SKIP_GRAPHIC
|
|
@@ -523,7 +523,7 @@ def _stable_read(root, rel):
|
|
|
523
523
|
if data is None:
|
|
524
524
|
return None, encoding, before
|
|
525
525
|
after, _ = _stat_candidate(root, rel)
|
|
526
|
-
if after == before and (
|
|
526
|
+
if after == before and (document_text.is_extracted(rel) or len(data) == before[1]):
|
|
527
527
|
return data, encoding, before
|
|
528
528
|
return None, "file changed while reading", None
|
|
529
529
|
|
|
@@ -621,7 +621,7 @@ def _reindex(root, included_paths, embed_batch, chunk_lines=CHUNK_LINES, on_prog
|
|
|
621
621
|
continue
|
|
622
622
|
observed[rel] = stat
|
|
623
623
|
force_file = any(rel.casefold() == p or rel.casefold().startswith(p + "/") for p in forced)
|
|
624
|
-
extractor_changed =
|
|
624
|
+
extractor_changed = document_text.is_extracted(rel) and meta.get('document_extractor_version') != document_text.VERSION
|
|
625
625
|
if (full_check or building or force_file or extractor_changed or old_state.get(rel) != stat):
|
|
626
626
|
candidates.append(rel)
|
|
627
627
|
else:
|
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.40",
|
|
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
|
@@ -12,6 +12,7 @@ Market pattern copied (docs/research/2026-10-24-scout-modelo-leve-no-smart-tool.
|
|
|
12
12
|
excerpts, never an agent prompt, so no agent context is reopened per call.
|
|
13
13
|
"""
|
|
14
14
|
import os
|
|
15
|
+
import re
|
|
15
16
|
|
|
16
17
|
import document_text
|
|
17
18
|
import index_scope
|
|
@@ -35,6 +36,8 @@ PLAN_MAX_FOLDERS = 6
|
|
|
35
36
|
OUTLINE_MAX_DIRS = 300
|
|
36
37
|
OUTLINE_MAX_ROOT_FILES = 200
|
|
37
38
|
_FIELDS = {"project_root", "question", "terms", "max_files"}
|
|
39
|
+
DECISION_NOTES_MAX = 3
|
|
40
|
+
_INTENT = re.compile(r"\b(why|por\s*qu[êe]|rationale|reason|motivo|raz[ãa]o|decid\w*|decis[ãa]o)\b", re.IGNORECASE)
|
|
38
41
|
|
|
39
42
|
PLAN_SYSTEM = (
|
|
40
43
|
"You plan a keyword scan of a code project that has no search index yet. Given a question and the project's "
|
|
@@ -91,9 +94,15 @@ def arguments(raw):
|
|
|
91
94
|
return {"project_root": root, "question": question.strip(), "terms": (terms or "").strip(), "max_files": max_files}
|
|
92
95
|
|
|
93
96
|
|
|
97
|
+
def is_intent_question(question):
|
|
98
|
+
"""The question asks why something is as it is (why, reason, rationale, decided; por que, motivo, decisão): the
|
|
99
|
+
answer may sit in the notes the project keeps about its own intent, not in the code."""
|
|
100
|
+
return bool(_INTENT.search(question or ""))
|
|
101
|
+
|
|
102
|
+
|
|
94
103
|
def disk_lines(root, path, start, end):
|
|
95
104
|
"""`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
|
|
96
|
-
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx)."""
|
|
105
|
+
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf)."""
|
|
97
106
|
if document_text.location_kind(path) != "source_line":
|
|
98
107
|
return None
|
|
99
108
|
data, encoding = indexer.read_indexable(root, path)
|
|
@@ -250,12 +259,13 @@ def valid_answer(raw, items):
|
|
|
250
259
|
return raw["found"], answer, citations
|
|
251
260
|
|
|
252
261
|
|
|
253
|
-
def render(question, source, items, citations=None, answer=None, found=None, model_note="", warnings=(), others=()
|
|
262
|
+
def render(question, source, items, citations=None, answer=None, found=None, model_note="", warnings=(), others=(),
|
|
263
|
+
notes=()):
|
|
254
264
|
"""The text returned to the agent. Lexical mode (`citations` None): every evidence window, within
|
|
255
265
|
LITERAL_BUDGET_CHARS, after the model's answer when there is one (a model that cited nothing never leaves the
|
|
256
266
|
answer without lines). Model mode: the model's answer, then only the cited ranges (sliced from the windows read
|
|
257
267
|
from disk), and the uncited excerpts as `path:lines` pointers. `others` are candidates not shown, listed as
|
|
258
|
-
pointers."""
|
|
268
|
+
pointers. `notes` are the dossier hits for a question about intent: ids with one excerpt each, after the evidence."""
|
|
259
269
|
out = [f"question: {question}", f"source: {source}", f"model: {model_note or 'none (literal lines only)'}"]
|
|
260
270
|
if answer is not None:
|
|
261
271
|
out.append(("answer: " if found else "answer (not found in the excerpts): ") + answer)
|
|
@@ -280,6 +290,9 @@ def render(question, source, items, citations=None, answer=None, found=None, mod
|
|
|
280
290
|
and citations is not None] + list(others)
|
|
281
291
|
if pointers:
|
|
282
292
|
out.append("other candidates (not shown): " + ", ".join(pointers))
|
|
293
|
+
if notes:
|
|
294
|
+
out.append("decision notes (project notes, not code):")
|
|
295
|
+
out += [f"- [{note['id']}] {note['source']}: {note['title']}: {note['snippet']}" for note in notes]
|
|
283
296
|
for warning in warnings:
|
|
284
297
|
out.append(f"warning: {warning}")
|
|
285
298
|
return "\n".join(out) + "\n"
|
package/setup_audit.py
CHANGED
|
@@ -865,13 +865,55 @@ def _save_locked(report):
|
|
|
865
865
|
atomic_io.write_secret_text(last_path, json.dumps(report, indent=1, ensure_ascii=False))
|
|
866
866
|
changes = report.get("changes") or {}
|
|
867
867
|
data = index()
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
868
|
+
key = index_key(report["project"])
|
|
869
|
+
entry = {"project": report["project"], "summary": report["summary"], "new": len(changes.get("new", [])),
|
|
870
|
+
"resolved": len(changes.get("resolved", [])), "checked_at": report["checked_at"],
|
|
871
|
+
"trigger": report["trigger"], "snapshot_key": report["snapshot_key"],
|
|
872
|
+
"pending": _pending_notice(data.get(key), report)}
|
|
873
|
+
data[key] = entry
|
|
872
874
|
_write_index(data)
|
|
873
875
|
|
|
874
876
|
|
|
877
|
+
def _pending_notice(previous, report):
|
|
878
|
+
"""The new errors and warnings of a scheduled run not yet told to the user, added to those a former run left
|
|
879
|
+
unannounced (by finding id). A manual run adds none: the user is looking at its result."""
|
|
880
|
+
pending = list((previous or {}).get("pending") or [])
|
|
881
|
+
if report["trigger"] == "schedule":
|
|
882
|
+
known = {item["id"] for item in pending}
|
|
883
|
+
pending += [{"id": f["id"], "severity": f["severity"], "check": f["check"], "subject": f["subject"]}
|
|
884
|
+
for f in report["findings"]
|
|
885
|
+
if f.get("new") and f["severity"] in ("error", "warn") and f["id"] not in known]
|
|
886
|
+
return pending
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def notices_due():
|
|
890
|
+
"""[{project, errors, warnings, items}] for the projects whose scheduled audits found new errors or warnings since
|
|
891
|
+
the last call; marks them announced, so each finding is told once (the tray toast)."""
|
|
892
|
+
with _INDEX_LOCK:
|
|
893
|
+
data = index()
|
|
894
|
+
due = []
|
|
895
|
+
for entry in data.values():
|
|
896
|
+
pending = entry.get("pending") or []
|
|
897
|
+
if pending:
|
|
898
|
+
due.append({"project": entry["project"], "errors": sum(i["severity"] == "error" for i in pending),
|
|
899
|
+
"warnings": sum(i["severity"] == "warn" for i in pending), "items": pending})
|
|
900
|
+
entry["pending"] = []
|
|
901
|
+
if due:
|
|
902
|
+
_write_index(data)
|
|
903
|
+
return due
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def toast_text(row):
|
|
907
|
+
"""(title, lines) of the toast for one `notices_due` row."""
|
|
908
|
+
total = row["errors"] + row["warnings"]
|
|
909
|
+
title = f"Setup audit: {total} new problem{'s' if total != 1 else ''} in the agent configuration"
|
|
910
|
+
lines = [os.path.basename(row["project"].rstrip("\\/")) or row["project"]]
|
|
911
|
+
lines += [f"{item['severity']}: {item['check']} - {item['subject']}"[:120] for item in row["items"][:3]]
|
|
912
|
+
if len(row["items"]) > 3:
|
|
913
|
+
lines.append(f"and {len(row['items']) - 3} more")
|
|
914
|
+
return title, lines
|
|
915
|
+
|
|
916
|
+
|
|
875
917
|
class Scheduler:
|
|
876
918
|
"""Periodic mode: audits every registered project whose folder exists, FIRST_RUN_DELAY_S after start and then
|
|
877
919
|
every SCHEDULE_S, one project at a time, reusing MCP probes younger than PROBE_MAX_AGE_S. projects() returns the
|
package/setup_ui.py
CHANGED
|
@@ -201,11 +201,13 @@ def handle_health_get():
|
|
|
201
201
|
|
|
202
202
|
|
|
203
203
|
def handle_notices():
|
|
204
|
-
"""What the tray should show now: the model offer (once a day until answered)
|
|
205
|
-
(once a day each)
|
|
204
|
+
"""What the tray should show now: the model offer (once a day until answered), models over the failure limit
|
|
205
|
+
(once a day each) and the new errors or warnings of the scheduled setup audit (once per finding). Fetching marks
|
|
206
|
+
them announced, so each toast is shown once per reminder period."""
|
|
206
207
|
upgrade = _checked_upgrade()
|
|
207
208
|
offer = model_upgrade.due() if upgrade["state"] == "pending" else None
|
|
208
|
-
return {"upgrade": offer, "failing": model_health.alerts_due(), "
|
|
209
|
+
return {"upgrade": offer, "failing": model_health.alerts_due(), "audit": setup_audit.notices_due(),
|
|
210
|
+
"check_error": upgrade["check_error"]}
|
|
209
211
|
|
|
210
212
|
|
|
211
213
|
def handle_local_models_get():
|
package/smart_tool_daemon.py
CHANGED
|
@@ -529,7 +529,7 @@ TOOLS = [
|
|
|
529
529
|
"description": (
|
|
530
530
|
"Reads a known URL and returns only the answer to the prompt, written by the gateway's mini model - use instead of "
|
|
531
531
|
"the native WebFetch. The page is cached for 24 h: another prompt on the same URL does not download it again. "
|
|
532
|
-
"JavaScript-built pages are rendered in the browser.
|
|
532
|
+
"JavaScript-built pages are rendered in the browser. Reads PDFs as text, page by page; does not read localhost or internal networks."
|
|
533
533
|
),
|
|
534
534
|
"inputSchema": {
|
|
535
535
|
"type": "object",
|
|
@@ -1706,6 +1706,11 @@ def _handle_smart_scout(arguments):
|
|
|
1706
1706
|
others = list(dict.fromkeys(f"{row['path']}:{row['lines']}" for row in rows
|
|
1707
1707
|
if row["path"] not in shown))[:SCOUT_MAX_POINTERS]
|
|
1708
1708
|
source = _SCOUT_SOURCES[found["source"]]
|
|
1709
|
+
notes = []
|
|
1710
|
+
if scout.is_intent_question(question):
|
|
1711
|
+
dossier_answer = dossier.query(root, query, max_hits=scout.DECISION_NOTES_MAX)
|
|
1712
|
+
notes = dossier_answer["hits"]
|
|
1713
|
+
warnings += [f"decision notes: {reason}" for reason in dossier_answer["reasons"]]
|
|
1709
1714
|
text = None
|
|
1710
1715
|
if model and items:
|
|
1711
1716
|
calls += 1
|
|
@@ -1716,7 +1721,8 @@ def _handle_smart_scout(arguments):
|
|
|
1716
1721
|
found_answer, answer, citations = scout.valid_answer(raw, items)
|
|
1717
1722
|
note = f"{model} ({calls} call{'s' if calls > 1 else ''})"
|
|
1718
1723
|
if citations:
|
|
1719
|
-
text = scout.render(question, source, items, citations, answer, found_answer, note, warnings, others
|
|
1724
|
+
text = scout.render(question, source, items, citations, answer, found_answer, note, warnings, others,
|
|
1725
|
+
notes)
|
|
1720
1726
|
else:
|
|
1721
1727
|
shown_items = scout.evidence(root, rows, terms, max_files,
|
|
1722
1728
|
scout.LEXICAL_WINDOW_LINES)[:scout.UNCITED_WINDOWS]
|
|
@@ -1724,14 +1730,14 @@ def _handle_smart_scout(arguments):
|
|
|
1724
1730
|
if item["path"] not in {kept["path"] for kept in shown_items}]
|
|
1725
1731
|
text = scout.render(question, source, shown_items, None, answer, found_answer, note,
|
|
1726
1732
|
warnings + ["the scout_model cited no lines; the best keyword windows are shown "
|
|
1727
|
-
"instead."], rest + others)
|
|
1733
|
+
"instead."], rest + others, notes)
|
|
1728
1734
|
except Exception as exc:
|
|
1729
1735
|
warnings.append(f"scout_model {model} failed writing the answer ({_project_error(exc)}): literal lines "
|
|
1730
1736
|
"only, no answer.")
|
|
1731
1737
|
failure = "answer"
|
|
1732
1738
|
items = scout.evidence(root, rows, terms, max_files, scout.LEXICAL_WINDOW_LINES)
|
|
1733
1739
|
if text is None:
|
|
1734
|
-
text = scout.render(question, source, items, warnings=warnings, others=others)
|
|
1740
|
+
text = scout.render(question, source, items, warnings=warnings, others=others, notes=notes)
|
|
1735
1741
|
_metric("scout", source=found["source"], model=bool(model) and failure is None, model_calls=calls, failure=failure,
|
|
1736
1742
|
files=len({item["path"] for item in items}), chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
|
|
1737
1743
|
return _sanitize_text(text)
|
package/tray.py
CHANGED
|
@@ -22,6 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
22
22
|
import autostart
|
|
23
23
|
import config
|
|
24
24
|
import daemon_launcher
|
|
25
|
+
import setup_audit
|
|
25
26
|
import toast_notice
|
|
26
27
|
import update_check
|
|
27
28
|
import version
|
|
@@ -114,7 +115,8 @@ def _poll_loop(icon):
|
|
|
114
115
|
|
|
115
116
|
|
|
116
117
|
def _show_notices(icon, notices):
|
|
117
|
-
"""Shows a toast for each pending notice: the new-default-models offer
|
|
118
|
+
"""Shows a toast for each pending notice: the new-default-models offer, each model over the failure limit and each
|
|
119
|
+
project whose scheduled setup audit found new errors or warnings."""
|
|
118
120
|
offer = notices.get("upgrade")
|
|
119
121
|
if offer:
|
|
120
122
|
lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
|
|
@@ -129,6 +131,10 @@ def _show_notices(icon, notices):
|
|
|
129
131
|
f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
|
|
130
132
|
[("Switch model", toast_notice.action_uri("open-setup", section="models")),
|
|
131
133
|
("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
|
|
134
|
+
for row in notices.get("audit") or []:
|
|
135
|
+
title, lines = setup_audit.toast_text(row)
|
|
136
|
+
toast_notice.show(title, lines, [("Open the audit", toast_notice.action_uri("open-setup", section="audit"))],
|
|
137
|
+
tag="setup-audit:" + row["project"])
|
|
132
138
|
|
|
133
139
|
|
|
134
140
|
def _notice_loop(icon):
|
package/version.py
CHANGED
package/web_fetch.py
CHANGED
|
@@ -20,14 +20,13 @@ import ipaddress
|
|
|
20
20
|
import json
|
|
21
21
|
import socket
|
|
22
22
|
import ssl
|
|
23
|
-
import subprocess
|
|
24
|
-
import sys
|
|
25
23
|
import threading
|
|
26
24
|
import time
|
|
27
25
|
import urllib.parse
|
|
28
26
|
|
|
29
27
|
import trafilatura
|
|
30
28
|
|
|
29
|
+
import document_text
|
|
31
30
|
import model_client
|
|
32
31
|
import research_cache
|
|
33
32
|
|
|
@@ -212,46 +211,19 @@ def thin(text):
|
|
|
212
211
|
return len(stripped) < PLACEHOLDER_SCAN_CHARS and any(p in stripped[:600].lower() for p in PLACEHOLDERS)
|
|
213
212
|
|
|
214
213
|
|
|
215
|
-
PDF_SCRIPT = """
|
|
216
|
-
import io, json, sys
|
|
217
|
-
import pypdf
|
|
218
|
-
reader = pypdf.PdfReader(io.BytesIO(sys.stdin.buffer.read()))
|
|
219
|
-
if reader.is_encrypted and reader.decrypt("") == pypdf.PasswordType.NOT_DECRYPTED:
|
|
220
|
-
print(json.dumps({"encrypted": True}))
|
|
221
|
-
sys.exit()
|
|
222
|
-
limit, budget = int(sys.argv[1]), int(sys.argv[2])
|
|
223
|
-
pages, size = [], 0
|
|
224
|
-
for page in reader.pages[:limit]:
|
|
225
|
-
pages.append((page.extract_text() or "").strip()[:budget - size])
|
|
226
|
-
size += len(pages[-1])
|
|
227
|
-
if size >= budget:
|
|
228
|
-
break
|
|
229
|
-
print(json.dumps({"pages": pages, "count": len(reader.pages)}))
|
|
230
|
-
"""
|
|
231
|
-
|
|
232
|
-
|
|
233
214
|
def pdf_text(body, url, deadline):
|
|
234
215
|
"""Text of a PDF (pypdf, pure Python), page by page up to PDF_MAX_PAGES. The native WebFetch reads PDFs and the hook
|
|
235
216
|
sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts). A hostile PDF can make pypdf
|
|
236
217
|
parse for minutes, so it runs in a subprocess killed at the request's deadline (at most PDF_TIMEOUT_S)."""
|
|
237
218
|
timeout = min(PDF_TIMEOUT_S, _left(deadline))
|
|
238
219
|
try:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
242
|
-
except subprocess.TimeoutExpired:
|
|
220
|
+
return document_text.run_pdf(body, timeout, PDF_MAX_PAGES, PAGE_STORE_MAX_CHARS)
|
|
221
|
+
except document_text.PdfTimeout:
|
|
243
222
|
raise FetchError(f"PDF not read in {timeout:.0f}s ({url}).") from None
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if result.get("encrypted"):
|
|
249
|
-
raise FetchError(f"PDF protected by a password is not read by web_fetch ({url}).")
|
|
250
|
-
pages = result["pages"]
|
|
251
|
-
text = "\n\n".join(f"[page {number}]\n{page}" for number, page in enumerate(pages, 1) if page)
|
|
252
|
-
if result["count"] > len(pages):
|
|
253
|
-
text += f"\n\n[pages {len(pages) + 1}-{result['count']} not read]"
|
|
254
|
-
return text
|
|
223
|
+
except document_text.PdfUnreadable as exc:
|
|
224
|
+
raise FetchError(f"PDF could not be read ({url}): {exc.detail}"[:300]) from None
|
|
225
|
+
except document_text.PdfEncrypted:
|
|
226
|
+
raise FetchError(f"PDF protected by a password is not read by web_fetch ({url}).") from None
|
|
255
227
|
|
|
256
228
|
|
|
257
229
|
def read_page(url, deadline, render):
|