@allansantos-dev/smart-tool 0.9.43 → 0.9.46
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 +53 -0
- package/README.md +25 -5
- package/autostart.py +5 -3
- package/browser_control.py +71 -15
- package/client_hooks.py +2 -2
- package/config.py +12 -0
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/folder_picker.py +7 -5
- package/hook_decision.py +9 -1
- package/i18n.py +213 -0
- package/lazy_mcp_proxy.py +185 -0
- package/locales/pt-BR.json +923 -0
- package/package.json +1 -1
- package/projects_ui.py +7 -2
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/setup_audit.py +5 -3
- package/setup_ui.py +15 -2
- package/smart_tool_daemon.py +69 -10
- package/toast_notice.py +3 -2
- package/tool_redirect.py +2 -1
- package/tray.py +106 -49
- package/update_check.py +28 -10
- package/update_splash.py +197 -0
- package/version.py +1 -1
- package/web_search_health.py +5 -2
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.46",
|
|
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/projects_ui.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
"""Tela local de projetos; todas as operações passam pelos jobs do daemon."""
|
|
2
2
|
import html
|
|
3
3
|
import re
|
|
4
|
+
import i18n
|
|
4
5
|
import setup_ui
|
|
5
6
|
import project_maps_ui
|
|
6
7
|
import project_insights_ui
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def render_projects_page():
|
|
11
|
+
"""The projects page with its token, map and insight components and the interface language script."""
|
|
10
12
|
return (_PAGE.replace("__TOKEN__", html.escape(setup_ui.SETUP_TOKEN, quote=True))
|
|
13
|
+
.replace("__I18N__", i18n.page_script(setup_ui.SETUP_TOKEN))
|
|
11
14
|
.replace("__MAP_STYLE__", project_maps_ui.STYLE)
|
|
12
15
|
.replace("__MAP_HTML__", project_maps_ui.HTML)
|
|
13
16
|
.replace("__MAP_SCRIPT__", project_maps_ui.SCRIPT)
|
|
@@ -24,13 +27,14 @@ def render_map_window():
|
|
|
24
27
|
'__STYLE__': style.replace('__MAP_STYLE__', project_maps_ui.STYLE).replace('__INSIGHTS_STYLE__', project_insights_ui.STYLE),
|
|
25
28
|
'__MAP_HTML__': project_maps_ui.HTML,
|
|
26
29
|
'__MAP_SCRIPT__': project_maps_ui.SCRIPT,
|
|
30
|
+
'__I18N__': i18n.page_script(setup_ui.SETUP_TOKEN),
|
|
27
31
|
}
|
|
28
32
|
return re.sub('|'.join(re.escape(key) for key in values), lambda m:values[m[0]], _MAP_WINDOW)
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
_MAP_WINDOW = r'''<!doctype html>
|
|
32
36
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
33
|
-
<meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title
|
|
37
|
+
<meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title>__I18N__<style>
|
|
34
38
|
__STYLE__
|
|
35
39
|
body.map-window{padding:0}.map-window header,.map-window main{max-width:none}.map-window header{padding:22px 28px 14px}.map-window main{padding:0 20px 30px}.map-window h1{font-size:22px}.map-window #window-root{margin:6px 0 0;font-size:12px;overflow-wrap:anywhere}.map-window .maps{margin-top:0}.map-window .maps>summary{display:none}.map-window .maps-workspace{grid-template-columns:minmax(0,1fr) 285px}.map-window #maps-svg{height:calc(100vh - 360px);min-height:430px}.map-window .maps-tools #maps-new-window{display:none}.map-window #maps-color-legend{max-height:90px}.map-window #window-message:empty{display:none}.map-window #window-message{margin-bottom:14px;overflow-wrap:anywhere}
|
|
36
40
|
@media(max-width:850px){.map-window .maps-workspace{grid-template-columns:1fr}.map-window header{padding-inline:18px}.map-window main{padding-inline:12px}.map-window #maps-svg{height:450px;min-height:0}}
|
|
@@ -51,6 +55,7 @@ _PAGE = r'''<!doctype html>
|
|
|
51
55
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
52
56
|
<meta name="setup-token" content="__TOKEN__">
|
|
53
57
|
<title>Projects and indexing · Smart Tool</title>
|
|
58
|
+
__I18N__
|
|
54
59
|
<style>
|
|
55
60
|
:root{color-scheme:light dark;--bg:#f6f7f9;--surface:#fff;--border:#dde1e6;--ink:#1b2430;--muted:#5b6472;--accent:#2f6fed;--good:#18723c;--bad:#b52c2c;--warn:#805300;--selected:#eaf1ff}
|
|
56
61
|
@media(prefers-color-scheme:dark){:root{--bg:#14171c;--surface:#1c2027;--border:#39414e;--ink:#e6e9ee;--muted:#a6afbb;--accent:#91baff;--good:#68dc93;--bad:#ff9696;--warn:#e0b565;--selected:#263751}}
|
|
@@ -96,7 +101,7 @@ function setAdd(open){const restore=$('add-form').contains(document.activeElemen
|
|
|
96
101
|
function select(id){selected=id;scopeKey='';scopeDirty=false;window.SmartMaps?.reset();window.SmartInsights?.reset();history.replaceState(null,'','#'+id);render()}
|
|
97
102
|
function jobsHtml(rows){return rows.map(j=>`<div class="job"><div class="job-line"><strong>${esc(kindNames[j.kind]||j.kind)}</strong><span class="state ${j.status==='error'?'bad':''}">${esc(states[j.status]||j.status)}</span></div>${j.message?`<p>${esc(j.cancel_requested?'Cancellation requested; waiting for the current call to finish.':j.message)}</p>`:''}${j.status==='pending'?`<progress aria-label="Indexing progress" ${j.total_files>0&&j.processed_files!=null?`max="${j.total_files}" value="${j.processed_files}"`:''}></progress>`:''}${j.error?`<p class="state bad">${esc(j.error)}</p>`:''}${j.warning?`<p>${esc(j.warning)}</p>`:''}<small>${esc(j.job_id)}${j.elapsed_s!=null?' · '+j.elapsed_s+' s':''}</small></div>`).join('')}
|
|
98
103
|
function render(){
|
|
99
|
-
$('count').textContent=projects.length
|
|
104
|
+
$('count').textContent=projects.length===1?'1 folder registered':projects.length+' folders registered';
|
|
100
105
|
const list=$('project-list');
|
|
101
106
|
for(const node of [...list.children])if(!projects.some(p=>p.id===node.dataset.project))node.remove();
|
|
102
107
|
for(const p of projects){let button=list.querySelector(`[data-project="${p.id}"]`);if(!button){button=document.createElement('button');button.className='project-item';button.dataset.project=p.id;button.append(document.createElement('strong'),document.createElement('small'));list.append(button)}button.setAttribute('aria-current',String(p.id===selected));button.firstChild.textContent=p.name;button.lastChild.textContent=(states[p.status]||p.status)+(p.watch&&!p.paused?' · Watching':'')}
|
package/requester.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Who asked for a web search or fetch, recorded next to each provider call and research job, never the query text.
|
|
2
|
+
|
|
3
|
+
An MCP client only sends `clientInfo` (name, version) and the daemon's own `Mcp-Session-Id`: no session or folder. The
|
|
4
|
+
PreToolUse hook of Claude Code does carry `session_id` and `cwd` before every tool call it matches, so the hook route
|
|
5
|
+
notes them here (note_hook) and the MCP call that follows claims them by tool and arguments (claim). The first claim
|
|
6
|
+
binds that MCP session to the Claude session and folder, so later calls of the same MCP session are attributed even
|
|
7
|
+
when the hook did not fire for them. A client without that hook (Codex, or a Claude Code whose hook predates the
|
|
8
|
+
web tools) leaves `session` and `project` empty and `source` says how far the attribution got.
|
|
9
|
+
"""
|
|
10
|
+
import collections
|
|
11
|
+
import contextvars
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
WEB_TOOLS = ("web_search", "web_fetch")
|
|
17
|
+
NOTE_TTL_S = 60
|
|
18
|
+
MAX_NOTES = 256
|
|
19
|
+
MAX_BOUND = 256
|
|
20
|
+
|
|
21
|
+
CURRENT = contextvars.ContextVar("requester", default=None)
|
|
22
|
+
_LOCK = threading.Lock()
|
|
23
|
+
_NOTES = collections.OrderedDict()
|
|
24
|
+
_BOUND = collections.OrderedDict()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _key(tool, arguments):
|
|
28
|
+
return tool + "\0" + json.dumps(arguments, sort_keys=True, ensure_ascii=False, default=str)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def note_hook(tool, arguments, session_id, cwd):
|
|
32
|
+
"""Remembers who the PreToolUse hook saw about to call `tool` with `arguments` (the MCP tool name without the
|
|
33
|
+
server prefix); claim() takes it back within NOTE_TTL_S."""
|
|
34
|
+
if tool not in WEB_TOOLS or not isinstance(arguments, dict):
|
|
35
|
+
return
|
|
36
|
+
entry = (time.monotonic(), session_id if isinstance(session_id, str) else None, cwd if isinstance(cwd, str) else None)
|
|
37
|
+
with _LOCK:
|
|
38
|
+
_NOTES.setdefault(_key(tool, arguments), collections.deque()).append(entry)
|
|
39
|
+
while len(_NOTES) > MAX_NOTES:
|
|
40
|
+
_NOTES.popitem(last=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def claim(mcp_session, client, tool, arguments):
|
|
44
|
+
"""The requester of one MCP tool call: {client, mcp_session, session, project, source}. source is `hook` when the
|
|
45
|
+
hook noted this very call, `mcp-session` when only an earlier call of the same MCP session was attributed, `none`
|
|
46
|
+
when the client gave nothing beyond its name."""
|
|
47
|
+
now = time.monotonic()
|
|
48
|
+
found = None
|
|
49
|
+
with _LOCK:
|
|
50
|
+
queue = _NOTES.get(_key(tool, arguments))
|
|
51
|
+
while queue and now - queue[0][0] > NOTE_TTL_S:
|
|
52
|
+
queue.popleft()
|
|
53
|
+
if queue:
|
|
54
|
+
found = queue.popleft()
|
|
55
|
+
if queue is not None and not queue:
|
|
56
|
+
del _NOTES[_key(tool, arguments)]
|
|
57
|
+
if found and mcp_session:
|
|
58
|
+
_BOUND[mcp_session] = (found[1], found[2])
|
|
59
|
+
while len(_BOUND) > MAX_BOUND:
|
|
60
|
+
_BOUND.popitem(last=False)
|
|
61
|
+
bound = _BOUND.get(mcp_session)
|
|
62
|
+
session, project = (found[1:] if found else bound) or (None, None)
|
|
63
|
+
return {"client": client or None, "mcp_session": (mcp_session or "")[:12] or None, "session": session,
|
|
64
|
+
"project": project, "source": "hook" if found else ("mcp-session" if bound else "none")}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def from_hook(client, payload):
|
|
68
|
+
"""The requester of a native WebSearch/WebFetch the hook is about to redirect: the payload itself names the session
|
|
69
|
+
and folder, no MCP call is involved."""
|
|
70
|
+
return {"client": client or None, "mcp_session": None, "session": payload.get("session_id"),
|
|
71
|
+
"project": payload.get("cwd"), "source": "hook"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def current():
|
|
75
|
+
"""The requester of the tool call this thread (or a context copied from it) is serving, or None outside one."""
|
|
76
|
+
return CURRENT.get()
|
package/research_metrics.py
CHANGED
|
@@ -5,8 +5,9 @@ reaproveitamento, e `research_max_iterations` limita o retorno por rodada. Write
|
|
|
5
5
|
`router-metrics.jsonl` — nada no daemon lê isto de volta; quem lê é
|
|
6
6
|
`.token-guard/analyze_research.py`.
|
|
7
7
|
|
|
8
|
-
Grava só o que a calibração usa: textos de consulta/tópico truncados e números
|
|
9
|
-
|
|
8
|
+
Grava só o que a calibração usa: textos de consulta/tópico truncados e números, mais o
|
|
9
|
+
`requester` (sessão e pasta de quem pediu, ver requester.py). Nunca snippets nem páginas,
|
|
10
|
+
que são conteúdo de terceiros e não ajudam a decidir o valor.
|
|
10
11
|
"""
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
@@ -15,6 +16,7 @@ import threading
|
|
|
15
16
|
import time
|
|
16
17
|
|
|
17
18
|
import atomic_io
|
|
19
|
+
import requester
|
|
18
20
|
|
|
19
21
|
METRICS_PATH = os.path.join(paths.DATA_DIR, "research-metrics.jsonl")
|
|
20
22
|
METRICS_MAX_BYTES = 2 * 1024 * 1024
|
|
@@ -34,7 +36,7 @@ def _short(text):
|
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def _append(record):
|
|
37
|
-
record = {"ts": time.time(), **record}
|
|
39
|
+
record = {"ts": time.time(), **({"requester": requester.current()} if requester.current() else {}), **record}
|
|
38
40
|
try:
|
|
39
41
|
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
40
42
|
with _LOCK:
|
package/setup_audit.py
CHANGED
|
@@ -31,6 +31,7 @@ import yaml
|
|
|
31
31
|
|
|
32
32
|
import atomic_io
|
|
33
33
|
import endpoint_sync
|
|
34
|
+
import i18n
|
|
34
35
|
import paths
|
|
35
36
|
|
|
36
37
|
REPORT_DIR = os.path.join(paths.DATA_DIR, "setup-audit")
|
|
@@ -904,13 +905,14 @@ def notices_due():
|
|
|
904
905
|
|
|
905
906
|
|
|
906
907
|
def toast_text(row):
|
|
907
|
-
"""(title, lines) of the toast for one `notices_due` row."""
|
|
908
|
+
"""(title, lines) of the toast for one `notices_due` row, in the interface language."""
|
|
908
909
|
total = row["errors"] + row["warnings"]
|
|
909
|
-
title =
|
|
910
|
+
title = (i18n.t("Setup audit: 1 new problem in the agent configuration") if total == 1
|
|
911
|
+
else i18n.t("Setup audit: {count} new problems in the agent configuration", count=total))
|
|
910
912
|
lines = [os.path.basename(row["project"].rstrip("\\/")) or row["project"]]
|
|
911
913
|
lines += [f"{item['severity']}: {item['check']} - {item['subject']}"[:120] for item in row["items"][:3]]
|
|
912
914
|
if len(row["items"]) > 3:
|
|
913
|
-
lines.append(
|
|
915
|
+
lines.append(i18n.t("and {count} more", count=len(row["items"]) - 3))
|
|
914
916
|
return title, lines
|
|
915
917
|
|
|
916
918
|
|
package/setup_ui.py
CHANGED
|
@@ -28,6 +28,7 @@ import config
|
|
|
28
28
|
import daemon_launcher
|
|
29
29
|
import endpoint_sync
|
|
30
30
|
import gateway
|
|
31
|
+
import i18n
|
|
31
32
|
import model_catalog
|
|
32
33
|
import model_defaults
|
|
33
34
|
import model_health
|
|
@@ -101,6 +102,7 @@ def render_setup_page():
|
|
|
101
102
|
# Único valor que entra no corpo do HTML, e não no <script>: escapado porque vem
|
|
102
103
|
# de variável de ambiente, ainda que só quem já executa código possa definí-la.
|
|
103
104
|
"__SETUP_ORIGIN__": html.escape(setup_origin(), quote=True),
|
|
105
|
+
"__I18N__": i18n.page_script(SETUP_TOKEN),
|
|
104
106
|
}
|
|
105
107
|
# Um único passe sobre o template original: .replace() encadeado re-escanearia o
|
|
106
108
|
# próprio resultado e corromperia o JSON se um valor coincidisse com o outro placeholder.
|
|
@@ -419,6 +421,16 @@ def handle_providers_save(body):
|
|
|
419
421
|
"validated_results": validated}
|
|
420
422
|
|
|
421
423
|
|
|
424
|
+
def handle_language_save(body):
|
|
425
|
+
"""Saves the interface language the language button picked; returns {"language": <saved>}."""
|
|
426
|
+
if not isinstance(body, dict):
|
|
427
|
+
raise SetupError("Request body must be a JSON object with 'language'.")
|
|
428
|
+
try:
|
|
429
|
+
return {"language": i18n.set_language(body.get("language"))}
|
|
430
|
+
except ValueError as exc:
|
|
431
|
+
raise SetupError(str(exc)) from exc
|
|
432
|
+
|
|
433
|
+
|
|
422
434
|
def handle_autostart_get():
|
|
423
435
|
return autostart.status()
|
|
424
436
|
|
|
@@ -494,6 +506,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
494
506
|
<meta charset="utf-8">
|
|
495
507
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
496
508
|
<title>Settings · Smart Tool</title>
|
|
509
|
+
__I18N__
|
|
497
510
|
<style>
|
|
498
511
|
:root { color-scheme: light dark;
|
|
499
512
|
--bg: #f6f7f9; --surface: #ffffff; --line: #dde1e6; --ink: #1b2430; --muted: #5b6472; --soft: #eef1f5;
|
|
@@ -665,7 +678,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
665
678
|
</head>
|
|
666
679
|
<body>
|
|
667
680
|
<header class="top">
|
|
668
|
-
<div><h1>Smart Tool settings</h1><span class="where">__SETUP_ORIGIN__ ·
|
|
681
|
+
<div><h1>Smart Tool settings</h1><span class="where">__SETUP_ORIGIN__ · <span>Version</span> <span id="app-version"></span></span></div>
|
|
669
682
|
<nav><a href="/projects">Projects and indexing →</a></nav>
|
|
670
683
|
</header>
|
|
671
684
|
<div class="shell">
|
|
@@ -1010,7 +1023,7 @@ function renderOverview() {
|
|
|
1010
1023
|
badge.hidden = !entry;
|
|
1011
1024
|
badge.textContent = entry ? entry.count : "";
|
|
1012
1025
|
badge.dataset.tone = entry && entry.bad ? "bad" : "warn";
|
|
1013
|
-
badge.title = entry ?
|
|
1026
|
+
badge.title = !entry ? "" : entry.count === 1 ? "1 item needs attention" : entry.count + " items need attention";
|
|
1014
1027
|
}
|
|
1015
1028
|
}
|
|
1016
1029
|
|
package/smart_tool_daemon.py
CHANGED
|
@@ -50,6 +50,7 @@ import indexer
|
|
|
50
50
|
import index_views
|
|
51
51
|
import index_inventory
|
|
52
52
|
import usage_meter
|
|
53
|
+
import document_read
|
|
53
54
|
import document_text
|
|
54
55
|
import dossier
|
|
55
56
|
import scout
|
|
@@ -60,6 +61,7 @@ import project_identity
|
|
|
60
61
|
import project_store
|
|
61
62
|
import project_scheduler
|
|
62
63
|
import project_monitor
|
|
64
|
+
import requester
|
|
63
65
|
import research_metrics
|
|
64
66
|
import research_cache
|
|
65
67
|
import router
|
|
@@ -350,7 +352,9 @@ SERVER_INSTRUCTIONS = (
|
|
|
350
352
|
"project_manage action=affected_tests lists the tests to run. When smart_search returns nothing, retry smart_search with "
|
|
351
353
|
"other terms; do not switch to Grep, Read of folders or shell search. To answer a question that would mean reading "
|
|
352
354
|
"several files, call smart_scout: it reads them inside Smart Tool and returns only the lines that answer, as "
|
|
353
|
-
"path:lines with the literal text."
|
|
355
|
+
"path:lines with the literal text. To read a PDF, Word, PowerPoint or Excel file, call read_document (load it with "
|
|
356
|
+
"ToolSearch if it is not in your tool list): it returns the page count, size and estimated tokens first, then the "
|
|
357
|
+
"text of only the pages, slides or sheets you ask for, in far fewer tokens than the whole file."
|
|
354
358
|
)
|
|
355
359
|
|
|
356
360
|
TOOLS = [
|
|
@@ -540,6 +544,31 @@ TOOLS = [
|
|
|
540
544
|
"required": ["url", "prompt"],
|
|
541
545
|
},
|
|
542
546
|
},
|
|
547
|
+
{
|
|
548
|
+
"name": "read_document",
|
|
549
|
+
"description": (
|
|
550
|
+
"Reads a PDF, Word (.docx), PowerPoint (.pptx) or Excel (.xlsx) file as compact text, a range at a time, "
|
|
551
|
+
"inside Smart Tool so the file never enters your context whole. The answer opens with the facts: type, "
|
|
552
|
+
"size, page, slide, sheet or part count and the estimated tokens of all the text; then comes the text of "
|
|
553
|
+
"the range you asked for (default: from the start, up to max_chars) with a `next:` line naming the range "
|
|
554
|
+
"to continue. PDF pages come with the layout noise removed (ligatures, hyphenation, wrapped lines); docx "
|
|
555
|
+
"as markdown (headings, lists, tables) in parts of about 6,000 characters because a docx has no fixed "
|
|
556
|
+
"pages; pptx per slide with the speaker notes; xlsx per sheet, one tab-separated line per row with the "
|
|
557
|
+
"cached formula values. Use info_only first to size a large file and pick the pages you need. A scanned "
|
|
558
|
+
"PDF (no text layer) fails with that reason; there is no OCR. Not for plain text or code: use Read."
|
|
559
|
+
),
|
|
560
|
+
"inputSchema": {
|
|
561
|
+
"type": "object",
|
|
562
|
+
"properties": {
|
|
563
|
+
"path": {"type": "string", "description": "Absolute path of the .pdf, .docx, .pptx or .xlsx file"},
|
|
564
|
+
"range": {"type": "string", "description": "Pages, slides, docx parts or sheets to read: `3`, `3-5`, `7-` (to the end), `-4` (the first four) or a comma list like `1,4-6`; a sheet name also works for a workbook. Default: from the first one"},
|
|
565
|
+
"max_chars": {"type": "integer", "minimum": 1000, "maximum": document_read.MAX_CHARS_LIMIT, "default": document_read.DEFAULT_MAX_CHARS, "description": "Cap of the text returned (about four characters per token)"},
|
|
566
|
+
"info_only": {"type": "boolean", "default": False, "description": "Return only the facts (type, size, counts, estimated tokens), no text"},
|
|
567
|
+
},
|
|
568
|
+
"required": ["path"],
|
|
569
|
+
"additionalProperties": False,
|
|
570
|
+
},
|
|
571
|
+
},
|
|
543
572
|
{
|
|
544
573
|
"name": "web_search_result",
|
|
545
574
|
"_meta": ALWAYS_LOAD,
|
|
@@ -2615,14 +2644,15 @@ def _fan_out_web_search(query, num, budget_s=None, site_domains=()):
|
|
|
2615
2644
|
for tier_name in web_search_adapters.FAN_OUT_TIERS:
|
|
2616
2645
|
fut = WEB_SEARCH_EXECUTOR.submit(_run_tier, tier_name, query, num)
|
|
2617
2646
|
futures[fut] = tier_name
|
|
2647
|
+
who = requester.current() # the callbacks run in the pool's threads, outside this request's context
|
|
2618
2648
|
|
|
2619
2649
|
def _record_on_done(tier_name):
|
|
2620
2650
|
def _cb(fut):
|
|
2621
2651
|
try:
|
|
2622
2652
|
fut.result()
|
|
2623
|
-
_record_tier_health(tier_name, success=True)
|
|
2653
|
+
_record_tier_health(tier_name, success=True, who=who)
|
|
2624
2654
|
except Exception as exc:
|
|
2625
|
-
_record_tier_failure(tier_name, exc)
|
|
2655
|
+
_record_tier_failure(tier_name, exc, who)
|
|
2626
2656
|
return _cb
|
|
2627
2657
|
|
|
2628
2658
|
for fut, tier_name in futures.items():
|
|
@@ -2690,7 +2720,7 @@ def _handle_web_search_body(arguments):
|
|
|
2690
2720
|
job_id = uuid.uuid4().hex
|
|
2691
2721
|
_set_job(job_id, status="pending", result=None, error=None, created_at=time.time())
|
|
2692
2722
|
try:
|
|
2693
|
-
DEEP_JOB_EXECUTOR.submit(_run_deep_job, job_id, query, scope)
|
|
2723
|
+
DEEP_JOB_EXECUTOR.submit(contextvars.copy_context().run, _run_deep_job, job_id, query, scope)
|
|
2694
2724
|
except Exception as exc:
|
|
2695
2725
|
# Se o submit() em si falhar (pool fechado/saturado), o job já foi
|
|
2696
2726
|
# registrado como "pending" — sem isto, ficaria assim pra sempre,
|
|
@@ -2702,7 +2732,7 @@ def _handle_web_search_body(arguments):
|
|
|
2702
2732
|
job_id = uuid.uuid4().hex
|
|
2703
2733
|
_set_job(job_id, status="pending", result=None, error=None, created_at=time.time())
|
|
2704
2734
|
try:
|
|
2705
|
-
RESEARCH_JOB_EXECUTOR.submit(_run_research_job, job_id, query, num, scope)
|
|
2735
|
+
RESEARCH_JOB_EXECUTOR.submit(contextvars.copy_context().run, _run_research_job, job_id, query, num, scope)
|
|
2706
2736
|
except Exception as exc:
|
|
2707
2737
|
_set_job(job_id, status="error", result=None, error=_sanitize_text(f"{type(exc).__name__}: {exc}"), created_at=time.time())
|
|
2708
2738
|
return _to_json_sanitized({"job_id": job_id, "status": "pending"})
|
|
@@ -3210,14 +3240,14 @@ def _annotate_quick_results(rows, classification, cache=None):
|
|
|
3210
3240
|
return result
|
|
3211
3241
|
|
|
3212
3242
|
|
|
3213
|
-
def _record_tier_health(tier_name, success, outcome=None):
|
|
3243
|
+
def _record_tier_health(tier_name, success, outcome=None, who=None):
|
|
3214
3244
|
try:
|
|
3215
|
-
web_search_health.record(tier_name, success, outcome)
|
|
3245
|
+
web_search_health.record(tier_name, success, outcome, who or requester.current())
|
|
3216
3246
|
except Exception:
|
|
3217
3247
|
pass # telemetria é best-effort — nunca pode derrubar uma busca já resolvida
|
|
3218
3248
|
|
|
3219
3249
|
|
|
3220
|
-
def _record_tier_failure(tier_name, exc):
|
|
3250
|
+
def _record_tier_failure(tier_name, exc, who=None):
|
|
3221
3251
|
"""Health entry for a failed tier call, keeping a 429, a captcha and a quota pause apart from other errors."""
|
|
3222
3252
|
if isinstance(exc, (TierSkipped, browser_service_client.BrowserSkipped)):
|
|
3223
3253
|
outcome = "paused"
|
|
@@ -3227,7 +3257,7 @@ def _record_tier_failure(tier_name, exc):
|
|
|
3227
3257
|
outcome = "captcha"
|
|
3228
3258
|
else:
|
|
3229
3259
|
outcome = "failed"
|
|
3230
|
-
_record_tier_health(tier_name, False, outcome)
|
|
3260
|
+
_record_tier_health(tier_name, False, outcome, who)
|
|
3231
3261
|
|
|
3232
3262
|
|
|
3233
3263
|
_is_safe_crawl_target = web_fetch.is_safe_target
|
|
@@ -3979,7 +4009,29 @@ def _session_audit_hook(handler):
|
|
|
3979
4009
|
return output
|
|
3980
4010
|
|
|
3981
4011
|
|
|
4012
|
+
def _handle_read_document(arguments):
|
|
4013
|
+
"""read_document: facts and a page, slide, sheet or part range of a PDF, docx, pptx or xlsx as compact text (see
|
|
4014
|
+
document_read). Fails loudly, with the reason, for a path that is not absolute or readable, an unsupported or
|
|
4015
|
+
oversized file, a password, a scanned PDF, a corrupt or hostile package and a range outside the document; one metric
|
|
4016
|
+
row records the kind, the estimated tokens of the whole text and of the answer."""
|
|
4017
|
+
started = time.monotonic()
|
|
4018
|
+
unknown = sorted(set(arguments) - {"path", "range", "max_chars", "info_only"})
|
|
4019
|
+
if unknown:
|
|
4020
|
+
raise ValueError(f"read_document does not take {', '.join(unknown)}")
|
|
4021
|
+
try:
|
|
4022
|
+
text = document_read.read(arguments.get("path"), arguments.get("range") or None,
|
|
4023
|
+
arguments.get("max_chars", document_read.DEFAULT_MAX_CHARS),
|
|
4024
|
+
bool(arguments.get("info_only")))
|
|
4025
|
+
except Exception as exc:
|
|
4026
|
+
_metric("read_document", error=type(exc).__name__, elapsed_s=round(time.monotonic() - started, 2))
|
|
4027
|
+
raise
|
|
4028
|
+
_metric("read_document", kind=os.path.splitext(str(arguments.get("path")))[1].lower(),
|
|
4029
|
+
answer_tokens=document_read.estimate_tokens(text), elapsed_s=round(time.monotonic() - started, 2))
|
|
4030
|
+
return _sanitize_text(text)
|
|
4031
|
+
|
|
4032
|
+
|
|
3982
4033
|
_TOOL_HANDLERS = {
|
|
4034
|
+
"read_document": _handle_read_document,
|
|
3983
4035
|
"web_fetch": _handle_web_fetch,
|
|
3984
4036
|
"project_manage": _handle_project_manage,
|
|
3985
4037
|
"smart_search": _handle_smart_search,
|
|
@@ -4002,7 +4054,8 @@ def _metric(event, **fields):
|
|
|
4002
4054
|
_session, client = _session_client()
|
|
4003
4055
|
try:
|
|
4004
4056
|
integration_metrics.record(event, client=client_hooks.identify(client) or client.get("name"),
|
|
4005
|
-
client_version=client.get("version"),
|
|
4057
|
+
client_version=client.get("version"),
|
|
4058
|
+
**({"requester": requester.current()} if requester.current() else {}), **fields)
|
|
4006
4059
|
except (OSError, ValueError) as exc:
|
|
4007
4060
|
_log(f"integration metric not saved: {exc}")
|
|
4008
4061
|
|
|
@@ -4016,6 +4069,9 @@ def _dispatch_tool_call(params, session_id=None):
|
|
|
4016
4069
|
if handler is None:
|
|
4017
4070
|
return _tool_result(_sanitize_text(f"Unknown tool: {name}"), is_error=True)
|
|
4018
4071
|
token = _MCP_SESSION.set(session_id)
|
|
4072
|
+
who = None
|
|
4073
|
+
if name in requester.WEB_TOOLS:
|
|
4074
|
+
who = requester.CURRENT.set(requester.claim(session_id, _session_client()[1].get("name"), name, arguments))
|
|
4019
4075
|
try:
|
|
4020
4076
|
with model_health.tool_call(name) as failures:
|
|
4021
4077
|
try:
|
|
@@ -4031,6 +4087,8 @@ def _dispatch_tool_call(params, session_id=None):
|
|
|
4031
4087
|
result["content"][0]["text"] += _sanitize_text(warning)
|
|
4032
4088
|
return result
|
|
4033
4089
|
finally:
|
|
4090
|
+
if who is not None:
|
|
4091
|
+
requester.CURRENT.reset(who)
|
|
4034
4092
|
_MCP_SESSION.reset(token)
|
|
4035
4093
|
|
|
4036
4094
|
|
|
@@ -4105,6 +4163,7 @@ _SETUP_SAVES = {
|
|
|
4105
4163
|
"/setup/gateway": setup_ui.handle_gateway_save,
|
|
4106
4164
|
"/setup/autostart": setup_ui.handle_autostart_action,
|
|
4107
4165
|
"/setup/contact": setup_ui.handle_contact_save,
|
|
4166
|
+
"/setup/language": setup_ui.handle_language_save,
|
|
4108
4167
|
"/setup/audit": setup_ui.handle_audit,
|
|
4109
4168
|
}
|
|
4110
4169
|
|
package/toast_notice.py
CHANGED
|
@@ -74,13 +74,14 @@ def action_uri(action, **params):
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def toast_xml(title, lines, buttons):
|
|
77
|
-
"""`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast.
|
|
77
|
+
"""`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast. An empty list
|
|
78
|
+
gives a toast without an actions element."""
|
|
78
79
|
text = "".join(f"<text>{escape(line)}</text>" for line in [title, *lines])
|
|
79
80
|
actions = "".join(
|
|
80
81
|
f'<action content={quoteattr(label)} arguments={quoteattr(uri)} activationType="protocol"/>'
|
|
81
82
|
for label, uri in buttons)
|
|
82
83
|
return (f'<toast><visual><binding template="ToastGeneric">{text}</binding></visual>'
|
|
83
|
-
f'<actions>{actions}</actions
|
|
84
|
+
+ (f'<actions>{actions}</actions>' if actions else '') + '</toast>')
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
def show(title, lines, buttons, tag=None):
|
package/tool_redirect.py
CHANGED
|
@@ -10,6 +10,7 @@ the PreToolUse is rewritten to a no-op that always succeeds (a grep with no matc
|
|
|
10
10
|
replacement: its shell call is rewritten to print a file holding the answer, which Codex only applies with `allow`.
|
|
11
11
|
"""
|
|
12
12
|
import concurrent.futures
|
|
13
|
+
import contextvars
|
|
13
14
|
import os
|
|
14
15
|
import threading
|
|
15
16
|
import time
|
|
@@ -40,7 +41,7 @@ class Jobs:
|
|
|
40
41
|
for old in [k for k, (at, _f) in self._futures.items() if now - at > RESULT_TTL_S]:
|
|
41
42
|
del self._futures[old]
|
|
42
43
|
if key not in self._futures:
|
|
43
|
-
self._futures[key] = (now, _POOL.submit(self._runners[kind], arguments))
|
|
44
|
+
self._futures[key] = (now, _POOL.submit(contextvars.copy_context().run, self._runners[kind], arguments))
|
|
44
45
|
|
|
45
46
|
def wait(self, key, timeout):
|
|
46
47
|
"""The text of key's call when it finished within timeout seconds, else None (the call keeps running and a
|