@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.43",
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><style>
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+' folder'+(projects.length===1?' registered':'s registered');
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()
@@ -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. Nunca
9
- snippets nem páginas, que são conteúdo de terceiros e não ajudam a decidir o valor.
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 = f"Setup audit: {total} new problem{'s' if total != 1 else ''} in the agent configuration"
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(f"and {len(row['items']) - 3} more")
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__ · version <span id="app-version"></span></span></div>
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 ? plural(entry.count, "item") + (entry.count === 1 ? " needs" : " need") + " attention" : "";
1026
+ badge.title = !entry ? "" : entry.count === 1 ? "1 item needs attention" : entry.count + " items need attention";
1014
1027
  }
1015
1028
  }
1016
1029
 
@@ -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"), **fields)
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></toast>')
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