@allansantos-dev/smart-tool 0.9.28 → 0.9.31
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 +74 -0
- package/README.md +10 -1
- package/bin/network.mjs +78 -0
- package/bin/smart-tool.mjs +33 -10
- package/config.py +1 -0
- package/endpoint_sync.py +92 -9
- package/install_runtime.py +2 -1
- package/model_client.py +25 -2
- package/model_defaults.py +9 -5
- package/package.json +1 -1
- package/scout.py +307 -0
- package/setup_ui.py +6 -1
- package/smart_tool_daemon.py +183 -4
- package/usage_meter.py +1 -1
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -1
- package/web_adapters/browser/requirements.txt +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.31 - beta
|
|
4
|
+
|
|
5
|
+
- `scout_model` is filled by default. The setup screen (and the daemon, when it checks the models) now fills an empty
|
|
6
|
+
`scout_model` with Claude Haiku 5.5 when the configured gateway has it; a choice you made is never replaced, and a
|
|
7
|
+
gateway without that model leaves the field empty to choose (the scout then returns the literal lines, as before).
|
|
8
|
+
- The other recommended models were re-measured by the same criterion (correctness first, price second; 3 rounds per
|
|
9
|
+
model; docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md) and changed for new fills: `router_model`
|
|
10
|
+
Claude Haiku 5.5 (was GPT-4o-mini: it invented an answer to every request whose answer was not on the page),
|
|
11
|
+
`scope_model` GPT-5.4 nano (same accuracy as GPT-5-mini at 29% of the price), `research_model` GPT-5.4 nano (was
|
|
12
|
+
GPT-OSS 20B, which failed tool calls). Models already set are kept.
|
|
13
|
+
- Scout with an index (16 questions x 3 rounds x 15 models): Haiku 5.5 21/36 files found (the index's ceiling, equal
|
|
14
|
+
to no model), 12/12 on the questions with no answer, 0 failures; without an index, 20/36 against 12/36 for the
|
|
15
|
+
keyword scan and at most 17/36 for the other models.
|
|
16
|
+
- 8-question sessions with Sonnet: US$ 0.121 per session with Haiku 5.5 against 0.177 without a model, 73/80 against
|
|
17
|
+
75/80, and the agent never left the tool to read files itself (GPT-4o-mini: 10 times).
|
|
18
|
+
- The client repeats a chat call once without `temperature` when the gateway answers 400 about it (Claude on Bedrock,
|
|
19
|
+
the GPT-5 family) and remembers the model; a second refusal still fails.
|
|
20
|
+
- Tests: `tests/test_model_client.py`; probes `tests/scout_model_probe.py` and `tests/role_model_probe.py`;
|
|
21
|
+
`tests/scout_probe.py --scout-model`.
|
|
22
|
+
|
|
23
|
+
## 0.9.30 - beta
|
|
24
|
+
|
|
25
|
+
- New MCP tool `smart_scout(project_root, question, terms?, max_files?)`: answers a question about a project's code with
|
|
26
|
+
the lines that answer it, read inside Smart Tool so the files never enter the agent's context.
|
|
27
|
+
- Candidates come from the `smart_search` pipeline: the index when it is ready, the index plus a keyword read of the
|
|
28
|
+
files changed after it, or the keyword scan of the disk while the first index is built (answered at once, outside
|
|
29
|
+
the per-folder queue). With no index, no saved scope and no model, the scan covers the whole project (dependency,
|
|
30
|
+
hidden and generated folders excluded) instead of the root files only.
|
|
31
|
+
- For each of up to `max_files` files (default 5, at most 10) it reads the current lines from disk and keeps the
|
|
32
|
+
window with most of the question's words; the agent gets `path:lines`, the functions in it and those lines verbatim.
|
|
33
|
+
- Optional `scout_model` (setup screen, "Code scout"; empty by default): a small model of the configured gateway
|
|
34
|
+
reads the windows, writes up to 5 lines of answer and picks the line ranges to show, which are sliced from what
|
|
35
|
+
was read on disk. Without an index it first chooses the terms and folders to scan (two calls; one with an index).
|
|
36
|
+
A range outside what was read is clipped or dropped; an answer that cites no lines comes with the best keyword
|
|
37
|
+
windows; a model failure returns the lines with the error in a warning. Model calls are metered under the
|
|
38
|
+
purpose `scout` (`project_manage action=usage`).
|
|
39
|
+
- The server instructions point to `smart_scout` for questions that would mean reading several files, and the tool
|
|
40
|
+
loads upfront in Claude Code.
|
|
41
|
+
- Measured in isolated sessions (clean HOME and config, no CLAUDE.md, hooks, skills or plugins; 8 questions per
|
|
42
|
+
session, Sonnet, two repositories; docs/research/2026-10-24-smart-scout-construido-e-medido.md): with an index,
|
|
43
|
+
`smart_scout` with a `scout_model` cost US$ 0.154 per session against US$ 0.250 for `smart_search` alone, 31/32
|
|
44
|
+
right against 29/32, 90 s against 70 s; without a model, US$ 0.203, 29/32, 63 s. On a fresh install (no index)
|
|
45
|
+
it answered in 43 s (no model) and 117 s (model) against 256 s.
|
|
46
|
+
- Tests: `tests/test_scout.py`; probe `tests/scout_probe.py`.
|
|
47
|
+
|
|
48
|
+
## 0.9.29 - beta
|
|
49
|
+
|
|
50
|
+
- Registering the MCP server in Claude Code or Codex is idempotent. `claude mcp add` refuses a name that exists
|
|
51
|
+
("MCP server smart-tool already exists in user"), so a second registration used to fail. Now the same URL runs
|
|
52
|
+
nothing and succeeds, another URL (the port changed) is removed with `claude mcp remove smart-tool --scope user`
|
|
53
|
+
(`codex mcp remove smart-tool`) and added again, and no entry only adds. The result is read back from the client's
|
|
54
|
+
configuration, as before.
|
|
55
|
+
- The client CLI is resolved before it runs and never left hanging:
|
|
56
|
+
- Only `.exe`, `.cmd` and `.bat` are candidates, never a `.ps1` or the extensionless script npm leaves beside its
|
|
57
|
+
shims.
|
|
58
|
+
- A `.exe` that does not start with `MZ`, or an npm shim whose `claude-code` package holds such a file (Claude Code
|
|
59
|
+
installed by npm 11 with its postinstall blocked: a ~500-byte text `claude.exe`, which makes Windows open a modal
|
|
60
|
+
"not compatible" box and hang the command), fails at once with the reason and, for Claude Code,
|
|
61
|
+
`npm install -g @anthropic-ai/claude-code@latest --include=optional --allow-scripts=@anthropic-ai/claude-code`.
|
|
62
|
+
- The limit is 120 s (it was 60) and `SMART_TOOL_CLI_TIMEOUT_S` sets it.
|
|
63
|
+
- The CLI is searched in the PATH as Windows stores it now (machine and user, from the registry), not the one the daemon
|
|
64
|
+
started with, and then in `%USERPROFILE%\.local\bin`, `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin` and `%APPDATA%\npm`.
|
|
65
|
+
- Networks that inspect TLS with a corporate root CA:
|
|
66
|
+
- `npx ... install` shows the real error code (`SELF_SIGNED_CERT_IN_CHAIN`, kept by Node in `error.cause`, not
|
|
67
|
+
"fetch failed") and, on Node 22.15+/23.8+/24, runs itself again once with `NODE_USE_SYSTEM_CA=1`; otherwise it says
|
|
68
|
+
to set `NODE_EXTRA_CA_CERTS`. uv gets one retry with `UV_NATIVE_TLS=1` on a certificate error. The installer steps
|
|
69
|
+
(npm, Playwright) run with `NODE_USE_SYSTEM_CA=1`, and the Camoufox download uses the Windows store through
|
|
70
|
+
`truststore`. `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `UV_NATIVE_TLS` and
|
|
71
|
+
`NODE_USE_SYSTEM_CA` are never overwritten.
|
|
72
|
+
- The bootstrap ends by setting `process.exitCode` instead of calling `process.exit()` while a fetch is closing (Node
|
|
73
|
+
24 on Windows aborts with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`).
|
|
74
|
+
- Tests: `tests/test_cli_registration.py`, `tests/test_bootstrap_network.py`, and cases in `test_fetch_camoufox.py` and
|
|
75
|
+
`test_install_runtime.py`.
|
|
76
|
+
|
|
3
77
|
## 0.9.28 - beta
|
|
4
78
|
|
|
5
79
|
- Node, npx and npm are looked up when they are needed, never remembered. A process keeps the PATH it was born with:
|
package/README.md
CHANGED
|
@@ -5,6 +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
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 |
|
|
9
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`) |
|
|
10
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 |
|
|
@@ -15,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
15
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
16
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
17
18
|
|
|
18
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.31 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
19
20
|
|
|
20
21
|
## How it works
|
|
21
22
|
|
|
@@ -27,6 +28,7 @@ program that runs on your machine and offers the agent better tools for the same
|
|
|
27
28
|
```
|
|
28
29
|
Claude Code / Codex ──MCP──▶ Smart Tool daemon (127.0.0.1, one per machine)
|
|
29
30
|
├─ smart_search ──▶ local index of your project (SQLite)
|
|
31
|
+
├─ smart_scout ───▶ the files behind a search, read on disk (small model optional)
|
|
30
32
|
├─ web_search ───▶ several search engines at once, shared cache
|
|
31
33
|
├─ web_fetch ────▶ the page, read by a small model
|
|
32
34
|
└─ project_manage
|
|
@@ -41,6 +43,13 @@ rerank model, reorders the best candidates. The agent gets back a few chunks spl
|
|
|
41
43
|
the functions most likely to matter, instead of every line that contains a word. The index follows the project:
|
|
42
44
|
only files whose content changed are processed again, and each Git branch keeps its own view.
|
|
43
45
|
|
|
46
|
+
**Reading code to answer a question.** `smart_scout` takes a question in plain language. It finds the candidates the
|
|
47
|
+
same way as `smart_search` (or by keyword on disk while there is no index yet), reads the current lines of each one
|
|
48
|
+
and keeps, per file, the window with most of the question's words. The agent receives `path:lines` and those lines
|
|
49
|
+
verbatim. With a `scout_model` (the setup screen fills it with Claude Haiku 5.5 when the gateway has it), a small model reads those windows, writes a few lines of answer
|
|
50
|
+
and picks which lines to show; without an index it first picks the words and folders to scan (two calls; one with an
|
|
51
|
+
index). What the model writes never replaces the literal lines, and when it fails the lines come back with the error.
|
|
52
|
+
|
|
44
53
|
**Searching the web.** `web_search` asks several free search sources in parallel (DuckDuckGo, Wikipedia, Yahoo,
|
|
45
54
|
Exa, Google and others), in the language of the question and in English, and merges what comes back. Search APIs with a
|
|
46
55
|
free quota (Tavily, Firecrawl) and a real browser are tried only when those results are not enough. Answers are cached and shared by every agent
|
package/bin/network.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Network failures of the bootstrap on machines whose firewall inspects TLS (a corporate root CA in the Windows store,
|
|
2
|
+
// unknown to Node's bundled certificates). Nothing here overwrites a TLS setting the user already made.
|
|
3
|
+
|
|
4
|
+
export class Failure extends Error {}
|
|
5
|
+
|
|
6
|
+
const CERTIFICATE_CODES = new Set([
|
|
7
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
8
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
9
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
10
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
11
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
12
|
+
"CERT_UNTRUSTED",
|
|
13
|
+
"CERT_HAS_EXPIRED",
|
|
14
|
+
]);
|
|
15
|
+
const CERTIFICATE_TEXT = /certificate|UnknownIssuer|invalid peer|self[- ]signed/i;
|
|
16
|
+
|
|
17
|
+
/** The real reason of a failed fetch: Node reports "fetch failed" and keeps the code in `error.cause`. */
|
|
18
|
+
export function errorCode(error) {
|
|
19
|
+
return error?.cause?.code ?? error?.code ?? "unknown";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isCertificateError(error) {
|
|
23
|
+
return CERTIFICATE_CODES.has(errorCode(error));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether this Node can read the system certificate store through NODE_USE_SYSTEM_CA (22.15+, 23.8+). */
|
|
27
|
+
export function supportsSystemCa(version = process.versions.node) {
|
|
28
|
+
const [major, minor] = version.split(".").map(Number);
|
|
29
|
+
return major >= 24 || (major === 23 && minor >= 8) || (major === 22 && minor >= 15);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The environment of a second run that trusts the system certificate store, or null when the user already chose
|
|
33
|
+
* (NODE_USE_SYSTEM_CA set to anything), this is already that second run, or this Node cannot do it. */
|
|
34
|
+
export function systemCaEnvironment(env = process.env, version = process.versions.node) {
|
|
35
|
+
if (env.NODE_USE_SYSTEM_CA !== undefined || env.SMART_TOOL_SYSTEM_CA_RETRY || !supportsSystemCa(version)) return null;
|
|
36
|
+
return { ...env, NODE_USE_SYSTEM_CA: "1", SMART_TOOL_SYSTEM_CA_RETRY: "1" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The environment for retrying uv with the Windows certificate store, or null when UV_NATIVE_TLS is already set. */
|
|
40
|
+
export function nativeTlsEnvironment(env = process.env) {
|
|
41
|
+
return env.UV_NATIVE_TLS !== undefined ? null : { ...env, UV_NATIVE_TLS: "1" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isCertificateText(text) {
|
|
45
|
+
return CERTIFICATE_TEXT.test(text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function certificateAdvice(env = process.env) {
|
|
49
|
+
const configured = ["NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "UV_NATIVE_TLS"].filter((name) => env[name]);
|
|
50
|
+
return "The network seems to inspect TLS with a certificate Node does not trust. Point NODE_EXTRA_CA_CERTS at the " +
|
|
51
|
+
"corporate root certificate (a .pem file), or use Node 22.15+ with NODE_USE_SYSTEM_CA=1 to trust the Windows " +
|
|
52
|
+
"certificate store." + (configured.length ? ` Already set here and left untouched: ${configured.join(", ")}.` : "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function describeFetchError(url, error, env = process.env) {
|
|
56
|
+
const code = errorCode(error);
|
|
57
|
+
const reason = error?.cause?.message ? `${error.message}: ${error.cause.message}` : String(error?.message ?? error);
|
|
58
|
+
return `download of ${url} failed (${code}): ${reason}` + (isCertificateError(error) ? `\n${certificateAdvice(env)}` : "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Downloads url as a Buffer. A certificate failure first runs `rerun(environment)` once, a second run of the whole
|
|
62
|
+
* bootstrap that trusts the system store, and returns { rerun: result } when it did; otherwise it throws Failure with
|
|
63
|
+
* the error code and what to configure. */
|
|
64
|
+
export async function download(url, { fetchFn = fetch, env = process.env, version = process.versions.node, rerun } = {}) {
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
response = await fetchFn(url);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const retry = isCertificateError(error) && rerun ? systemCaEnvironment(env, version) : null;
|
|
70
|
+
if (retry) {
|
|
71
|
+
console.log(`TLS inspection detected (${errorCode(error)}); running again with the Windows certificate store...`);
|
|
72
|
+
return { rerun: rerun(retry) };
|
|
73
|
+
}
|
|
74
|
+
throw new Failure(describeFetchError(url, error, env));
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) throw new Failure(`download of ${url} failed: HTTP ${response.status}`);
|
|
77
|
+
return { body: Buffer.from(await response.arrayBuffer()) };
|
|
78
|
+
}
|
package/bin/smart-tool.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Failure, certificateAdvice, download, isCertificateText, nativeTlsEnvironment } from "./network.mjs";
|
|
8
9
|
|
|
9
10
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
11
|
const RUNTIME_DIR = join(homedir(), ".smart-tool", "runtime");
|
|
@@ -20,8 +21,7 @@ const USAGE = `Usage: npx @allansantos-dev/smart-tool install [--target DIR] [--
|
|
|
20
21
|
Installs or updates Smart Tool for this Windows user. Run it again with @latest to update.`;
|
|
21
22
|
|
|
22
23
|
function fail(message) {
|
|
23
|
-
|
|
24
|
-
process.exit(1);
|
|
24
|
+
throw new Failure(message);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function run(command, args, options = {}) {
|
|
@@ -31,13 +31,18 @@ function run(command, args, options = {}) {
|
|
|
31
31
|
return (result.stdout || "").trim();
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function runAgain(env) {
|
|
35
|
+
const again = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: "inherit", env });
|
|
36
|
+
if (again.error) fail(`could not run again: ${again.error.message}`);
|
|
37
|
+
return again.status ?? 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
async function ensureUv() {
|
|
35
41
|
if (existsSync(UV_EXE)) return;
|
|
36
42
|
const url = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ASSET}`;
|
|
37
43
|
console.log(`Downloading uv ${UV_VERSION} (Python manager)...`);
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
const archive = Buffer.from(await response.arrayBuffer());
|
|
44
|
+
const { body: archive, rerun } = await download(url, { rerun: runAgain });
|
|
45
|
+
if (rerun !== undefined) return { rerun };
|
|
41
46
|
const digest = createHash("sha256").update(archive).digest("hex");
|
|
42
47
|
if (digest !== UV_SHA256) fail(`${UV_ASSET} checksum mismatch (expected ${UV_SHA256}, got ${digest}).`);
|
|
43
48
|
const scratch = mkdtempSync(join(tmpdir(), "smart-tool-uv-"));
|
|
@@ -55,7 +60,17 @@ async function ensureUv() {
|
|
|
55
60
|
function ensurePython() {
|
|
56
61
|
const env = { ...process.env, UV_PYTHON_INSTALL_DIR: PYTHON_DIR };
|
|
57
62
|
console.log(`Preparing Python ${PYTHON_VERSION} in ${PYTHON_DIR}...`);
|
|
58
|
-
|
|
63
|
+
const install = ["python", "install", PYTHON_VERSION, "--no-bin", "--no-registry"];
|
|
64
|
+
try {
|
|
65
|
+
run(UV_EXE, install, { env });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const native = nativeTlsEnvironment(env);
|
|
68
|
+
if (!(error instanceof Failure) || !isCertificateText(error.message)) throw error;
|
|
69
|
+
if (!native) throw new Failure(`${error.message}\n${certificateAdvice(env)}`);
|
|
70
|
+
console.log("TLS inspection detected by uv; running it again with the Windows certificate store...");
|
|
71
|
+
env.UV_NATIVE_TLS = native.UV_NATIVE_TLS;
|
|
72
|
+
run(UV_EXE, install, { env });
|
|
73
|
+
}
|
|
59
74
|
const python = run(UV_EXE, ["python", "find", "--managed-python", "--no-python-downloads", PYTHON_VERSION], { env });
|
|
60
75
|
if (!python.toLowerCase().startsWith(PYTHON_DIR.toLowerCase())) fail(`uv returned a Python outside ${PYTHON_DIR}: ${python}`);
|
|
61
76
|
return python;
|
|
@@ -69,10 +84,15 @@ async function main() {
|
|
|
69
84
|
}
|
|
70
85
|
if (command !== "install") {
|
|
71
86
|
console.log(USAGE);
|
|
72
|
-
process.
|
|
87
|
+
process.exitCode = command === undefined || command === "--help" || command === "-h" ? 0 : 1;
|
|
88
|
+
return;
|
|
73
89
|
}
|
|
74
90
|
if (process.platform !== "win32" || process.arch !== "x64") fail(`Smart Tool runs on Windows x64 only (this is ${process.platform}-${process.arch}).`);
|
|
75
|
-
await ensureUv();
|
|
91
|
+
const again = await ensureUv();
|
|
92
|
+
if (again) {
|
|
93
|
+
process.exitCode = again.rerun;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
76
96
|
const python = ensurePython();
|
|
77
97
|
const installer = spawnSync(python, [join(PACKAGE_DIR, "install.py"), "--skip-tests", ...rest], {
|
|
78
98
|
stdio: "inherit",
|
|
@@ -80,7 +100,10 @@ async function main() {
|
|
|
80
100
|
env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" },
|
|
81
101
|
});
|
|
82
102
|
if (installer.error) fail(`the installer did not start: ${installer.error.message}`);
|
|
83
|
-
process.
|
|
103
|
+
process.exitCode = installer.status ?? 1;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
main().catch((error) =>
|
|
106
|
+
main().catch((error) => {
|
|
107
|
+
console.error(error instanceof Failure ? `smart-tool: ${error.message}` : `smart-tool: ${error.stack || error}`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
});
|
package/config.py
CHANGED
package/endpoint_sync.py
CHANGED
|
@@ -8,11 +8,12 @@ Cada arquivo é relido logo antes da gravação atômica: o Claude Code reescrev
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
-
import shutil
|
|
12
11
|
import subprocess
|
|
13
12
|
import time
|
|
14
13
|
import urllib.parse
|
|
15
14
|
|
|
15
|
+
import node_locator
|
|
16
|
+
|
|
16
17
|
SERVER = "smart-tool"
|
|
17
18
|
HOOK_PATH = "/hooks/pretool"
|
|
18
19
|
POST_HOOK_PATH = "/hooks/posttool"
|
|
@@ -110,7 +111,11 @@ def registrations():
|
|
|
110
111
|
return found
|
|
111
112
|
|
|
112
113
|
|
|
113
|
-
REGISTER_TIMEOUT_S =
|
|
114
|
+
REGISTER_TIMEOUT_S = 120
|
|
115
|
+
TIMEOUT_ENV = "SMART_TOOL_CLI_TIMEOUT_S"
|
|
116
|
+
CLI_EXTENSIONS = (".exe", ".cmd", ".bat")
|
|
117
|
+
NPM_CLAUDE_FIX = "npm install -g @anthropic-ai/claude-code@latest --include=optional --allow-scripts=@anthropic-ai/claude-code"
|
|
118
|
+
_NPM_CLAUDE_BINARY = os.path.join("node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe")
|
|
114
119
|
_CMD_METACHARS = re.compile(r'[&|<>^%!"\r\n]')
|
|
115
120
|
|
|
116
121
|
|
|
@@ -124,21 +129,99 @@ def register_command(client, base):
|
|
|
124
129
|
raise ValueError(f"Client without MCP registration: {client!r}")
|
|
125
130
|
|
|
126
131
|
|
|
132
|
+
def remove_command(client):
|
|
133
|
+
"""Official CLI command that removes the smart-tool MCP server a client holds."""
|
|
134
|
+
if client == "claude":
|
|
135
|
+
return ["claude", "mcp", "remove", SERVER, "--scope", "user"]
|
|
136
|
+
if client == "codex":
|
|
137
|
+
return ["codex", "mcp", "remove", SERVER]
|
|
138
|
+
raise ValueError(f"Client without MCP registration: {client!r}")
|
|
139
|
+
|
|
140
|
+
|
|
127
141
|
def register(client, base):
|
|
128
|
-
"""
|
|
142
|
+
"""Makes the client's smart-tool MCP entry point to base, through the client's own CLI (it owns its config file;
|
|
143
|
+
~/.claude.json is rewritten by Claude Code all the time). Idempotent: the same URL runs nothing, another URL (the
|
|
144
|
+
port changed) is removed first because `mcp add` refuses an existing name, no entry only adds. Returns what was run."""
|
|
145
|
+
url = f"{base}/mcp"
|
|
146
|
+
current = registrations().get(client)
|
|
147
|
+
if current == url:
|
|
148
|
+
return f"nothing to run: {client} already points to {url}"
|
|
129
149
|
command = register_command(client, base)
|
|
150
|
+
if current:
|
|
151
|
+
run_cli(remove_command(client))
|
|
130
152
|
run_cli(command)
|
|
131
|
-
if registrations().get(client) !=
|
|
153
|
+
if registrations().get(client) != url:
|
|
132
154
|
raise RuntimeError(f"'{' '.join(command)}' finished, but the registration did not appear in the client's configuration.")
|
|
133
155
|
return " ".join(command)
|
|
134
156
|
|
|
135
157
|
|
|
158
|
+
def cli_folders():
|
|
159
|
+
"""Where the native installers of the clients put their CLI, for a PATH that does not list them."""
|
|
160
|
+
home, local, roaming = os.path.expanduser("~"), os.environ.get("LOCALAPPDATA"), os.environ.get("APPDATA")
|
|
161
|
+
folders = [os.path.join(home, ".local", "bin")]
|
|
162
|
+
if local:
|
|
163
|
+
folders.append(os.path.join(local, "Programs", "OpenAI", "Codex", "bin"))
|
|
164
|
+
if roaming:
|
|
165
|
+
folders.append(os.path.join(roaming, "npm"))
|
|
166
|
+
return folders
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_cli(name):
|
|
170
|
+
"""Full path of the client CLI `name` as a .exe, .cmd or .bat (never a .ps1 or the extensionless script npm leaves
|
|
171
|
+
beside its shims, which Windows cannot run), looked up in the PATH as Windows stores it now and then in cli_folders;
|
|
172
|
+
None when absent."""
|
|
173
|
+
for folder in node_locator.search_path() + cli_folders():
|
|
174
|
+
for extension in CLI_EXTENSIONS:
|
|
175
|
+
path = os.path.join(folder, name + extension)
|
|
176
|
+
if os.path.isfile(path):
|
|
177
|
+
return path
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_native(path):
|
|
182
|
+
with open(path, "rb") as stream:
|
|
183
|
+
return stream.read(2) == b"MZ"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_runnable(executable):
|
|
187
|
+
"""Raises RuntimeError, without running anything, when executable is a .exe that is not a Windows program or an npm
|
|
188
|
+
shim whose package holds such a file: Windows answers those with a modal "not compatible" box and the command hangs.
|
|
189
|
+
That is how Claude Code installed by npm 11 ends up when its postinstall is blocked (a ~500-byte text claude.exe)."""
|
|
190
|
+
if executable.lower().endswith(".exe"):
|
|
191
|
+
broken = executable if not _is_native(executable) else None
|
|
192
|
+
else:
|
|
193
|
+
package_binary = os.path.join(os.path.dirname(executable), _NPM_CLAUDE_BINARY)
|
|
194
|
+
broken = package_binary if os.path.isfile(package_binary) and not _is_native(package_binary) else None
|
|
195
|
+
if not broken:
|
|
196
|
+
return
|
|
197
|
+
if os.path.join("@anthropic-ai", "claude-code") in broken:
|
|
198
|
+
raise RuntimeError(f"Claude Code was installed by npm without its native binary ({broken} is a text file). "
|
|
199
|
+
f"Run: {NPM_CLAUDE_FIX}")
|
|
200
|
+
raise RuntimeError(f"{broken} is not a Windows executable (it does not start with MZ); reinstall that CLI.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cli_timeout_s():
|
|
204
|
+
"""Seconds a client CLI may run: SMART_TOOL_CLI_TIMEOUT_S when set (a positive number), else REGISTER_TIMEOUT_S."""
|
|
205
|
+
raw = os.environ.get(TIMEOUT_ENV)
|
|
206
|
+
if raw is None:
|
|
207
|
+
return REGISTER_TIMEOUT_S
|
|
208
|
+
try:
|
|
209
|
+
seconds = float(raw)
|
|
210
|
+
except ValueError:
|
|
211
|
+
seconds = 0
|
|
212
|
+
if seconds <= 0:
|
|
213
|
+
raise RuntimeError(f"{TIMEOUT_ENV} must be a positive number of seconds, not {raw!r}.")
|
|
214
|
+
return seconds
|
|
215
|
+
|
|
216
|
+
|
|
136
217
|
def run_cli(command):
|
|
137
|
-
"""Runs a client CLI command (claude, codex) found
|
|
138
|
-
Raises RuntimeError with the command and the CLI's own message when it is missing, hangs or exits non-zero."""
|
|
139
|
-
executable =
|
|
218
|
+
"""Runs a client CLI command (claude, codex) found by resolve_cli, killing its whole shim tree after cli_timeout_s.
|
|
219
|
+
Raises RuntimeError with the command and the CLI's own message when it is missing, cannot run, hangs or exits non-zero."""
|
|
220
|
+
executable = resolve_cli(command[0])
|
|
140
221
|
if not executable:
|
|
141
222
|
raise RuntimeError(f"CLI '{command[0]}' not found in Smart Tool's PATH. Register it manually: {' '.join(command)}")
|
|
223
|
+
check_runnable(executable)
|
|
224
|
+
timeout = cli_timeout_s()
|
|
142
225
|
unsafe = [arg for arg in command[1:] if _CMD_METACHARS.search(arg)]
|
|
143
226
|
if executable.lower().endswith((".cmd", ".bat")) and unsafe:
|
|
144
227
|
# cmd.exe parses a .cmd shim's arguments again: & | < > ^ % ! would run as commands or expand as variables.
|
|
@@ -152,7 +235,7 @@ def run_cli(command):
|
|
|
152
235
|
except OSError as exc:
|
|
153
236
|
raise RuntimeError(f"'{' '.join(command)}' did not start: {exc}") from None
|
|
154
237
|
try:
|
|
155
|
-
stdout, stderr = proc.communicate(timeout=
|
|
238
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
156
239
|
except subprocess.TimeoutExpired:
|
|
157
240
|
# npm shims are cmd.exe -> node: killing only cmd.exe leaves node holding the pipes forever.
|
|
158
241
|
if os.name == "nt":
|
|
@@ -160,7 +243,7 @@ def run_cli(command):
|
|
|
160
243
|
else:
|
|
161
244
|
proc.kill()
|
|
162
245
|
proc.communicate(timeout=10)
|
|
163
|
-
raise RuntimeError(f"'{' '.join(command)}' did not finish in {
|
|
246
|
+
raise RuntimeError(f"'{' '.join(command)}' did not finish in {timeout:g}s.") from None
|
|
164
247
|
if proc.returncode != 0:
|
|
165
248
|
detail = (stderr or stdout).strip()[-400:]
|
|
166
249
|
raise RuntimeError(f"'{' '.join(command)}' failed (code {proc.returncode}): {detail}")
|
package/install_runtime.py
CHANGED
|
@@ -176,6 +176,7 @@ def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, at
|
|
|
176
176
|
node, _npm = _node_commands()
|
|
177
177
|
env = node_locator.environment(prefer=node)
|
|
178
178
|
env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
|
|
179
|
+
env.setdefault("NODE_USE_SYSTEM_CA", "1")
|
|
179
180
|
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
|
|
180
181
|
for attempt in range(1, attempts + 1):
|
|
181
182
|
print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
|
|
@@ -256,7 +257,7 @@ def _check():
|
|
|
256
257
|
if status["browser_python"]:
|
|
257
258
|
try:
|
|
258
259
|
result = subprocess.run(
|
|
259
|
-
[str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright"],
|
|
260
|
+
[str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright, truststore"],
|
|
260
261
|
capture_output=True, check=False, timeout=30,
|
|
261
262
|
)
|
|
262
263
|
status["browser_packages"] = result.returncode == 0
|
package/model_client.py
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"""Smart Tool model client: talks to the configured model gateway through its adapter."""
|
|
2
2
|
import contextvars
|
|
3
|
+
import io
|
|
4
|
+
import threading
|
|
3
5
|
import time
|
|
6
|
+
import urllib.error
|
|
4
7
|
import usage_meter
|
|
5
8
|
|
|
6
9
|
import gateway
|
|
7
10
|
|
|
8
11
|
OFFLINE = contextvars.ContextVar("model_offline", default=None)
|
|
12
|
+
_NO_TEMPERATURE = set()
|
|
13
|
+
_no_temperature_lock = threading.Lock()
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
def get_token():
|
|
@@ -32,7 +37,25 @@ def fetch(path, token, method="GET", body=None, timeout=15):
|
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
def _fetch(path, _token, method="GET", body=None, timeout=15):
|
|
35
|
-
|
|
40
|
+
"""Routes are named /v1/...; the adapter knows where its API starts. Some models (a Claude on Bedrock, the GPT-5
|
|
41
|
+
family) reject `temperature` with HTTP 400; the request is repeated once without it and the model is remembered, so
|
|
42
|
+
any model of the gateway works with the same call. A second refusal propagates."""
|
|
36
43
|
if not path.startswith("/v1/"):
|
|
37
44
|
raise ValueError("Only /v1/ routes are accepted by this client.")
|
|
38
|
-
|
|
45
|
+
route = path[len("/v1/"):]
|
|
46
|
+
model = body.get("model") if isinstance(body, dict) else None
|
|
47
|
+
if isinstance(body, dict) and "temperature" in body and model in _NO_TEMPERATURE:
|
|
48
|
+
body = {key: value for key, value in body.items() if key != "temperature"}
|
|
49
|
+
try:
|
|
50
|
+
return gateway.request(route, body=body, timeout=timeout)
|
|
51
|
+
except urllib.error.HTTPError as exc:
|
|
52
|
+
if exc.code != 400 or not isinstance(body, dict) or "temperature" not in body:
|
|
53
|
+
raise
|
|
54
|
+
detail = exc.read()
|
|
55
|
+
if b"temperature" not in detail.lower():
|
|
56
|
+
exc.read = io.BytesIO(detail).read
|
|
57
|
+
raise
|
|
58
|
+
with _no_temperature_lock:
|
|
59
|
+
_NO_TEMPERATURE.add(model)
|
|
60
|
+
return gateway.request(route, body={key: value for key, value in body.items() if key != "temperature"},
|
|
61
|
+
timeout=timeout)
|
package/model_defaults.py
CHANGED
|
@@ -6,12 +6,16 @@ import config
|
|
|
6
6
|
import model_catalog
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
# Matched as the last path/dot segment of the catalog id, so "
|
|
10
|
-
# and "
|
|
9
|
+
# Matched as the last path/dot segment of the catalog id, so "claude-haiku-5-5" also finds "vertex_ai.anthropic.claude-haiku-5-5"
|
|
10
|
+
# (LiteLLM aliases) and "anthropic/claude-haiku-5-5" (OpenRouter). Chosen by measurement, 3 rounds per model, correctness
|
|
11
|
+
# first and price second (docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md): the router answers pages
|
|
12
|
+
# and the scout reads code excerpts, where Claude Haiku 5.5 answered every request and led the no-index path; scope and
|
|
13
|
+
# research pick the GPT-5.4 nano, as accurate there at about half the price and without the long reasoning of the mini.
|
|
11
14
|
RECOMMENDED_MODELS = {
|
|
12
|
-
"router_model": ("chat", "
|
|
13
|
-
"scope_model": ("chat", "gpt-5-
|
|
14
|
-
"
|
|
15
|
+
"router_model": ("chat", "claude-haiku-5-5"),
|
|
16
|
+
"scope_model": ("chat", "gpt-5.4-nano"),
|
|
17
|
+
"scout_model": ("chat", "claude-haiku-5-5"),
|
|
18
|
+
"research_model": ("chat", "gpt-5.4-nano"),
|
|
15
19
|
"embedding_model": ("embedding", "text-embedding-3-small"),
|
|
16
20
|
"rerank_model": ("rerank", "rerank-3-5"),
|
|
17
21
|
}
|
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.31",
|
|
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
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""smart_scout: answers a question about a project's code with the literal lines that answer it, read from disk.
|
|
2
|
+
|
|
3
|
+
The heavy model of the session passes the question; the daemon finds the candidates (the index when it is ready, the
|
|
4
|
+
keyword scan of the disk when it is not), reads the current lines of each candidate, keeps the window with most query
|
|
5
|
+
terms and returns `path:lines` plus those lines verbatim. With a `scout_model` configured, a light model of the Smart
|
|
6
|
+
Tool's own gateway also writes a short answer and picks which lines to show; what it writes never replaces the literal
|
|
7
|
+
lines, which always come from the file on disk. Without an index the model gets two fixed calls (terms and folders to
|
|
8
|
+
scan, then the answer); with an index, one.
|
|
9
|
+
|
|
10
|
+
Market pattern copied (docs/research/2026-10-24-scout-modelo-leve-no-smart-tool.md): read-only, step limit, return
|
|
11
|
+
`path:lines` with the excerpt (Windsurf Fast Context, Relace, Claude Code Explore); the light model is fed only the
|
|
12
|
+
excerpts, never an agent prompt, so no agent context is reopened per call.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
import document_text
|
|
18
|
+
import index_scope
|
|
19
|
+
import indexer
|
|
20
|
+
|
|
21
|
+
MAX_QUESTION_CHARS = 2000
|
|
22
|
+
MAX_TERMS_CHARS = 400
|
|
23
|
+
DEFAULT_MAX_FILES = 5
|
|
24
|
+
MAX_FILES = 10
|
|
25
|
+
WINDOWS_PER_FILE = 2
|
|
26
|
+
LEXICAL_WINDOW_LINES = 24
|
|
27
|
+
MODEL_WINDOW_LINES = 60
|
|
28
|
+
CITED_MAX_LINES = 40
|
|
29
|
+
UNCITED_WINDOWS = 2
|
|
30
|
+
LITERAL_BUDGET_CHARS = 9000
|
|
31
|
+
LINE_MAX_CHARS = 240
|
|
32
|
+
ANSWER_MAX_CHARS = 1200
|
|
33
|
+
PLAN_MAX_TERMS = 12
|
|
34
|
+
PLAN_MAX_FOLDERS = 6
|
|
35
|
+
OUTLINE_MAX_DIRS = 300
|
|
36
|
+
OUTLINE_MAX_ROOT_FILES = 200
|
|
37
|
+
_FIELDS = {"project_root", "question", "terms", "max_files"}
|
|
38
|
+
_WORD = re.compile(r"[\w]{3,}", re.UNICODE)
|
|
39
|
+
_STOPWORDS = {
|
|
40
|
+
"the", "and", "for", "how", "what", "where", "which", "does", "with", "from", "that", "this", "are", "when", "who",
|
|
41
|
+
"why", "into", "used", "use", "uses", "its", "has", "have", "code", "project", "file", "files",
|
|
42
|
+
"como", "onde", "qual", "quais", "que", "quem", "por", "para", "com", "uma", "dos", "das", "nos", "nas", "pelo",
|
|
43
|
+
"pela", "ele", "ela", "isso", "esse", "essa", "este", "esta", "fica", "ficam", "usa", "usado", "usada", "sobre",
|
|
44
|
+
"entre", "depois", "antes", "quando", "cada", "seu", "sua", "seus", "suas", "não", "sem", "mais", "código",
|
|
45
|
+
"projeto", "arquivo", "arquivos", "decide", "funciona",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
PLAN_SYSTEM = (
|
|
49
|
+
"You plan a keyword scan of a code project that has no search index yet. Given a question and the project's "
|
|
50
|
+
"folders and root files, return the identifier-style terms most likely written in the code that answers it "
|
|
51
|
+
"(function, class, variable, config names; split camelCase is fine; both the code's language and the question's) "
|
|
52
|
+
"and the folders worth scanning (only names from the list; the root files are always scanned). Call submit_scan_plan."
|
|
53
|
+
)
|
|
54
|
+
PLAN_TOOL = {"type": "function", "function": {
|
|
55
|
+
"name": "submit_scan_plan",
|
|
56
|
+
"description": "Terms to look for in file contents and the folders to scan.",
|
|
57
|
+
"parameters": {"type": "object", "additionalProperties": False, "required": ["terms", "folders"], "properties": {
|
|
58
|
+
"terms": {"type": "array", "items": {"type": "string"}, "maxItems": PLAN_MAX_TERMS},
|
|
59
|
+
"folders": {"type": "array", "items": {"type": "string"}, "maxItems": PLAN_MAX_FOLDERS}}}}}
|
|
60
|
+
ANSWER_SYSTEM = (
|
|
61
|
+
"You answer a question about a code project using ONLY the numbered excerpts provided (each line starts with its "
|
|
62
|
+
"line number). Write at most 5 short lines, citing excerpts as [E1], [E2]. Then cite the excerpts that answer the "
|
|
63
|
+
"question with the narrowest line range inside each one (at most 40 lines). If no excerpt answers it, set found to "
|
|
64
|
+
"false and say what is missing; never guess beyond the excerpts. Answer in the language of the question. "
|
|
65
|
+
"Call submit_scout_answer."
|
|
66
|
+
)
|
|
67
|
+
ANSWER_TOOL = {"type": "function", "function": {
|
|
68
|
+
"name": "submit_scout_answer",
|
|
69
|
+
"description": "Short answer and the excerpt line ranges that support it.",
|
|
70
|
+
"parameters": {"type": "object", "additionalProperties": False, "required": ["found", "answer", "citations"],
|
|
71
|
+
"properties": {
|
|
72
|
+
"found": {"type": "boolean"},
|
|
73
|
+
"answer": {"type": "string"},
|
|
74
|
+
"citations": {"type": "array", "maxItems": MAX_FILES * WINDOWS_PER_FILE, "items": {
|
|
75
|
+
"type": "object", "additionalProperties": False, "required": ["id", "start_line", "end_line"],
|
|
76
|
+
"properties": {"id": {"type": "string"}, "start_line": {"type": "integer"},
|
|
77
|
+
"end_line": {"type": "integer"}}}}}}}}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def arguments(raw):
|
|
81
|
+
"""Validated `{"project_root", "question", "terms", "max_files"}` from the tool call; raises ValueError naming the
|
|
82
|
+
field. `terms` (identifier words the caller already knows) is optional and joins the question in the search."""
|
|
83
|
+
if not isinstance(raw, dict):
|
|
84
|
+
raise ValueError("smart_scout requires its arguments as a JSON object")
|
|
85
|
+
unknown = set(raw) - _FIELDS
|
|
86
|
+
if unknown:
|
|
87
|
+
raise ValueError(f"Unknown fields in smart_scout: {', '.join(sorted(unknown))}. Accepted: {', '.join(sorted(_FIELDS))}.")
|
|
88
|
+
root = raw.get("project_root")
|
|
89
|
+
if not isinstance(root, str) or not os.path.isdir(root):
|
|
90
|
+
raise ValueError("smart_scout requires 'project_root' pointing to an existing folder")
|
|
91
|
+
question = raw.get("question")
|
|
92
|
+
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
93
|
+
raise ValueError(f"smart_scout requires 'question' (non-empty text of at most {MAX_QUESTION_CHARS} characters)")
|
|
94
|
+
terms = raw.get("terms")
|
|
95
|
+
if terms is not None and (not isinstance(terms, str) or len(terms) > MAX_TERMS_CHARS):
|
|
96
|
+
raise ValueError(f"terms must be text of at most {MAX_TERMS_CHARS} characters")
|
|
97
|
+
max_files = raw.get("max_files", DEFAULT_MAX_FILES)
|
|
98
|
+
if type(max_files) is not int or not 1 <= max_files <= MAX_FILES:
|
|
99
|
+
raise ValueError(f"max_files must be an integer from 1 to {MAX_FILES}")
|
|
100
|
+
return {"project_root": root, "question": question.strip(), "terms": (terms or "").strip(), "max_files": max_files}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def focus_terms(*texts):
|
|
104
|
+
"""Casefolded content words of the texts (3+ characters, common question words dropped), camelCase split, in
|
|
105
|
+
order: what a window is scored by."""
|
|
106
|
+
found = []
|
|
107
|
+
for text in texts:
|
|
108
|
+
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", text or "")
|
|
109
|
+
for word in _WORD.findall(spaced):
|
|
110
|
+
folded = word.casefold()
|
|
111
|
+
if folded not in _STOPWORDS and folded not in found:
|
|
112
|
+
found.append(folded)
|
|
113
|
+
return found[:24]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def disk_lines(root, path, start, end):
|
|
117
|
+
"""`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
|
|
118
|
+
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx)."""
|
|
119
|
+
if document_text.location_kind(path) != "source_line":
|
|
120
|
+
return None
|
|
121
|
+
data, encoding = indexer.read_indexable(root, path)
|
|
122
|
+
if data is None:
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
lines = data.decode(encoding or "utf-8", "replace").splitlines()
|
|
126
|
+
except LookupError:
|
|
127
|
+
return None
|
|
128
|
+
start = max(1, start)
|
|
129
|
+
end = min(len(lines), end)
|
|
130
|
+
return [(number, lines[number - 1]) for number in range(start, end + 1)] if start <= end else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def best_window(lines, terms, size):
|
|
134
|
+
"""The run of at most `size` consecutive `(number, text)` lines holding most occurrences of `terms` (first run on a
|
|
135
|
+
tie), trimmed of blank edges."""
|
|
136
|
+
if len(lines) <= size:
|
|
137
|
+
window = lines
|
|
138
|
+
else:
|
|
139
|
+
hits = [sum(text.casefold().count(term) for term in terms) for _number, text in lines]
|
|
140
|
+
best, best_at, current = -1, 0, sum(hits[:size])
|
|
141
|
+
for at in range(len(lines) - size + 1):
|
|
142
|
+
if at:
|
|
143
|
+
current += hits[at + size - 1] - hits[at - 1]
|
|
144
|
+
if current > best:
|
|
145
|
+
best, best_at = current, at
|
|
146
|
+
window = lines[best_at:best_at + size]
|
|
147
|
+
while window and not window[0][1].strip():
|
|
148
|
+
window = window[1:]
|
|
149
|
+
while window and not window[-1][1].strip():
|
|
150
|
+
window = window[:-1]
|
|
151
|
+
return window
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def order_candidates(blocks, max_files):
|
|
155
|
+
"""Search rows of the code, doc and test blocks in reading order: code first, with the best doc and test rows kept
|
|
156
|
+
inside the first `max_files` files when the code rows would crowd them out."""
|
|
157
|
+
code, doc, test = (list(blocks.get(name) or []) for name in ("code", "doc", "test"))
|
|
158
|
+
reserved = [rows[0] for rows in (doc, test) if rows]
|
|
159
|
+
head = code[:max(1, max_files - len(reserved))]
|
|
160
|
+
rest = code[len(head):] + doc[1:] + test[1:]
|
|
161
|
+
return head + reserved + rest
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def evidence(root, rows, terms, max_files, window_lines):
|
|
165
|
+
"""Evidence items `{id, path, start, end, lines, symbols}` for the first `max_files` files of `rows` (search rows
|
|
166
|
+
`{path, lines: "a-b", snippet, symbols}`), at most WINDOWS_PER_FILE non-overlapping windows per file, each the
|
|
167
|
+
`window_lines` lines of the chunk with most `terms`, read from disk now. A file that cannot be read from disk keeps
|
|
168
|
+
the indexed snippet with its chunk range."""
|
|
169
|
+
items, files = [], []
|
|
170
|
+
for row in rows:
|
|
171
|
+
path = row["path"]
|
|
172
|
+
try:
|
|
173
|
+
start, end = (int(part) for part in str(row["lines"]).split("-", 1))
|
|
174
|
+
except ValueError:
|
|
175
|
+
continue
|
|
176
|
+
if path not in files:
|
|
177
|
+
if len(files) >= max_files:
|
|
178
|
+
continue
|
|
179
|
+
files.append(path)
|
|
180
|
+
mine = [item for item in items if item["path"] == path]
|
|
181
|
+
if len(mine) >= WINDOWS_PER_FILE or any(item["start"] <= end and start <= item["end"] for item in mine):
|
|
182
|
+
continue
|
|
183
|
+
lines = disk_lines(root, path, start, end)
|
|
184
|
+
if lines:
|
|
185
|
+
window = best_window(lines, terms, window_lines)
|
|
186
|
+
if not window:
|
|
187
|
+
continue
|
|
188
|
+
start, end = window[0][0], window[-1][0]
|
|
189
|
+
else:
|
|
190
|
+
window = [(None, text) for text in str(row.get("snippet") or "").splitlines()[:window_lines]]
|
|
191
|
+
items.append({"id": f"E{len(items) + 1}", "path": path, "start": start, "end": end, "lines": window,
|
|
192
|
+
"symbols": row.get("symbols") or ""})
|
|
193
|
+
return items
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def numbered(lines):
|
|
197
|
+
"""Lines as ` 123| text`, long lines cut at LINE_MAX_CHARS."""
|
|
198
|
+
out = []
|
|
199
|
+
for number, text in lines:
|
|
200
|
+
text = text if len(text) <= LINE_MAX_CHARS else text[:LINE_MAX_CHARS] + " …"
|
|
201
|
+
out.append(f"{number:>6}| {text}" if number is not None else f" | {text}")
|
|
202
|
+
return "\n".join(out)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def outline(root):
|
|
206
|
+
"""`(folders, root_files)` shown to the plan call: folders up to two levels deep and loose files at the root, with
|
|
207
|
+
the index's mechanical exclusions (dependency caches, hidden folders, generated output) already applied."""
|
|
208
|
+
skipped = set(index_scope.mechanical_excludes(root))
|
|
209
|
+
folders = index_scope._structure(root, max_dirs=OUTLINE_MAX_DIRS,
|
|
210
|
+
keep=lambda rel: rel.count("/") < 2 and rel.split("/")[0] not in skipped)
|
|
211
|
+
try:
|
|
212
|
+
names = sorted(os.listdir(root))
|
|
213
|
+
except OSError:
|
|
214
|
+
names = []
|
|
215
|
+
files = [name for name in names if not name.startswith(".") and os.path.isfile(os.path.join(root, name))]
|
|
216
|
+
return folders, files[:OUTLINE_MAX_ROOT_FILES]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def plan_prompt(question, folders, root_files):
|
|
220
|
+
"""User message of the plan call."""
|
|
221
|
+
return ("Question: " + question + "\n\nFolders:\n" + "\n".join(folders or ["(none)"]) +
|
|
222
|
+
"\n\nRoot files:\n" + "\n".join(root_files or ["(none)"]))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def valid_plan(raw, folders):
|
|
226
|
+
"""`(terms, folders)` from the plan call: terms as non-empty words, folders only those listed (none means only the
|
|
227
|
+
root files, which `index_scope.resolve_included_files` always adds); raises ValueError when no usable term
|
|
228
|
+
remains."""
|
|
229
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("terms"), list) or not isinstance(raw.get("folders"), list):
|
|
230
|
+
raise ValueError("the scan plan came back without terms and folders")
|
|
231
|
+
terms = [term.strip() for term in raw["terms"] if isinstance(term, str) and term.strip()][:PLAN_MAX_TERMS]
|
|
232
|
+
if not terms:
|
|
233
|
+
raise ValueError("the scan plan came back without terms")
|
|
234
|
+
allowed = set(folders)
|
|
235
|
+
picked = [folder.strip().strip("/") for folder in raw["folders"] if isinstance(folder, str)]
|
|
236
|
+
return terms, [folder for folder in dict.fromkeys(picked) if folder in allowed][:PLAN_MAX_FOLDERS]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def answer_prompt(question, items):
|
|
240
|
+
"""User message of the answer call: the question and each excerpt with its id, path and numbered lines."""
|
|
241
|
+
parts = [f"Question: {question}"]
|
|
242
|
+
for item in items:
|
|
243
|
+
parts.append(f"[{item['id']}] {item['path']}:{item['start']}-{item['end']}"
|
|
244
|
+
+ (f" ({item['symbols']})" if item["symbols"] else "") + "\n" + numbered(item["lines"]))
|
|
245
|
+
return "\n\n".join(parts)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def valid_answer(raw, items):
|
|
249
|
+
"""`(found, answer, citations)` from the answer call. Citations name an excerpt id and a range clipped to that
|
|
250
|
+
excerpt and to CITED_MAX_LINES; unknown ids and empty ranges are dropped. Raises ValueError on a malformed reply."""
|
|
251
|
+
if (not isinstance(raw, dict) or type(raw.get("found")) is not bool or not isinstance(raw.get("answer"), str)
|
|
252
|
+
or not isinstance(raw.get("citations"), list)):
|
|
253
|
+
raise ValueError("the answer came back outside the expected fields")
|
|
254
|
+
by_id = {item["id"]: item for item in items}
|
|
255
|
+
citations = []
|
|
256
|
+
for cite in raw["citations"]:
|
|
257
|
+
if not isinstance(cite, dict) or cite.get("id") not in by_id:
|
|
258
|
+
continue
|
|
259
|
+
item = by_id[cite["id"]]
|
|
260
|
+
try:
|
|
261
|
+
start, end = int(cite.get("start_line")), int(cite.get("end_line"))
|
|
262
|
+
except (TypeError, ValueError):
|
|
263
|
+
continue
|
|
264
|
+
if item["lines"][0][0] is None:
|
|
265
|
+
start, end = item["start"], item["end"]
|
|
266
|
+
start, end = max(start, item["start"]), min(end, item["end"], max(start, item["start"]) + CITED_MAX_LINES - 1)
|
|
267
|
+
if start <= end and not any(c["id"] == item["id"] and c["start"] <= end and start <= c["end"] for c in citations):
|
|
268
|
+
citations.append({"id": item["id"], "start": start, "end": end})
|
|
269
|
+
answer = raw["answer"].strip()[:ANSWER_MAX_CHARS]
|
|
270
|
+
if not answer:
|
|
271
|
+
raise ValueError("the answer came back empty")
|
|
272
|
+
return raw["found"], answer, citations
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def render(question, source, items, citations=None, answer=None, found=None, model_note="", warnings=(), others=()):
|
|
276
|
+
"""The text returned to the agent. Lexical mode (`citations` None): every evidence window, within
|
|
277
|
+
LITERAL_BUDGET_CHARS, after the model's answer when there is one (a model that cited nothing never leaves the
|
|
278
|
+
answer without lines). Model mode: the model's answer, then only the cited ranges (sliced from the windows read
|
|
279
|
+
from disk), and the uncited excerpts as `path:lines` pointers. `others` are candidates not shown, listed as
|
|
280
|
+
pointers."""
|
|
281
|
+
out = [f"question: {question}", f"source: {source}", f"model: {model_note or 'none (literal lines only)'}"]
|
|
282
|
+
if answer is not None:
|
|
283
|
+
out.append(("answer: " if found else "answer (not found in the excerpts): ") + answer)
|
|
284
|
+
shown, budget = [], LITERAL_BUDGET_CHARS
|
|
285
|
+
if citations is None:
|
|
286
|
+
picks = [(item, item["start"], item["end"]) for item in items]
|
|
287
|
+
else:
|
|
288
|
+
by_id = {item["id"]: item for item in items}
|
|
289
|
+
picks = [(by_id[c["id"]], c["start"], c["end"]) for c in citations]
|
|
290
|
+
out.append("evidence:" if picks else "evidence: none")
|
|
291
|
+
for item, start, end in picks:
|
|
292
|
+
lines = [(n, t) for n, t in item["lines"] if n is None or start <= n <= end]
|
|
293
|
+
block = numbered(lines)
|
|
294
|
+
head = f"- [{item['id']}] {item['path']}:{start}-{end}" + (f" ({item['symbols']})" if item["symbols"] else "")
|
|
295
|
+
if len(block) > budget:
|
|
296
|
+
out.append(head + " (lines not shown: output budget reached)")
|
|
297
|
+
continue
|
|
298
|
+
budget -= len(block)
|
|
299
|
+
out += [head, block]
|
|
300
|
+
shown.append(item["id"])
|
|
301
|
+
pointers = [f"{item['path']}:{item['start']}-{item['end']}" for item in items if item["id"] not in shown
|
|
302
|
+
and citations is not None] + list(others)
|
|
303
|
+
if pointers:
|
|
304
|
+
out.append("other candidates (not shown): " + ", ".join(pointers))
|
|
305
|
+
for warning in warnings:
|
|
306
|
+
out.append(f"warning: {warning}")
|
|
307
|
+
return "\n".join(out) + "\n"
|
package/setup_ui.py
CHANGED
|
@@ -79,6 +79,7 @@ def status_payload():
|
|
|
79
79
|
"missing_models": models["missing"],
|
|
80
80
|
"router_model": cfg.get("router_model", ""),
|
|
81
81
|
"scope_model": cfg.get("scope_model", ""),
|
|
82
|
+
"scout_model": cfg.get("scout_model", ""),
|
|
82
83
|
"research_model": cfg.get("research_model", ""),
|
|
83
84
|
"research_max_iterations": cfg.get("research_max_iterations", config.DEFAULT_CONFIG["research_max_iterations"]),
|
|
84
85
|
"quick_search_timeout_s": cfg.get("quick_search_timeout_s", config.DEFAULT_CONFIG["quick_search_timeout_s"]),
|
|
@@ -132,6 +133,7 @@ def handle_models_get():
|
|
|
132
133
|
"current": {
|
|
133
134
|
"router_model": cfg.get("router_model", ""),
|
|
134
135
|
"scope_model": cfg.get("scope_model", ""),
|
|
136
|
+
"scout_model": cfg.get("scout_model", ""),
|
|
135
137
|
"research_model": cfg.get("research_model", ""),
|
|
136
138
|
"research_max_iterations": cfg.get("research_max_iterations", config.DEFAULT_CONFIG["research_max_iterations"]),
|
|
137
139
|
"quick_search_timeout_s": cfg.get("quick_search_timeout_s", config.DEFAULT_CONFIG["quick_search_timeout_s"]),
|
|
@@ -369,6 +371,7 @@ def handle_autostart_action(body):
|
|
|
369
371
|
_MODEL_FIELDS = {
|
|
370
372
|
"router_model": "chat",
|
|
371
373
|
"scope_model": "chat",
|
|
374
|
+
"scout_model": "chat",
|
|
372
375
|
"research_model": "chat",
|
|
373
376
|
"embedding_model": "embedding",
|
|
374
377
|
"rerank_model": "rerank",
|
|
@@ -681,6 +684,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
681
684
|
<div class="field"><label for="sel-scope">Index classification</label><span class="explain">Decides scope, code or documentation, and languages while indexing. Runs once per project.</span><input id="sel-scope" list="dl-scope" autocomplete="off" spellcheck="false"><datalist id="dl-scope"></datalist></div>
|
|
682
685
|
<div class="field"><label for="sel-router">Router</label><span class="explain">Decides before each agent Grep, Read or Bash. Use a small model without reasoning: above 10 s routing turns off.</span><input id="sel-router" list="dl-router" autocomplete="off" spellcheck="false"><datalist id="dl-router"></datalist></div>
|
|
683
686
|
</div>
|
|
687
|
+
<div class="field"><label for="sel-scout">Code scout (optional)</label><span class="explain">smart_scout: writes a short answer over the lines it read and keeps only the ones that answer. Empty returns the literal lines without a model.</span><input id="sel-scout" list="dl-scout" autocomplete="off" spellcheck="false"><datalist id="dl-scout"></datalist></div>
|
|
684
688
|
<div class="field"><label for="sel-research">Web research</label><span class="explain">Used by web_search with depth=research. Empty uses the router model.</span><input id="sel-research" list="dl-research" autocomplete="off" spellcheck="false"><datalist id="dl-research"></datalist></div>
|
|
685
689
|
<details class="more">
|
|
686
690
|
<summary>Advanced web research limits</summary>
|
|
@@ -1031,6 +1035,7 @@ async function loadModels() {
|
|
|
1031
1035
|
const data = await getJSON("/setup/models");
|
|
1032
1036
|
fillSuggestions($("sel-router"), data.chat_lightweight.length ? data.chat_lightweight : data.chat, data.current.router_model);
|
|
1033
1037
|
fillSuggestions($("sel-scope"), data.chat, data.current.scope_model);
|
|
1038
|
+
fillSuggestions($("sel-scout"), data.chat_lightweight.length ? data.chat_lightweight : data.chat, data.current.scout_model);
|
|
1034
1039
|
fillSuggestions($("sel-research"), data.chat, data.current.research_model);
|
|
1035
1040
|
fillSuggestions($("sel-embedding"), data.embedding, data.current.embedding_model);
|
|
1036
1041
|
fillSuggestions($("sel-rerank"), data.rerank, data.current.rerank_model);
|
|
@@ -1048,7 +1053,7 @@ $("btn-save-models").addEventListener("click", async () => {
|
|
|
1048
1053
|
setMsg("models-msg", "", "Saving…");
|
|
1049
1054
|
try {
|
|
1050
1055
|
await post("/setup/models", {
|
|
1051
|
-
router_model: $("sel-router").value, scope_model: $("sel-scope").value, research_model: $("sel-research").value,
|
|
1056
|
+
router_model: $("sel-router").value, scope_model: $("sel-scope").value, scout_model: $("sel-scout").value, research_model: $("sel-research").value,
|
|
1052
1057
|
research_max_iterations: Number($("num-research-iterations").value), quick_search_timeout_s: Number($("num-quick-timeout").value),
|
|
1053
1058
|
site_memory_similarity_threshold: Number($("num-site-memory-threshold").value),
|
|
1054
1059
|
embedding_model: $("sel-embedding").value, rerank_model: $("sel-rerank").value,
|
package/smart_tool_daemon.py
CHANGED
|
@@ -52,6 +52,7 @@ import index_inventory
|
|
|
52
52
|
import usage_meter
|
|
53
53
|
import document_text
|
|
54
54
|
import dossier
|
|
55
|
+
import scout
|
|
55
56
|
import embedding_cache
|
|
56
57
|
import model_defaults
|
|
57
58
|
import project_identity
|
|
@@ -334,7 +335,8 @@ def _smart_search_job_payload(job_id):
|
|
|
334
335
|
return payload
|
|
335
336
|
|
|
336
337
|
# Claude Code defers MCP tools behind tool search; the tools the hook redirects to (and their job polling) load
|
|
337
|
-
# upfront, so an agent denied by the hook already has the replacement in its tool list.
|
|
338
|
+
# upfront, so an agent denied by the hook already has the replacement in its tool list. smart_scout loads upfront too:
|
|
339
|
+
# the server instructions point to it in place of reading several files.
|
|
338
340
|
ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
|
|
339
341
|
SERVER_INSTRUCTIONS = (
|
|
340
342
|
"Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. Use smart_search "
|
|
@@ -345,9 +347,9 @@ SERVER_INSTRUCTIONS = (
|
|
|
345
347
|
"node, PowerShell, curl or wget. "
|
|
346
348
|
"Before changing a function, project_manage action=graph with symbol lists its callers and tests; after editing, "
|
|
347
349
|
"project_manage action=affected_tests lists the tests to run. When smart_search returns nothing, retry smart_search with "
|
|
348
|
-
"other terms; do not switch to Grep, Read of folders or shell search.
|
|
349
|
-
"
|
|
350
|
-
"
|
|
350
|
+
"other terms; do not switch to Grep, Read of folders or shell search. To answer a question that would mean reading "
|
|
351
|
+
"several files, call smart_scout: it reads them inside Smart Tool and returns only the lines that answer, as "
|
|
352
|
+
"path:lines with the literal text."
|
|
351
353
|
)
|
|
352
354
|
|
|
353
355
|
TOOLS = [
|
|
@@ -433,6 +435,30 @@ TOOLS = [
|
|
|
433
435
|
"required": ["project_root"],
|
|
434
436
|
},
|
|
435
437
|
},
|
|
438
|
+
{
|
|
439
|
+
"name": "smart_scout",
|
|
440
|
+
"_meta": ALWAYS_LOAD,
|
|
441
|
+
"description": (
|
|
442
|
+
"Answers a question about a project's code with the lines that answer it, read inside Smart Tool so the "
|
|
443
|
+
"files never enter your context: path:lines plus the literal text of each excerpt, read from disk now. "
|
|
444
|
+
"Use it instead of reading several files one by one (how a flow works, where something is decided). "
|
|
445
|
+
"Candidates come from the index when it is ready and from a keyword scan of the disk when it is not. "
|
|
446
|
+
"When a scout_model is configured, a light model also writes a short answer and keeps only the lines that "
|
|
447
|
+
"support it; its text never replaces the literal lines. For a plain lookup of a name, smart_search is enough."
|
|
448
|
+
),
|
|
449
|
+
"inputSchema": {
|
|
450
|
+
"type": "object",
|
|
451
|
+
"properties": {
|
|
452
|
+
"project_root": {"type": "string", "description": "Absolute path to the project root"},
|
|
453
|
+
"question": {"type": "string", "description": "The question in natural language, as you would ask a colleague"},
|
|
454
|
+
"terms": {"type": "string", "description": "Optional identifier words you already know (function, class, config names)"},
|
|
455
|
+
"max_files": {"type": "integer", "minimum": 1, "maximum": scout.MAX_FILES, "default": scout.DEFAULT_MAX_FILES,
|
|
456
|
+
"description": "Files read and shown at most"},
|
|
457
|
+
},
|
|
458
|
+
"required": ["project_root", "question"],
|
|
459
|
+
"additionalProperties": False,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
436
462
|
{
|
|
437
463
|
"name": "smart_search_result",
|
|
438
464
|
"_meta": ALWAYS_LOAD,
|
|
@@ -1102,6 +1128,7 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1102
1128
|
vectors = {}
|
|
1103
1129
|
overlay = None
|
|
1104
1130
|
search_model = model
|
|
1131
|
+
source = "index"
|
|
1105
1132
|
if problem:
|
|
1106
1133
|
model_client.OFFLINE.set(problem)
|
|
1107
1134
|
token = "" if problem else model_client.get_token()
|
|
@@ -1120,6 +1147,7 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1120
1147
|
elif _cold_index(root, cfg, problem):
|
|
1121
1148
|
overlay, notice = _cold_overlay(root, texts)
|
|
1122
1149
|
search_model = None
|
|
1150
|
+
source = "cold"
|
|
1123
1151
|
warnings += [text for text in (notice, _refresh_in_background(root, {"refresh": True})) if text]
|
|
1124
1152
|
elif problem and not local_embedder.is_local(model):
|
|
1125
1153
|
if not indexer.info(root).get("chunks"):
|
|
@@ -1144,6 +1172,7 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1144
1172
|
lag = _index_lag(root, model, cfg, problem, job_id)
|
|
1145
1173
|
if lag is not None:
|
|
1146
1174
|
overlay, notice = _lag_overlay(root, lag)
|
|
1175
|
+
source = "index+disk" if overlay else source
|
|
1147
1176
|
warnings += [text for text in (notice, _refresh_in_background(root, lag)) if text]
|
|
1148
1177
|
else:
|
|
1149
1178
|
try:
|
|
@@ -1231,6 +1260,9 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1231
1260
|
warning = " ".join(warnings)
|
|
1232
1261
|
if warning:
|
|
1233
1262
|
_update_job(job_id, warning=warning)
|
|
1263
|
+
if arguments.get("_structured"):
|
|
1264
|
+
return {"source": source if source == "cold" or vectors else "lexical index", "blocks": blocks,
|
|
1265
|
+
"warnings": warnings}
|
|
1234
1266
|
return _grouped_yaml(index_profile.public(profile), blocks, " ".join(warnings + notes))
|
|
1235
1267
|
|
|
1236
1268
|
|
|
@@ -1614,6 +1646,152 @@ def _agent_search_answer(payload):
|
|
|
1614
1646
|
return _to_json_sanitized(slim)
|
|
1615
1647
|
|
|
1616
1648
|
|
|
1649
|
+
SCOUT_SEARCH_WAIT_S = 20
|
|
1650
|
+
SCOUT_MODEL_TIMEOUT_S = 30
|
|
1651
|
+
SCOUT_SCAN_MAX_FILES = 4000
|
|
1652
|
+
SCOUT_MAX_POINTERS = 8
|
|
1653
|
+
_SCOUT_SOURCES = {
|
|
1654
|
+
"index": "index (semantic search and rerank)",
|
|
1655
|
+
"index+disk": "index, with the files changed after it searched by keyword on disk",
|
|
1656
|
+
"lexical index": "index by keyword only (no embedding)",
|
|
1657
|
+
"cold": "keyword scan of the disk (no index yet; it is being built in the background)",
|
|
1658
|
+
"pending": "keyword scan of the disk (the index is being updated in the background)",
|
|
1659
|
+
"failed": "keyword scan of the disk (the search failed)",
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _handle_smart_scout(arguments):
|
|
1664
|
+
"""smart_scout: the literal lines that answer a question about a project, read from disk, with a short answer by
|
|
1665
|
+
the configured `scout_model` when there is one. Candidates come from the smart_search pipeline (index when ready,
|
|
1666
|
+
keyword scan of the disk when not); without an index the model first plans terms and folders to scan (two calls in
|
|
1667
|
+
all, one with an index). A model failure is reported in a warning and the literal lines are returned without a
|
|
1668
|
+
summary; model calls are recorded in the usage meter under the purpose `scout`."""
|
|
1669
|
+
started = time.monotonic()
|
|
1670
|
+
args = scout.arguments(arguments)
|
|
1671
|
+
root = project_identity.display_root(args["project_root"])
|
|
1672
|
+
question, max_files = args["question"], args["max_files"]
|
|
1673
|
+
query = " ".join(part for part in (args["terms"], question) if part)
|
|
1674
|
+
model = config.load_config().get("scout_model") or ""
|
|
1675
|
+
problem = _gateway_problem() if model else None
|
|
1676
|
+
warnings, calls, failure = [], 0, None
|
|
1677
|
+
if problem:
|
|
1678
|
+
warnings.append(f"scout_model {model} unavailable ({problem}): literal lines only, no answer.")
|
|
1679
|
+
failure, model = "gateway", ""
|
|
1680
|
+
with usage_meter.job(root, index_views.describe(root), "scout"):
|
|
1681
|
+
found = _scout_search(root, query, question, max_files)
|
|
1682
|
+
usage_meter.phase("scout")
|
|
1683
|
+
warnings += found["warnings"]
|
|
1684
|
+
rows = scout.order_candidates(found["blocks"], max_files)
|
|
1685
|
+
plan_terms = []
|
|
1686
|
+
if model and found["source"] in ("cold", "pending", "failed"):
|
|
1687
|
+
calls += 1
|
|
1688
|
+
try:
|
|
1689
|
+
plan_terms, planned = _scout_plan_scan(root, question, model)
|
|
1690
|
+
rows = planned + rows
|
|
1691
|
+
except Exception as exc:
|
|
1692
|
+
warnings.append(f"scout_model {model} failed planning the scan ({_project_error(exc)}): literal lines "
|
|
1693
|
+
"only, no answer.")
|
|
1694
|
+
failure, model = "plan", ""
|
|
1695
|
+
if (not model and found["source"] in ("cold", "pending", "failed")
|
|
1696
|
+
and not index_scope.valid_scope(index_scope.load_scope(root))):
|
|
1697
|
+
wide, scanned, total = _scout_scan(root, [""], " ".join(scout.focus_terms(args["terms"], question)))
|
|
1698
|
+
rows = wide + rows
|
|
1699
|
+
warnings.append(f"No index and no saved scope yet: keyword scan of the whole project in {scanned} of {total} "
|
|
1700
|
+
"files (dependency, hidden and generated folders excluded).")
|
|
1701
|
+
terms = scout.focus_terms(args["terms"], question, " ".join(plan_terms))
|
|
1702
|
+
items = scout.evidence(root, rows, terms, max_files,
|
|
1703
|
+
scout.MODEL_WINDOW_LINES if model else scout.LEXICAL_WINDOW_LINES)
|
|
1704
|
+
shown = {item["path"] for item in items}
|
|
1705
|
+
others = list(dict.fromkeys(f"{row['path']}:{row['lines']}" for row in rows
|
|
1706
|
+
if row["path"] not in shown))[:SCOUT_MAX_POINTERS]
|
|
1707
|
+
source = _SCOUT_SOURCES[found["source"]]
|
|
1708
|
+
text = None
|
|
1709
|
+
if model and items:
|
|
1710
|
+
calls += 1
|
|
1711
|
+
try:
|
|
1712
|
+
raw = _research_llm_call(model, model_client.get_token(), scout.ANSWER_SYSTEM,
|
|
1713
|
+
scout.answer_prompt(question, items), scout.ANSWER_TOOL,
|
|
1714
|
+
timeout=SCOUT_MODEL_TIMEOUT_S)
|
|
1715
|
+
found_answer, answer, citations = scout.valid_answer(raw, items)
|
|
1716
|
+
note = f"{model} ({calls} call{'s' if calls > 1 else ''})"
|
|
1717
|
+
if citations:
|
|
1718
|
+
text = scout.render(question, source, items, citations, answer, found_answer, note, warnings, others)
|
|
1719
|
+
else:
|
|
1720
|
+
shown_items = scout.evidence(root, rows, terms, max_files,
|
|
1721
|
+
scout.LEXICAL_WINDOW_LINES)[:scout.UNCITED_WINDOWS]
|
|
1722
|
+
rest = [f"{item['path']}:{item['start']}-{item['end']}" for item in items
|
|
1723
|
+
if item["path"] not in {kept["path"] for kept in shown_items}]
|
|
1724
|
+
text = scout.render(question, source, shown_items, None, answer, found_answer, note,
|
|
1725
|
+
warnings + ["the scout_model cited no lines; the best keyword windows are shown "
|
|
1726
|
+
"instead."], rest + others)
|
|
1727
|
+
except Exception as exc:
|
|
1728
|
+
warnings.append(f"scout_model {model} failed writing the answer ({_project_error(exc)}): literal lines "
|
|
1729
|
+
"only, no answer.")
|
|
1730
|
+
failure = "answer"
|
|
1731
|
+
items = scout.evidence(root, rows, terms, max_files, scout.LEXICAL_WINDOW_LINES)
|
|
1732
|
+
if text is None:
|
|
1733
|
+
text = scout.render(question, source, items, warnings=warnings, others=others)
|
|
1734
|
+
_metric("scout", source=found["source"], model=bool(model) and failure is None, model_calls=calls, failure=failure,
|
|
1735
|
+
files=len({item["path"] for item in items}), chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
|
|
1736
|
+
return _sanitize_text(text)
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def _scout_search(root, query, question, max_files):
|
|
1740
|
+
"""`{"source", "blocks", "warnings"}` for smart_scout: the smart_search pipeline (structured result) when it answers
|
|
1741
|
+
within SCOUT_SEARCH_WAIT_S; otherwise, or when it fails, a keyword scan of the disk in the searched scope while the
|
|
1742
|
+
search job keeps updating the index in the background. While the first index of the folder is being built the cold
|
|
1743
|
+
path runs at once, outside the per-folder queue, as for a redirected search."""
|
|
1744
|
+
limits = {"code": min(SEARCH_LIMIT_TOTAL - 6, max_files * 2), "test": 3, "doc": 3}
|
|
1745
|
+
search = {"project_root": root, "query": query, "doc_query": question, "group": "all", "top_k": limits,
|
|
1746
|
+
"_structured": True}
|
|
1747
|
+
if _cold_index_building(root):
|
|
1748
|
+
return _perform_smart_search(search, "")
|
|
1749
|
+
job_id, future = _submit_project_job("smart_search", search)
|
|
1750
|
+
deadline = time.monotonic() + SCOUT_SEARCH_WAIT_S
|
|
1751
|
+
if future is not None:
|
|
1752
|
+
with contextlib.suppress(Exception):
|
|
1753
|
+
future.result(timeout=SCOUT_SEARCH_WAIT_S)
|
|
1754
|
+
while True:
|
|
1755
|
+
with _JOBS_LOCK:
|
|
1756
|
+
job = dict(_jobs.get(job_id) or {})
|
|
1757
|
+
if job.get("status") != "pending" or time.monotonic() >= deadline:
|
|
1758
|
+
break
|
|
1759
|
+
time.sleep(0.2)
|
|
1760
|
+
if job.get("status") == "done" and isinstance(job.get("result"), dict):
|
|
1761
|
+
return job["result"]
|
|
1762
|
+
state, warnings = ("pending", []) if job.get("status") == "pending" else (
|
|
1763
|
+
"failed", [f"smart_search failed ({job.get('error') or 'no result'}); keyword scan of the disk instead."])
|
|
1764
|
+
overlay, _notice = _cold_overlay(root, [query])
|
|
1765
|
+
found = indexer.search(root, None, top_k=max_files * 3, query=query, overlay=overlay)
|
|
1766
|
+
return {"source": state, "warnings": warnings,
|
|
1767
|
+
"blocks": {"code": [{"path": path, "lines": f"{start}-{end}", "snippet": text[:500]}
|
|
1768
|
+
for _score, path, start, end, text in found]}}
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def _scout_plan_scan(root, question, model):
|
|
1772
|
+
"""First model call of smart_scout without an index: the model names identifier terms and folders from the
|
|
1773
|
+
project outline, and the daemon scans those folders (plus the root files) by keyword within COLD_SCAN_BUDGET_S.
|
|
1774
|
+
Returns `(terms, rows)` with the rows ranked like the cold search."""
|
|
1775
|
+
folders, root_files = scout.outline(root)
|
|
1776
|
+
raw = _research_llm_call(model, model_client.get_token(), scout.PLAN_SYSTEM,
|
|
1777
|
+
scout.plan_prompt(question, folders, root_files), scout.PLAN_TOOL,
|
|
1778
|
+
timeout=SCOUT_MODEL_TIMEOUT_S)
|
|
1779
|
+
terms, picked = scout.valid_plan(raw, folders)
|
|
1780
|
+
rows, _scanned, _total = _scout_scan(root, picked, " ".join(terms))
|
|
1781
|
+
return terms, rows
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _scout_scan(root, include, query):
|
|
1785
|
+
"""`(rows, scanned, total)`: the files under the `include` folders (index exclusions applied, root files always
|
|
1786
|
+
in, '' = the whole project) holding a term of `query`, read from disk within COLD_SCAN_BUDGET_S and ranked by
|
|
1787
|
+
keyword like the cold search."""
|
|
1788
|
+
files = index_scope.resolve_included_files(root, {"include": include})[:SCOUT_SCAN_MAX_FILES]
|
|
1789
|
+
chunks, scanned = indexer.keyword_chunks(root, files, [query], COLD_SCAN_BUDGET_S)
|
|
1790
|
+
found = indexer.search(root, None, top_k=scout.MAX_FILES * 2, query=query, overlay={"drop": [], "chunks": chunks})
|
|
1791
|
+
return ([{"path": path, "lines": f"{start}-{end}", "snippet": text[:500]} for _score, path, start, end, text in found],
|
|
1792
|
+
scanned, len(files))
|
|
1793
|
+
|
|
1794
|
+
|
|
1617
1795
|
def _project_payload(project):
|
|
1618
1796
|
data = dict(project)
|
|
1619
1797
|
data['dismissed_duplicates'] = len(data.get('dismissed_duplicates') or {})
|
|
@@ -3773,6 +3951,7 @@ _TOOL_HANDLERS = {
|
|
|
3773
3951
|
"project_manage": _handle_project_manage,
|
|
3774
3952
|
"smart_search": _handle_smart_search,
|
|
3775
3953
|
"smart_search_result": _handle_smart_search_result,
|
|
3954
|
+
"smart_scout": _handle_smart_scout,
|
|
3776
3955
|
"web_search": _handle_web_search,
|
|
3777
3956
|
"web_search_result": _handle_web_search_result,
|
|
3778
3957
|
}
|
package/usage_meter.py
CHANGED
|
@@ -11,7 +11,7 @@ import project_store
|
|
|
11
11
|
|
|
12
12
|
_CONTEXT=contextvars.ContextVar('smart_usage',default=None)
|
|
13
13
|
_PHASES={'scope':'scope','embedding_query':'query_embedding','model_probe':'embedding_probe',
|
|
14
|
-
'indexing':'index_embedding','reranking':'rerank'}
|
|
14
|
+
'indexing':'index_embedding','reranking':'rerank','scout':'scout'}
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
@contextlib.contextmanager
|
package/version.py
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
caught here and turned into a failing exit, and the browser executable must exist afterwards. The 1.3 GB download
|
|
3
3
|
goes through ranged_download (Range requests that resume after a dropped or stalled connection; camoufox still checks
|
|
4
4
|
the sha256), its other requests get a timeout (requests passes None, which also overrides socket.setdefaulttimeout),
|
|
5
|
-
and progress is printed as plain lines (the rich bar stays invisible in a pipe).
|
|
5
|
+
and progress is printed as plain lines (the rich bar stays invisible in a pipe). requests trusts only certifi's bundle, so on
|
|
6
|
+
a network that inspects TLS with a corporate root CA its release lookups fail; the Windows certificate store is used
|
|
7
|
+
instead (truststore), unless the user already set a CA bundle variable."""
|
|
8
|
+
import os
|
|
6
9
|
import sys
|
|
7
10
|
from io import BytesIO
|
|
8
11
|
from pathlib import Path
|
|
@@ -14,6 +17,7 @@ from camoufox import pkgman
|
|
|
14
17
|
import ranged_download
|
|
15
18
|
|
|
16
19
|
TIMEOUT_S = (30, 60)
|
|
20
|
+
CA_VARIABLES = ("REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE")
|
|
17
21
|
REPORT_EVERY_MB = 50
|
|
18
22
|
_update = camoufox_cli.CamoufoxUpdate.update
|
|
19
23
|
_get = requests.get
|
|
@@ -59,7 +63,18 @@ def installed_executable():
|
|
|
59
63
|
return executable
|
|
60
64
|
|
|
61
65
|
|
|
66
|
+
def trust_system_certificates():
|
|
67
|
+
"""Makes ssl (and so requests) verify against the Windows certificate store; False and untouched when the user set
|
|
68
|
+
REQUESTS_CA_BUNDLE, SSL_CERT_FILE or CURL_CA_BUNDLE."""
|
|
69
|
+
if any(os.environ.get(name) for name in CA_VARIABLES):
|
|
70
|
+
return False
|
|
71
|
+
import truststore
|
|
72
|
+
truststore.inject_into_ssl()
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
62
76
|
def main():
|
|
77
|
+
trust_system_certificates()
|
|
63
78
|
requests.get = _get_with_timeout
|
|
64
79
|
pkgman.webdl = _ranged_webdl
|
|
65
80
|
camoufox_cli.CamoufoxUpdate.update = _recording_update
|