@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -2
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +86 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +353 -0
  32. package/install_runtime.py +283 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +33 -4
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/fetch_camoufox.py +28 -0
  66. package/web_adapters/browser/requirements.txt +2 -0
  67. package/web_adapters/node/adapter-common.mjs +40 -0
  68. package/web_adapters/node/ddg-search.mjs +89 -0
  69. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  70. package/web_adapters/node/package-lock.json +2720 -0
  71. package/web_adapters/node/package.json +12 -0
  72. package/web_adapters/node/wikipedia-search.mjs +44 -0
  73. package/web_document_graph.py +71 -0
  74. package/web_fetch.py +242 -0
  75. package/web_search_adapters.py +193 -0
  76. package/web_search_ddgs.py +42 -0
  77. package/web_search_external.py +288 -0
  78. package/web_search_health.py +88 -0
package/setup_ui.py ADDED
@@ -0,0 +1,986 @@
1
+ #!/usr/bin/env python3
2
+ """Tela de modelos do Smart Tool, servida pelo daemon em `GET /setup`.
3
+
4
+ Esta tela aponta o gateway de modelos OpenAI-compatível (URL base + API key opcional),
5
+ escolhe modelos, controla o autostart e recebe chaves opcionais de Tavily/Firecrawl,
6
+ protegidas por DPAPI no config próprio do Smart Tool.
7
+
8
+ `SETUP_TOKEN` é gerado uma vez por processo (nunca persistido em disco) e embutido no
9
+ HTML de `GET /setup`; as rotas que escrevem estado (`POST /setup/models`, `POST /setup/gateway`,
10
+ `POST /setup/providers` e `POST /setup/autostart`) exigem
11
+ esse token de volta no header `X-Setup-Token`. Não é uma
12
+ trava contra quem já tem acesso de leitura à máquina (que também leria o token de
13
+ `GET /setup`) — só impede escrever config sem nunca ter carregado a página.
14
+ """
15
+ import html
16
+ import json
17
+ import os
18
+ import re
19
+ import secrets
20
+
21
+ import autostart
22
+ import client_hooks
23
+ import config
24
+ import daemon_launcher
25
+ import endpoint_sync
26
+ import gateway
27
+ import model_catalog
28
+ import model_defaults
29
+ import local_embedder
30
+ import secret_store
31
+ import version
32
+ import web_search_external
33
+
34
+ SETUP_TOKEN = secrets.token_urlsafe(24)
35
+
36
+ AGENT_CLIENTS = {"claude": {"name": "claude-code"}, "codex": {"name": "codex"}}
37
+
38
+
39
+ def setup_origin():
40
+ """The port the daemon announced in daemon.json (it may fall back from 8765 when another program holds it)."""
41
+ registry = daemon_launcher.read_registry()
42
+ return registry["url"] if registry else f"http://127.0.0.1:{os.environ.get('SMART_TOOL_PORT', '8765')}"
43
+
44
+
45
+ def gateway_state():
46
+ return {**gateway.describe(), **gateway.status()}
47
+
48
+
49
+ def handle_gateway_save(body):
50
+ """{"adapter": name ("" disconnects), "values": {field: value}}; a secret field absent keeps, "" removes."""
51
+ if not isinstance(body, dict) or not isinstance(body.get("adapter", ""), str) \
52
+ or not isinstance(body.get("values", {}), dict):
53
+ raise SetupError("Send the adapter and the field values in a JSON object.")
54
+ try:
55
+ if body.get("remove_secret"):
56
+ gateway.remove_secret(body.get("adapter", ""), body["remove_secret"])
57
+ else:
58
+ gateway.save(body.get("adapter", ""), body.get("values") or {})
59
+ except (ValueError, RuntimeError, OSError, config.ConfigCorruptedError) as exc:
60
+ raise SetupError(str(exc))
61
+ return {"ok": True, "gateway": gateway_state()}
62
+
63
+
64
+ def status_payload():
65
+ gateway = gateway_state()
66
+ models = model_defaults.ensure_recommended_models(gateway["status"] == "ready")
67
+ cfg = config.load_config()
68
+ return {
69
+ "gateway": gateway,
70
+ "models_status": models["status"],
71
+ "models_message": models["message"],
72
+ "missing_models": models["missing"],
73
+ "router_model": cfg.get("router_model", ""),
74
+ "scope_model": cfg.get("scope_model", ""),
75
+ "research_model": cfg.get("research_model", ""),
76
+ "research_max_iterations": cfg.get("research_max_iterations", config.DEFAULT_CONFIG["research_max_iterations"]),
77
+ "quick_search_timeout_s": cfg.get("quick_search_timeout_s", config.DEFAULT_CONFIG["quick_search_timeout_s"]),
78
+ "site_memory_similarity_threshold": cfg.get("site_memory_similarity_threshold", config.DEFAULT_CONFIG["site_memory_similarity_threshold"]),
79
+ "embedding_model": cfg.get("embedding_model", ""),
80
+ "rerank_model": cfg.get("rerank_model", ""),
81
+ }
82
+
83
+
84
+ def render_setup_page():
85
+ # Defesa em profundidade: um valor com esse literal fecharia a tag <script> antes da hora.
86
+ status_json = json.dumps(status_payload(), ensure_ascii=False).replace("</", "<\\/")
87
+ replacements = {
88
+ "__STATUS_JSON__": status_json,
89
+ "__SETUP_TOKEN__": json.dumps(SETUP_TOKEN),
90
+ # Único valor que entra no corpo do HTML, e não no <script>: escapado porque vem
91
+ # de variável de ambiente, ainda que só quem já executa código possa definí-la.
92
+ "__SETUP_ORIGIN__": html.escape(setup_origin(), quote=True),
93
+ }
94
+ # Um único passe sobre o template original: .replace() encadeado re-escanearia o
95
+ # próprio resultado e corromperia o JSON se um valor coincidisse com o outro placeholder.
96
+ pattern = re.compile("|".join(re.escape(k) for k in replacements))
97
+ return pattern.sub(lambda m: replacements[m.group(0)], _PAGE_TEMPLATE)
98
+
99
+
100
+ class SetupError(Exception):
101
+ """Erro de request na tela de setup (token ausente/errado, corpo inválido)."""
102
+
103
+
104
+ def require_token(headers):
105
+ # `compare_digest` em vez de `!=`: comparação de string em Python sai no primeiro
106
+ # byte diferente, e as rotas de escrita ficam expostas a qualquer página que o
107
+ # navegador do usuário abra.
108
+ # Em bytes: cabeçalho HTTP é decodificado em latin-1, e `compare_digest` levanta
109
+ # TypeError com `str` fora de ASCII — um byte alto no header viraria 500 em vez do
110
+ # 400 claro.
111
+ supplied = (headers.get("X-Setup-Token") or "").encode("utf-8", "replace")
112
+ if not secrets.compare_digest(supplied, SETUP_TOKEN.encode("utf-8")):
113
+ raise SetupError(f"Setup page session token missing or invalid; open {setup_origin()}/setup again.")
114
+
115
+
116
+ def handle_models_get():
117
+ model_defaults.ensure_recommended_models(True)
118
+ cfg = config.load_config()
119
+ chat = model_catalog.list_models("chat")
120
+ return {
121
+ "chat": chat,
122
+ "chat_lightweight": [m for m in chat if model_catalog.is_lightweight(m)],
123
+ "embedding": model_catalog.list_models("embedding"),
124
+ "rerank": model_catalog.list_models("rerank"),
125
+ "current": {
126
+ "router_model": cfg.get("router_model", ""),
127
+ "scope_model": cfg.get("scope_model", ""),
128
+ "research_model": cfg.get("research_model", ""),
129
+ "research_max_iterations": cfg.get("research_max_iterations", config.DEFAULT_CONFIG["research_max_iterations"]),
130
+ "quick_search_timeout_s": cfg.get("quick_search_timeout_s", config.DEFAULT_CONFIG["quick_search_timeout_s"]),
131
+ "site_memory_similarity_threshold": cfg.get("site_memory_similarity_threshold", config.DEFAULT_CONFIG["site_memory_similarity_threshold"]),
132
+ "embedding_model": cfg.get("embedding_model", ""),
133
+ "rerank_model": cfg.get("rerank_model", ""),
134
+ },
135
+ "recommended": {
136
+ **{field: model_id for field, (_mode, model_id) in model_defaults.RECOMMENDED_MODELS.items()},
137
+ "research_max_iterations": config.DEFAULT_CONFIG["research_max_iterations"],
138
+ "quick_search_timeout_s": config.DEFAULT_CONFIG["quick_search_timeout_s"],
139
+ "site_memory_similarity_threshold": config.DEFAULT_CONFIG["site_memory_similarity_threshold"],
140
+ },
141
+ "ranges": {
142
+ "research_max_iterations": list(config.RESEARCH_MAX_ITERATIONS_RANGE),
143
+ "quick_search_timeout_s": list(config.QUICK_SEARCH_TIMEOUT_RANGE),
144
+ "site_memory_similarity_threshold": list(config.SITE_MEMORY_SIMILARITY_THRESHOLD_RANGE),
145
+ },
146
+ }
147
+
148
+
149
+ def handle_local_models_get():
150
+ available = local_embedder.info() if local_embedder.configured() else None
151
+ return {"mode": config.local_models_mode(), "modes": list(config.LOCAL_MODEL_MODES),
152
+ "sidecar": {"configured": local_embedder.configured(), "url": local_embedder.url(),
153
+ "embedding_model": (available or {}).get("model")}}
154
+
155
+
156
+ def handle_local_models_save(body):
157
+ if not isinstance(body, dict) or body.get("mode") not in config.LOCAL_MODEL_MODES:
158
+ raise SetupError("Invalid mode: use fallback, off or prefer.")
159
+ try:
160
+ config.update_config(lambda cfg: cfg.update(local_models=body["mode"]))
161
+ except config.ConfigCorruptedError as exc:
162
+ raise SetupError(str(exc))
163
+ except OSError as exc:
164
+ raise SetupError(f"Disk error while saving the configuration ({exc}); try again.")
165
+ return {"ok": True, **handle_local_models_get()}
166
+
167
+
168
+ def handle_integration_get():
169
+ registered = endpoint_sync.registrations()
170
+ clients = []
171
+ for key, info in AGENT_CLIENTS.items():
172
+ spec, state = client_hooks.CLIENTS[key], client_hooks.status(info)
173
+ clients.append({"client": key, "label": spec["label"], "mcp_url": registered.get(key), "hook": state["installed"],
174
+ "hook_error": state["error"], "file": spec["files"][0], "docs": spec["docs"],
175
+ "register_command": " ".join(endpoint_sync.register_command(key, setup_origin()))})
176
+ try:
177
+ mode, mode_error = config.hook_mode(), ""
178
+ except ValueError as exc:
179
+ mode, mode_error = "", str(exc)
180
+ return {"daemon_url": setup_origin(), "clients": clients, "hook_mode": mode, "hook_mode_error": mode_error,
181
+ "hook_modes": list(config.HOOK_MODES)}
182
+
183
+
184
+ def handle_integration_save(body):
185
+ """`hook_mode` sets redirect/advise/off for every client; `register_mcp` runs the client's CLI; hook in two steps:
186
+ `preview` shows the exact change, `install` writes it (with backup)."""
187
+ if isinstance(body, dict) and body.get("action") == "hook_mode":
188
+ if body.get("mode") not in config.HOOK_MODES:
189
+ raise SetupError("Invalid mode: use redirect, advise or off.")
190
+ try:
191
+ config.update_config(lambda cfg: cfg.update(hook_mode=body["mode"]))
192
+ except (OSError, config.ConfigCorruptedError) as exc:
193
+ raise SetupError(str(exc))
194
+ return handle_integration_get()
195
+ if not isinstance(body, dict) or body.get("client") not in AGENT_CLIENTS:
196
+ raise SetupError("Invalid client: use claude or codex.")
197
+ info = AGENT_CLIENTS[body["client"]]
198
+ try:
199
+ if body.get("action") == "register_mcp":
200
+ return {"command": endpoint_sync.register(body["client"], setup_origin()), **handle_integration_get()}
201
+ if body.get("action") == "preview":
202
+ return client_hooks.preview(info)
203
+ if body.get("action") == "install":
204
+ return {**client_hooks.install(info, body.get("token"), True), **handle_integration_get()}
205
+ except (ValueError, RuntimeError) as exc:
206
+ raise SetupError(str(exc))
207
+ raise SetupError("Invalid action: use hook_mode, register_mcp, preview or install.")
208
+
209
+
210
+ def handle_providers_get():
211
+ return {"providers": {
212
+ name: {**web_search_external.PROVIDERS[name], "status": config.provider_key_status(name)}
213
+ for name in config.WEB_PROVIDER_NAMES
214
+ }, "contact": config.load_config().get("contact") or "", "user_agent": version.user_agent(
215
+ config.load_config().get("contact"))}
216
+
217
+
218
+ def handle_contact_save(body):
219
+ """Each user's own contact for the User-Agent that public APIs (Wikimedia) ask for; never a shared default."""
220
+ if not isinstance(body, dict) or not isinstance(body.get("contact", ""), str):
221
+ raise SetupError("Send the contact as text.")
222
+ try:
223
+ contact = version.check_contact(body.get("contact"))
224
+ config.update_config(lambda cfg: cfg.update(contact=contact))
225
+ except (ValueError, OSError, config.ConfigCorruptedError) as exc:
226
+ raise SetupError(str(exc))
227
+ return handle_providers_get()
228
+
229
+
230
+ def handle_providers_save(body):
231
+ if not isinstance(body, dict):
232
+ raise SetupError("Send the provider and the action in a JSON object.")
233
+ provider = body.get("provider")
234
+ if provider not in config.WEB_PROVIDER_NAMES:
235
+ raise SetupError("Unknown web provider.")
236
+ action = body.get("action")
237
+ if action == "save":
238
+ key = body.get("api_key")
239
+ if not isinstance(key, str) or not key.strip():
240
+ raise SetupError("Paste the provider API key before saving.")
241
+ elif action == "remove":
242
+ key = ""
243
+ else:
244
+ raise SetupError("Invalid action: use save or remove.")
245
+ validated = None
246
+ if key:
247
+ key = key.strip()
248
+ label = web_search_external.PROVIDERS[provider]["label"]
249
+ try:
250
+ validated = web_search_external.validate_key(provider, key)
251
+ except web_search_external.ProviderAuthError as exc:
252
+ raise SetupError(f"{label} rejected the key ({exc}). Check that you copied the whole key from your account.")
253
+ except ValueError as exc:
254
+ raise SetupError(str(exc))
255
+ except web_search_external.ProviderRateLimited:
256
+ raise SetupError(f"{label} returned a rate limit while testing the key; try again in a few minutes.")
257
+ except Exception as exc:
258
+ raise SetupError(f"Could not test the key with {label}: {type(exc).__name__}: {exc}"[:300])
259
+ try:
260
+ config.set_provider_api_key(provider, key)
261
+ except secret_store.SecretProtectionError as exc:
262
+ raise SetupError(f"Could not protect the key in this Windows profile: {exc}")
263
+ except (ValueError, config.ConfigCorruptedError, OSError) as exc:
264
+ raise SetupError(str(exc))
265
+ return {"ok": True, "provider": provider, "status": config.provider_key_status(provider),
266
+ "validated_results": validated}
267
+
268
+
269
+ def handle_autostart_get():
270
+ return autostart.status()
271
+
272
+
273
+ def handle_autostart_action(body):
274
+ if not isinstance(body, dict):
275
+ raise SetupError("Request body must be a JSON object with 'action'.")
276
+ action = body.get("action")
277
+ if action == "install":
278
+ return autostart.install()
279
+ if action == "remove":
280
+ return autostart.remove()
281
+ raise SetupError("Invalid action: use 'install' or 'remove'.")
282
+
283
+
284
+ _MODEL_FIELDS = {
285
+ "router_model": "chat",
286
+ "scope_model": "chat",
287
+ "research_model": "chat",
288
+ "embedding_model": "embedding",
289
+ "rerank_model": "rerank",
290
+ }
291
+
292
+ # (cast, range, mensagem) — mesmos limites de config.py, validados aqui pra recusar com
293
+ # SetupError explicito em vez de deixar config.clamp_* engolir um valor ruim em silêncio.
294
+ _NUMERIC_FIELDS = {
295
+ "research_max_iterations": (int, config.RESEARCH_MAX_ITERATIONS_RANGE, "research loop round limit"),
296
+ "quick_search_timeout_s": (int, config.QUICK_SEARCH_TIMEOUT_RANGE, "quick search time limit"),
297
+ "site_memory_similarity_threshold": (float, config.SITE_MEMORY_SIMILARITY_THRESHOLD_RANGE, "per-site memory similarity threshold"),
298
+ }
299
+
300
+
301
+ def handle_models_save(body):
302
+ if not isinstance(body, dict):
303
+ raise SetupError("Request body must be a JSON object with the models.")
304
+ # Any id the gateway accepts (the catalog is only a suggestion); it is embedded in the status JSON of GET /setup.
305
+ validated = {}
306
+ for field, mode in _MODEL_FIELDS.items():
307
+ if field not in body:
308
+ continue
309
+ value = str(body[field] or "").strip()
310
+ if len(value) > 200 or any(ord(char) < 33 for char in value):
311
+ raise SetupError(f"Invalid {mode} model id: {value[:60]!r} (no spaces, up to 200 characters).")
312
+ validated[field] = value
313
+
314
+ for field, (cast, (lo, hi), label) in _NUMERIC_FIELDS.items():
315
+ if field not in body:
316
+ continue
317
+ try:
318
+ value = cast(body[field])
319
+ except (TypeError, ValueError):
320
+ raise SetupError(f"'{label}' must be numeric, got: {body[field]!r}")
321
+ if not (lo <= value <= hi):
322
+ raise SetupError(f"'{label}' must be between {lo} and {hi}, got: {value}")
323
+ validated[field] = value
324
+
325
+ def _mutate(cfg):
326
+ cfg.update(validated)
327
+
328
+ try:
329
+ config.update_config(_mutate)
330
+ except config.ConfigCorruptedError as exc:
331
+ raise SetupError(str(exc))
332
+ except OSError as exc:
333
+ raise SetupError(f"Disk error while saving the configuration ({exc}); try again.")
334
+ return {"ok": True}
335
+
336
+
337
+ _PAGE_TEMPLATE = """<!doctype html>
338
+ <html lang="en">
339
+ <head>
340
+ <meta charset="utf-8">
341
+ <meta name="viewport" content="width=device-width, initial-scale=1">
342
+ <title>Smart Tool — Settings</title>
343
+ <style>
344
+ :root {
345
+ --bg: #e8ece7; --surface: #f6f8f4; --line: #cfd6cf; --ink: #1c2621; --muted: #5d6b63;
346
+ --signal: #2450f0; --on-signal: #ffffff; --ok: #2d8a55; --bad: #c2381f; --warn: #a87400; --idle: #9aa69f;
347
+ --display: "Bahnschrift", "DIN Alternate", "Segoe UI", sans-serif;
348
+ --body: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
349
+ --mono: "Cascadia Mono", "Cascadia Code", Consolas, monospace;
350
+ }
351
+ @media (prefers-color-scheme: dark) {
352
+ :root { --bg: #121815; --surface: #1a221e; --line: #2c3731; --ink: #e3e9e4; --muted: #93a199;
353
+ --signal: #7d98ff; --on-signal: #0c1433; --ok: #59c786; --bad: #ff7a61; --warn: #e2b24a; --idle: #4b5850; }
354
+ }
355
+ * { box-sizing: border-box; }
356
+ html { scroll-behavior: smooth; }
357
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 var(--body); }
358
+ a { color: var(--signal); }
359
+ main { max-width: 1080px; margin: 0 auto; padding: 36px 28px 72px; }
360
+
361
+ .masthead { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
362
+ .masthead h1 { display: inline; font: 700 2.1rem/1 var(--display); font-stretch: 87.5%; letter-spacing: -.01em; margin: 0; }
363
+ .masthead .where { font: .8rem var(--mono); color: var(--muted); margin-left: 12px; }
364
+ .masthead nav a { font: 600 .9rem var(--display); text-decoration: none; letter-spacing: .02em; }
365
+ .masthead nav a:hover { text-decoration: underline; }
366
+
367
+ .path { list-style: none; margin: 28px 0 36px; padding: 26px 28px 22px; display: grid;
368
+ grid-template-columns: repeat(4, 1fr); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; }
369
+ .station { position: relative; }
370
+ .station a { display: block; color: inherit; text-decoration: none; padding-right: 18px; border-radius: 8px; }
371
+ .station a:focus-visible { outline: 2px solid var(--signal); outline-offset: 6px; }
372
+ .node { display: flex; align-items: center; height: 22px; }
373
+ .dot { width: 18px; height: 18px; border-radius: 50%; border: 3px solid var(--idle); background: var(--surface); flex: none; position: relative; z-index: 1; transition: border-color .3s, background .3s; }
374
+ .wire { flex: 1; height: 3px; margin-left: -1px; background: var(--line); position: relative; overflow: hidden; }
375
+ .wire::after { content: ""; position: absolute; inset: 0; background: var(--signal); transform: scaleX(0); transform-origin: left; }
376
+ .station:last-child .wire { display: none; }
377
+ .station[data-state="ok"] .dot { border-color: var(--ok); background: var(--ok); }
378
+ .station[data-state="bad"] .dot { border-color: var(--bad); }
379
+ .station[data-state="warn"] .dot { border-color: var(--warn); }
380
+ .station[data-live="1"] .wire::after { transform: scaleX(1); transition: transform .45s cubic-bezier(.6,0,.2,1) var(--delay, 0s); }
381
+ .station[data-break="1"] .wire { background: repeating-linear-gradient(90deg, var(--bad) 0 6px, transparent 6px 11px); }
382
+ .station h2 { font: 600 1.05rem/1.2 var(--display); letter-spacing: .03em; margin: 14px 0 3px; }
383
+ .station .detail { font: .78rem/1.35 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
384
+ .station .verdict { font-size: .84rem; margin-top: 6px; }
385
+ .station[data-state="bad"] .verdict { color: var(--bad); }
386
+ .station[data-state="warn"] .verdict { color: var(--warn); }
387
+
388
+ .panels { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 22px; align-items: start; }
389
+ .col { display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; }
390
+ section { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 22px 24px 24px; scroll-margin-top: 20px; }
391
+ section > h2 { font: 600 1.2rem/1.2 var(--display); letter-spacing: .02em; margin: 0 0 4px; }
392
+ section > .lede { color: var(--muted); font-size: .87rem; margin: 0 0 18px; }
393
+ fieldset { border: 0; border-top: 1px solid var(--line); margin: 18px 0 0; padding: 16px 0 0; min-width: 0; }
394
+ legend { font: 600 .78rem var(--display); letter-spacing: .12em; text-transform: uppercase; color: var(--muted); padding: 0 10px 0 0; }
395
+ fieldset .lede { color: var(--muted); font-size: .82rem; margin: 2px 0 12px; }
396
+ .field { margin-bottom: 12px; }
397
+ .pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
398
+ label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 4px; }
399
+ .hint { display: block; font-size: .76rem; color: var(--muted); margin-top: 3px; }
400
+ select, input[type=text], input[type=url], input[type=number], input[type=password] {
401
+ width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
402
+ background: var(--bg); color: var(--ink); font: .84rem var(--mono); min-width: 0; max-width: 100%; }
403
+ input[type=password] { font-family: var(--body); }
404
+ input::placeholder { color: var(--idle); }
405
+ select:focus-visible, input:focus-visible, button:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
406
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 14px; }
407
+ button { font: 600 .86rem var(--display); letter-spacing: .03em; border-radius: 8px; padding: 9px 16px; cursor: pointer;
408
+ background: var(--signal); color: var(--on-signal); border: 1px solid var(--signal); }
409
+ button.quiet { background: transparent; color: var(--ink); border-color: var(--line); }
410
+ button:disabled { opacity: .45; cursor: default; }
411
+ .state { display: inline-flex; align-items: center; gap: 8px; font-size: .86rem; }
412
+ .state::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--idle); }
413
+ .state[data-state="ok"]::before { background: var(--ok); }
414
+ .state[data-state="bad"]::before { background: var(--bad); }
415
+ .state[data-state="warn"]::before { background: var(--warn); }
416
+ .mono { font-family: var(--mono); font-size: .8rem; overflow-wrap: anywhere; }
417
+ .msg { font-size: .84rem; min-height: 1.3em; margin-top: 10px; }
418
+ .msg.error { color: var(--bad); }
419
+ .msg.ok { color: var(--ok); }
420
+ .msg.warn { color: var(--warn); }
421
+ .provider + .provider { border-top: 1px solid var(--line); margin-top: 18px; padding-top: 18px; }
422
+ .provider-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 8px; }
423
+ .provider-head strong { font: 600 .98rem var(--display); letter-spacing: .02em; }
424
+ .blocked { color: var(--muted); font-size: .88rem; border: 1px dashed var(--line); border-radius: 10px; padding: 14px 16px; }
425
+
426
+ body:not(.ready) .station[data-live="1"] .wire::after { transform: scaleX(0); }
427
+ @media (max-width: 900px) { .panels { grid-template-columns: minmax(0, 1fr); } }
428
+ @media (max-width: 640px) {
429
+ main { padding: 24px 16px 56px; }
430
+ .path { grid-template-columns: minmax(0, 1fr); gap: 0; padding: 20px; }
431
+ .station a { display: grid; grid-template-columns: 22px minmax(0, 1fr); column-gap: 14px; padding: 0 0 18px; }
432
+ .node { flex-direction: column; height: auto; grid-row: span 3; }
433
+ .wire { width: 3px; height: auto; flex: 1; margin: -1px 0 0; }
434
+ .wire::after { transform: scaleY(0); transform-origin: top; }
435
+ .station[data-live="1"] .wire::after { transform: scaleY(1); }
436
+ body:not(.ready) .station[data-live="1"] .wire::after { transform: scaleY(0); }
437
+ .station:last-child .wire { display: none; }
438
+ .station h2 { margin-top: 0; }
439
+ .pair { grid-template-columns: minmax(0, 1fr); }
440
+ }
441
+ @media (prefers-reduced-motion: reduce) {
442
+ html { scroll-behavior: auto; }
443
+ .station[data-live="1"] .wire::after, .dot { transition: none; }
444
+ }
445
+ </style>
446
+ </head>
447
+ <body>
448
+ <main>
449
+ <header class="masthead">
450
+ <div><h1>Smart Tool</h1><span class="where">__SETUP_ORIGIN__</span></div>
451
+ <nav><a href="/projects">Projects and indexing →</a></nav>
452
+ </header>
453
+
454
+ <ol class="path" aria-label="Path of a search, from the agent to the models">
455
+ <li class="station" id="st-agent" data-state="ok"><a href="#autostart">
456
+ <div class="node"><span class="dot"></span><span class="wire"></span></div>
457
+ <h2>Agent</h2><div class="detail">MCP at /mcp</div><div class="verdict">Claude Code, Codex and other MCP clients</div></a></li>
458
+ <li class="station" id="st-daemon" data-state="ok"><a href="#autostart">
459
+ <div class="node"><span class="dot"></span><span class="wire"></span></div>
460
+ <h2>Smart Tool</h2><div class="detail">__SETUP_ORIGIN__</div><div class="verdict" id="st-daemon-verdict">Responding</div></a></li>
461
+ <li class="station" id="st-gateway"><a href="#gateway">
462
+ <div class="node"><span class="dot"></span><span class="wire"></span></div>
463
+ <h2>Gateway</h2><div class="detail" id="st-gateway-detail">—</div><div class="verdict" id="st-gateway-verdict">Checking</div></a></li>
464
+ <li class="station" id="st-models"><a href="#models">
465
+ <div class="node"><span class="dot"></span><span class="wire"></span></div>
466
+ <h2>Models</h2><div class="detail" id="st-models-detail">—</div><div class="verdict" id="st-models-verdict">Checking</div></a></li>
467
+ </ol>
468
+
469
+ <div class="panels">
470
+ <div class="col">
471
+ <section id="gateway">
472
+ <h2>Model gateway</h2>
473
+ <p class="lede">Where embedding, chat and, if available, rerank calls go. The OpenAI-compatible adapter covers OpenAI, OpenRouter, a LiteLLM proxy and Ollama; other adapters go in as a .py file in the folder shown below.</p>
474
+ <div class="state" id="gateway-state" role="status">Checking</div>
475
+ <div class="field" style="margin-top:16px">
476
+ <label for="gateway-adapter">Adapter</label>
477
+ <select id="gateway-adapter"></select>
478
+ <span class="hint mono" id="gateway-dir"></span>
479
+ </div>
480
+ <div id="gateway-fields"></div>
481
+ <div class="msg error" id="gateway-errors" role="status" hidden></div>
482
+ <div class="actions">
483
+ <button type="button" id="gateway-save">Test and save</button>
484
+ <button type="button" class="quiet" id="gateway-clear">Disconnect</button>
485
+ </div>
486
+ <div class="msg" id="gateway-msg" role="status"></div>
487
+ </section>
488
+
489
+ <section id="models">
490
+ <h2>Models</h2>
491
+ <p class="lede">Any id the gateway accepts; the published catalog shows up as suggestions. Rerank is optional: without it the hybrid order applies.</p>
492
+ <div class="blocked" id="models-blocked" hidden>Connect the gateway to list the catalog and choose models.</div>
493
+ <div id="models-form" hidden>
494
+ <fieldset>
495
+ <legend>Code search</legend>
496
+ <p class="lede">Indexing and smart_search.</p>
497
+ <div class="pair">
498
+ <div class="field"><label for="sel-embedding">Embedding</label><input id="sel-embedding" list="dl-embedding" autocomplete="off" spellcheck="false"><datalist id="dl-embedding"></datalist></div>
499
+ <div class="field"><label for="sel-rerank">Rerank</label><input id="sel-rerank" list="dl-rerank" autocomplete="off" spellcheck="false"><datalist id="dl-rerank"></datalist></div>
500
+ </div>
501
+ <div class="field"><label for="sel-scope">Index classification</label><input id="sel-scope" list="dl-scope" autocomplete="off" spellcheck="false"><datalist id="dl-scope"></datalist>
502
+ <span class="hint">Decides scope, code vs. documentation and languages during indexing. Runs once per project.</span></div>
503
+ </fieldset>
504
+ <fieldset>
505
+ <legend>Router</legend>
506
+ <div class="field"><label for="sel-router">Router model</label><input id="sel-router" list="dl-router" autocomplete="off" spellcheck="false"><datalist id="dl-router"></datalist>
507
+ <span class="hint">Decides before each agent Grep/Read/Bash. Use a small model without reasoning: above 10 s routing is turned off.</span></div>
508
+ </fieldset>
509
+ <fieldset>
510
+ <legend>Web research</legend>
511
+ <div class="field"><label for="sel-research">Iterative research model</label><input id="sel-research" list="dl-research" autocomplete="off" spellcheck="false"><datalist id="dl-research"></datalist>
512
+ <span class="hint">Used by web_search with depth=research. Empty uses the router model.</span></div>
513
+ <div class="pair">
514
+ <div class="field"><label for="num-research-iterations">Maximum rounds</label><input type="number" id="num-research-iterations" step="1"><span class="hint" id="research-iterations-hint"></span></div>
515
+ <div class="field"><label for="num-quick-timeout">Quick search time limit (s)</label><input type="number" id="num-quick-timeout" step="1"><span class="hint" id="quick-timeout-hint"></span></div>
516
+ </div>
517
+ <div class="field"><label for="num-site-memory-threshold">Minimum per-site memory similarity</label><input type="number" id="num-site-memory-threshold" step="0.01"><span class="hint" id="site-memory-threshold-hint"></span></div>
518
+ </fieldset>
519
+ <div class="actions"><button type="button" id="btn-save-models">Save models</button></div>
520
+ <div class="msg" id="models-msg" role="status"></div>
521
+ </div>
522
+ </section>
523
+ </div>
524
+
525
+ <div class="col">
526
+ <section id="autostart">
527
+ <h2>Start automatically</h2>
528
+ <p class="lede">Starts Smart Tool when you sign in to Windows.</p>
529
+ <div class="state" id="autostart-status" role="status">Checking</div>
530
+ <div class="actions"><button type="button" class="quiet" id="btn-autostart-toggle" disabled>Checking</button></div>
531
+ <div class="msg" id="autostart-msg" role="status"></div>
532
+ </section>
533
+
534
+ <section id="local-models-section">
535
+ <h2>Local models</h2>
536
+ <p class="lede">Embedding from the mcp-memory sidecar on this machine. Switching between gateway and local rebuilds the project indexes; going back to a model used before reuses the vector cache.</p>
537
+ <div class="field"><label for="sel-local-models">Usage</label>
538
+ <select id="sel-local-models">
539
+ <option value="fallback">Fallback: only when the gateway lacks the model</option>
540
+ <option value="prefer">Prefer local: even when the gateway has the model</option>
541
+ <option value="off">Off: never use local</option>
542
+ </select>
543
+ <span class="hint" id="local-models-hint">Checking the sidecar</span></div>
544
+ <div class="actions"><button type="button" id="btn-save-local-models">Save</button></div>
545
+ <div class="msg" id="local-models-msg" role="status"></div>
546
+ </section>
547
+
548
+ <section id="agents-section">
549
+ <h2>Agents</h2>
550
+ <p class="lede">The Smart Tool MCP server and the hook that redirects Grep/Read/Bash to smart_search and WebSearch/WebFetch to web_search/web_fetch. In Claude Code the hook is a direct HTTP call to the daemon; in Codex, a thin command that forwards to the daemon. If the port changes, the daemon rewrites these URLs on its own.</p>
551
+ <div class="field"><label for="sel-hook-mode">Hook behavior</label>
552
+ <select id="sel-hook-mode">
553
+ <option value="redirect">Redirect: block the native tool and point to Smart Tool (default)</option>
554
+ <option value="advise">Advise only: let the native tool run and tell the agent what Smart Tool would do</option>
555
+ <option value="off">Off: no routing</option>
556
+ </select>
557
+ <span class="hint" id="hook-mode-hint">Applies to every client on the next tool call; nothing is reinstalled.</span></div>
558
+ <div class="actions"><button type="button" id="btn-save-hook-mode">Save behavior</button></div>
559
+ <div id="agents-list"></div>
560
+ <pre class="hint" id="agents-preview" hidden></pre>
561
+ <div class="actions"><button type="button" id="btn-agents-confirm" hidden>Confirm and write</button></div>
562
+ <div class="msg" id="agents-msg" role="status"></div>
563
+ </section>
564
+
565
+ <section id="providers-section">
566
+ <h2>Web search providers</h2>
567
+ <p class="lede">All of them work without a key, on each provider's public free tier. Add your own key only if you need more volume: it is tested with a real search before being saved and is protected by DPAPI on this Windows machine.</p>
568
+ <div class="field"><label for="contact-input">Your contact for public APIs (optional)</label>
569
+ <input id="contact-input" autocomplete="off" spellcheck="false" placeholder="you@example.com or https://your.site">
570
+ <span class="hint" id="contact-hint">Wikimedia asks tools to identify a contact in the User-Agent. It stays on this machine and is sent only to the search APIs.</span></div>
571
+ <div class="actions"><button type="button" id="btn-save-contact">Save contact</button></div>
572
+ <div class="msg" id="contact-msg" role="status"></div>
573
+ <div id="providers-list"></div>
574
+ </section>
575
+ </div>
576
+ </div>
577
+ </main>
578
+ <script>
579
+ const TOKEN = __SETUP_TOKEN__;
580
+ let status = __STATUS_JSON__;
581
+ const $ = (id) => document.getElementById(id);
582
+
583
+ function setMsg(id, kind, text) { const el = $(id); el.className = "msg" + (kind ? " " + kind : ""); el.textContent = text; }
584
+
585
+ async function post(path, body) {
586
+ const resp = await fetch(path, { method: "POST", headers: { "X-Setup-Token": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify(body) });
587
+ const data = await resp.json().catch(() => ({}));
588
+ if (!resp.ok || data.error) throw new Error(data.error || `HTTP error ${resp.status}.`);
589
+ return data;
590
+ }
591
+
592
+ function station(id, state, live, broken) {
593
+ const el = $(id);
594
+ el.dataset.state = state;
595
+ el.dataset.live = live ? "1" : "0";
596
+ el.dataset.break = broken ? "1" : "0";
597
+ }
598
+
599
+ function renderPath() {
600
+ const gw = status.gateway;
601
+ const gwReady = gw.status === "ready";
602
+ const modelsReady = status.models_status === "ready";
603
+ const source = gw.label || "Not configured";
604
+ $("st-gateway-detail").textContent = gw.label || "no adapter";
605
+ $("st-gateway-verdict").textContent = gwReady ? source : (gw.error || "Unavailable");
606
+ $("st-models-detail").textContent = [status.embedding_model, status.rerank_model].filter(Boolean).join(" · ") || "none chosen";
607
+ $("st-models-verdict").textContent = status.models_message || (modelsReady ? "Ready" : "Choose the models");
608
+ station("st-agent", "ok", true, false);
609
+ station("st-daemon", "ok", gwReady, !gwReady);
610
+ station("st-gateway", gwReady ? "ok" : "bad", gwReady && modelsReady, gwReady && !modelsReady);
611
+ station("st-models", gwReady ? (modelsReady ? "ok" : "warn") : "idle", false, false);
612
+ ["st-agent", "st-daemon", "st-gateway"].forEach((id, i) => $(id).style.setProperty("--delay", (i * 0.35) + "s"));
613
+ }
614
+
615
+ function renderGateway() {
616
+ const gw = status.gateway;
617
+ const ready = gw.status === "ready";
618
+ const el = $("gateway-state");
619
+ el.dataset.state = ready ? "ok" : "bad";
620
+ el.textContent = ready ? "Connected through " + gw.label : (gw.error || "Gateway unavailable");
621
+ $("gateway-dir").textContent = "Your adapters: " + gw.dir;
622
+ $("gateway-errors").hidden = !gw.errors.length;
623
+ $("gateway-errors").textContent = gw.errors.length ? "Adapters that failed to load: " + gw.errors.join(" · ") : "";
624
+ const select = $("gateway-adapter");
625
+ // Redraw only when the adapters or their stored state change: the 15 s refresh must not wipe what is being typed.
626
+ const signature = JSON.stringify([gw.selected, gw.adapters]);
627
+ if (signature !== gatewaySignature && document.activeElement !== select && !select.dataset.touched) {
628
+ gatewaySignature = signature;
629
+ select.replaceChildren(...gw.adapters.map((a) => new Option(a.label + (a.builtin ? "" : " (yours)"), a.name)));
630
+ select.value = gw.selected || (gw.adapters[0] || {}).name || "";
631
+ renderGatewayFields();
632
+ }
633
+ $("gateway-clear").disabled = !gw.selected;
634
+ $("models-blocked").hidden = ready;
635
+ $("models-form").hidden = !ready;
636
+ }
637
+
638
+ let gatewaySignature = null;
639
+ function renderGatewayFields() {
640
+ const adapter = status.gateway.adapters.find((a) => a.name === $("gateway-adapter").value);
641
+ const box = $("gateway-fields");
642
+ box.replaceChildren();
643
+ for (const field of (adapter || { fields: [] }).fields) {
644
+ const wrap = document.createElement("div");
645
+ wrap.className = "field";
646
+ wrap.innerHTML = `<label></label><input autocomplete="off" spellcheck="false"><span class="hint"></span>`;
647
+ const input = wrap.querySelector("input");
648
+ wrap.querySelector("label").textContent = field.label + (field.required ? "" : " (optional)");
649
+ input.dataset.field = field.name;
650
+ input.dataset.secret = field.secret ? "1" : "";
651
+ if (field.secret) {
652
+ input.type = "password";
653
+ input.autocomplete = "new-password";
654
+ input.placeholder = field.state === "configured" ? "Saved · paste another to replace" : "";
655
+ const hint = field.state === "unreadable" ? "The saved value cannot be opened in this Windows profile; paste it again. " : "";
656
+ wrap.querySelector(".hint").textContent = hint + (field.hint || "") + " Protected by DPAPI.";
657
+ if (field.state === "configured") {
658
+ const remove = document.createElement("button");
659
+ remove.type = "button"; remove.className = "quiet"; remove.textContent = "Remove";
660
+ remove.addEventListener("click", () => saveGateway({ adapter: adapter.name, remove_secret: field.name }));
661
+ wrap.appendChild(remove);
662
+ }
663
+ } else {
664
+ input.value = field.value || "";
665
+ input.placeholder = field.placeholder || "";
666
+ wrap.querySelector(".hint").textContent = field.hint || "";
667
+ }
668
+ box.appendChild(wrap);
669
+ }
670
+ }
671
+ function gatewayValues() {
672
+ const values = {};
673
+ for (const input of $("gateway-fields").querySelectorAll("input")) {
674
+ if (input.dataset.secret && !input.value.trim()) continue;
675
+ values[input.dataset.field] = input.value.trim();
676
+ }
677
+ return values;
678
+ }
679
+ $("gateway-adapter").addEventListener("change", () => { $("gateway-adapter").dataset.touched = "1"; renderGatewayFields(); });
680
+
681
+ function render() { renderPath(); renderGateway(); }
682
+ render();
683
+ requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.add("ready")));
684
+
685
+ async function saveGateway(body) {
686
+ const buttons = [$("gateway-save"), $("gateway-clear")];
687
+ buttons.forEach((b) => b.disabled = true);
688
+ setMsg("gateway-msg", "", body.adapter ? "Testing…" : "Disconnecting…");
689
+ try {
690
+ const data = await post("/setup/gateway", body);
691
+ status.gateway = data.gateway;
692
+ delete $("gateway-adapter").dataset.touched;
693
+ gatewaySignature = null;
694
+ setMsg("gateway-msg", data.gateway.status === "ready" ? "ok" : "warn",
695
+ body.adapter ? "Gateway saved and responding." : "Gateway disconnected.");
696
+ await refreshStatus(true);
697
+ } catch (error) {
698
+ setMsg("gateway-msg", "error", error.message);
699
+ } finally {
700
+ buttons.forEach((b) => b.disabled = false);
701
+ renderGateway();
702
+ }
703
+ }
704
+ $("gateway-save").addEventListener("click", () => saveGateway({ adapter: $("gateway-adapter").value, values: gatewayValues() }));
705
+ $("gateway-clear").addEventListener("click", () => saveGateway({ adapter: "" }));
706
+
707
+ let autostartState = { installed: false, method: null };
708
+ function renderAutostart() {
709
+ const el = $("autostart-status");
710
+ el.dataset.state = autostartState.installed ? "ok" : "warn";
711
+ el.textContent = autostartState.installed ? `On via ${autostartState.method === "task" ? "Scheduled Task" : "Startup folder"}` : "Off";
712
+ $("st-daemon-verdict").textContent = autostartState.installed ? "Responding · starts with Windows" : "Responding · manual start";
713
+ const btn = $("btn-autostart-toggle");
714
+ btn.disabled = false;
715
+ btn.textContent = autostartState.installed ? "Turn off automatic start" : "Turn on automatic start";
716
+ }
717
+ async function loadAutostart() {
718
+ try {
719
+ const resp = await fetch("/setup/autostart");
720
+ autostartState = await resp.json();
721
+ renderAutostart();
722
+ } catch (error) {
723
+ $("autostart-status").dataset.state = "bad";
724
+ $("autostart-status").textContent = "Status unavailable";
725
+ }
726
+ }
727
+ loadAutostart();
728
+ $("btn-autostart-toggle").addEventListener("click", async () => {
729
+ const action = autostartState.installed ? "remove" : "install";
730
+ $("btn-autostart-toggle").disabled = true;
731
+ setMsg("autostart-msg", "", action === "install" ? "Turning on…" : "Turning off…");
732
+ try {
733
+ const data = await post("/setup/autostart", { action });
734
+ setMsg("autostart-msg", "ok", action === "install"
735
+ ? (data.method === "task" ? "Turned on via Scheduled Task." : `Turned on via the Startup folder; the Scheduled Task was refused: ${data.fallback_reason || "no reason given"}.`)
736
+ : "Turned off.");
737
+ await loadAutostart();
738
+ } catch (error) {
739
+ setMsg("autostart-msg", "error", error.message);
740
+ $("btn-autostart-toggle").disabled = false;
741
+ }
742
+ });
743
+
744
+ function fillSelect(input, options, current) {
745
+ $(input.getAttribute("list")).replaceChildren(...options.map((opt) => new Option(opt, opt)));
746
+ input.value = current || "";
747
+ }
748
+ function fillNumber(id, hintId, range, current, recommended, unit) {
749
+ const el = $(id);
750
+ [el.min, el.max] = range;
751
+ el.value = current;
752
+ $(hintId).textContent = `Recommended ${recommended}${unit} · from ${range[0]} to ${range[1]}${unit}`;
753
+ }
754
+ let modelsLoaded = false;
755
+ async function loadModels() {
756
+ try {
757
+ const resp = await fetch("/setup/models");
758
+ const data = await resp.json();
759
+ if (!resp.ok || data.error) throw new Error(data.error || "The catalog did not respond.");
760
+ fillSelect($("sel-router"), data.chat_lightweight.length ? data.chat_lightweight : data.chat, data.current.router_model);
761
+ fillSelect($("sel-scope"), data.chat, data.current.scope_model);
762
+ fillSelect($("sel-research"), data.chat, data.current.research_model);
763
+ fillSelect($("sel-embedding"), data.embedding, data.current.embedding_model);
764
+ fillSelect($("sel-rerank"), data.rerank, data.current.rerank_model);
765
+ fillNumber("num-research-iterations", "research-iterations-hint", data.ranges.research_max_iterations, data.current.research_max_iterations, data.recommended.research_max_iterations, "");
766
+ fillNumber("num-quick-timeout", "quick-timeout-hint", data.ranges.quick_search_timeout_s, data.current.quick_search_timeout_s, data.recommended.quick_search_timeout_s, " s");
767
+ fillNumber("num-site-memory-threshold", "site-memory-threshold-hint", data.ranges.site_memory_similarity_threshold, data.current.site_memory_similarity_threshold, data.recommended.site_memory_similarity_threshold, "");
768
+ modelsLoaded = true;
769
+ setMsg("models-msg", "", "");
770
+ } catch (error) {
771
+ setMsg("models-msg", "error", "Could not read the gateway catalog: " + error.message);
772
+ }
773
+ }
774
+ if (status.gateway.status === "ready") loadModels();
775
+
776
+ $("btn-save-models").addEventListener("click", async () => {
777
+ if (!modelsLoaded) { setMsg("models-msg", "error", "The catalog has not loaded yet; nothing was saved."); return; }
778
+ setMsg("models-msg", "", "Saving…");
779
+ try {
780
+ await post("/setup/models", {
781
+ router_model: $("sel-router").value, scope_model: $("sel-scope").value, research_model: $("sel-research").value,
782
+ research_max_iterations: Number($("num-research-iterations").value), quick_search_timeout_s: Number($("num-quick-timeout").value),
783
+ site_memory_similarity_threshold: Number($("num-site-memory-threshold").value),
784
+ embedding_model: $("sel-embedding").value, rerank_model: $("sel-rerank").value,
785
+ });
786
+ setMsg("models-msg", "ok", "Models saved.");
787
+ await refreshStatus(true);
788
+ } catch (error) {
789
+ setMsg("models-msg", "error", error.message);
790
+ }
791
+ });
792
+
793
+ async function refreshStatus(force) {
794
+ try {
795
+ const resp = await fetch("/setup/status");
796
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
797
+ const next = await resp.json();
798
+ const reload = next.gateway.status === "ready" && (force || status.gateway.status !== "ready");
799
+ status = next;
800
+ render();
801
+ if (reload && !modelsLoaded) loadModels();
802
+ } catch (error) {
803
+ $("st-daemon-verdict").textContent = "No response from the daemon";
804
+ station("st-daemon", "bad", false, true);
805
+ }
806
+ }
807
+ setInterval(refreshStatus, 15000);
808
+
809
+ async function loadLocalModels() {
810
+ try {
811
+ const resp = await fetch("/setup/local-models");
812
+ const data = await resp.json();
813
+ if (!resp.ok || data.error) throw new Error(data.error);
814
+ $("sel-local-models").value = data.mode;
815
+ $("local-models-hint").textContent = !data.sidecar.configured ? "mcp-memory sidecar not configured on this machine."
816
+ : data.sidecar.embedding_model ? "Sidecar responding: " + data.sidecar.embedding_model
817
+ : "Sidecar configured (" + data.sidecar.url + ") but not responding right now.";
818
+ } catch (error) {
819
+ $("local-models-hint").textContent = "Status unavailable: " + error.message;
820
+ }
821
+ }
822
+ loadLocalModels();
823
+ $("btn-save-local-models").addEventListener("click", async () => {
824
+ setMsg("local-models-msg", "", "Saving…");
825
+ try {
826
+ await post("/setup/local-models", { mode: $("sel-local-models").value });
827
+ setMsg("local-models-msg", "ok", "Mode saved; the indexes adjust on the next search or check.");
828
+ await loadLocalModels();
829
+ } catch (error) {
830
+ setMsg("local-models-msg", "error", error.message);
831
+ }
832
+ });
833
+
834
+ let agentsPlan = null;
835
+ async function loadAgents() {
836
+ try {
837
+ const resp = await fetch("/setup/integration");
838
+ const data = await resp.json();
839
+ if (!resp.ok || data.error) throw new Error(data.error);
840
+ if (document.activeElement !== $("sel-hook-mode")) $("sel-hook-mode").value = data.hook_mode;
841
+ if (data.hook_mode_error) setMsg("agents-msg", "error", data.hook_mode_error);
842
+ const list = $("agents-list");
843
+ list.textContent = "";
844
+ for (const item of data.clients) {
845
+ const row = document.createElement("div");
846
+ row.className = "provider";
847
+ const mcp = item.mcp_url ? "MCP registered: " + item.mcp_url : "smart-tool MCP not registered in this client";
848
+ const hook = item.hook_error ? "Hook unreadable: " + item.hook_error : item.hook ? "Hook installed and up to date" : "Hook missing or outdated";
849
+ row.innerHTML = `<div class="provider-head"><strong></strong><span class="state"></span></div><p class="hint mcp"></p><p class="hint mono cmd"></p><p class="hint file"></p>
850
+ <div class="actions"><button type="button" class="register">Register MCP</button><button type="button" class="hook">Install or update hook</button></div>`;
851
+ row.querySelector("strong").textContent = item.label;
852
+ row.querySelector(".state").textContent = hook;
853
+ row.querySelector(".mcp").textContent = mcp;
854
+ row.querySelector(".file").textContent = item.file;
855
+ row.querySelector(".cmd").textContent = item.mcp_url ? "" : "Or in a terminal: " + item.register_command;
856
+ const register = row.querySelector(".register");
857
+ register.hidden = Boolean(item.mcp_url);
858
+ register.addEventListener("click", async () => {
859
+ register.disabled = true;
860
+ setMsg("agents-msg", "", "Registering the MCP in " + item.label + "…");
861
+ try {
862
+ const result = await post("/setup/integration", { client: item.client, action: "register_mcp" });
863
+ setMsg("agents-msg", "ok", "MCP registered with: " + result.command + ". Reconnect open sessions (/mcp).");
864
+ await loadAgents();
865
+ $("btn-save-hook-mode").addEventListener("click", async () => {
866
+ try {
867
+ await post("/setup/integration", { action: "hook_mode", mode: $("sel-hook-mode").value });
868
+ setMsg("agents-msg", "ok", "Hook behavior saved; it applies on the next tool call.");
869
+ await loadAgents();
870
+ } catch (error) {
871
+ setMsg("agents-msg", "error", error.message);
872
+ }
873
+ });
874
+ } catch (error) {
875
+ setMsg("agents-msg", "error", error.message);
876
+ register.disabled = false;
877
+ }
878
+ });
879
+ const button = row.querySelector(".hook");
880
+ button.hidden = Boolean(item.hook);
881
+ button.addEventListener("click", async () => {
882
+ setMsg("agents-msg", "", "Preparing the change…");
883
+ try {
884
+ agentsPlan = { client: item.client, ...(await post("/setup/integration", { client: item.client, action: "preview" })) };
885
+ $("agents-preview").textContent = item.file + "\\n" + JSON.stringify(agentsPlan.change, null, 2);
886
+ $("agents-preview").hidden = false;
887
+ $("btn-agents-confirm").hidden = false;
888
+ setMsg("agents-msg", "", "Review the entry above; the file is backed up before writing.");
889
+ } catch (error) {
890
+ setMsg("agents-msg", "error", error.message);
891
+ }
892
+ });
893
+ list.appendChild(row);
894
+ }
895
+ } catch (error) {
896
+ setMsg("agents-msg", "error", "Agent status unavailable: " + error.message);
897
+ }
898
+ }
899
+ loadAgents();
900
+ $("btn-agents-confirm").addEventListener("click", async () => {
901
+ if (!agentsPlan) return;
902
+ try {
903
+ const result = await post("/setup/integration", { client: agentsPlan.client, action: "install", token: agentsPlan.token });
904
+ setMsg("agents-msg", "ok", "Hook written. Backup: " + (result.backup || "new file") + (result.after_install ? ". " + result.after_install : ""));
905
+ agentsPlan = null;
906
+ $("agents-preview").hidden = true;
907
+ $("btn-agents-confirm").hidden = true;
908
+ await loadAgents();
909
+ } catch (error) {
910
+ setMsg("agents-msg", "error", error.message);
911
+ }
912
+ });
913
+
914
+ const providerLabels = { keyless: ["warn", "No key · free tier"], configured: ["ok", "Key configured"], unreadable: ["bad", "Key unreadable in this Windows profile"] };
915
+ function providerCard(name, info) {
916
+ const card = document.createElement("div");
917
+ card.className = "provider";
918
+ card.innerHTML = `
919
+ <div class="provider-head"><strong></strong><span class="state" aria-live="polite"></span></div>
920
+ <p class="hint free"></p><p class="hint keyed"></p>
921
+ <p class="hint"><a target="_blank" rel="noopener noreferrer">Create a key in your provider account</a></p>
922
+ <label>API key</label>
923
+ <input type="password" autocomplete="new-password">
924
+ <div class="actions"><button type="button" data-action="save">Validate and save</button><button type="button" class="quiet" data-action="remove">Remove key</button></div>
925
+ <div class="msg" role="status"></div>`;
926
+ card.querySelector("strong").textContent = info.label;
927
+ card.querySelector(".free").textContent = info.free;
928
+ card.querySelector(".keyed").textContent = info.keyed;
929
+ const link = card.querySelector("a");
930
+ link.href = info.signup;
931
+ const input = card.querySelector("input");
932
+ input.id = name + "-key";
933
+ card.querySelector("label").htmlFor = input.id;
934
+ input.placeholder = info.key_prefix ? `Paste the key (starts with ${info.key_prefix})` : "Paste the key from your account";
935
+ const [kind, label] = providerLabels[info.status] || ["bad", info.status];
936
+ Object.assign(card.querySelector(".state"), { textContent: label }).dataset.state = kind;
937
+ const msg = card.querySelector(".msg");
938
+ msg.id = name + "-msg";
939
+ card.querySelector('[data-action="remove"]').disabled = info.status === "keyless";
940
+ for (const button of card.querySelectorAll("button")) {
941
+ button.addEventListener("click", async () => {
942
+ const action = button.dataset.action;
943
+ if (action === "save" && !input.value.trim()) { setMsg(msg.id, "error", "Paste the API key before saving."); return; }
944
+ button.disabled = true;
945
+ setMsg(msg.id, "", action === "save" ? "Testing the key with a real search…" : "Removing…");
946
+ try {
947
+ const data = await post("/setup/providers", { provider: name, action, ...(action === "save" ? { api_key: input.value.trim() } : {}) });
948
+ input.value = "";
949
+ await loadProviders();
950
+ setMsg(name + "-msg", "ok", action === "save" ? `Key accepted by the provider (${data.validated_results} result(s) in the test) and protected on this Windows machine.` : "Key removed; free tier active.");
951
+ } catch (error) {
952
+ setMsg(msg.id, "error", error.message);
953
+ button.disabled = false;
954
+ }
955
+ });
956
+ }
957
+ return card;
958
+ }
959
+ $("btn-save-contact").addEventListener("click", async () => {
960
+ try {
961
+ const data = await post("/setup/contact", { contact: $("contact-input").value.trim() });
962
+ $("contact-hint").textContent = "Sent as: " + data.user_agent;
963
+ setMsg("contact-msg", "ok", "Contact saved.");
964
+ } catch (error) {
965
+ setMsg("contact-msg", "error", error.message);
966
+ }
967
+ });
968
+
969
+ async function loadProviders() {
970
+ const list = $("providers-list");
971
+ try {
972
+ const resp = await fetch("/setup/providers");
973
+ const data = await resp.json();
974
+ if (!resp.ok || data.error) throw new Error(data.error);
975
+ list.replaceChildren(...Object.entries(data.providers).map(([name, info]) => providerCard(name, info)));
976
+ if (document.activeElement !== $("contact-input")) $("contact-input").value = data.contact;
977
+ $("contact-hint").textContent = "Sent as: " + data.user_agent;
978
+ } catch (error) {
979
+ list.replaceChildren(Object.assign(document.createElement("p"), { className: "msg error", textContent: "Provider status unavailable: " + error.message }));
980
+ }
981
+ }
982
+ loadProviders();
983
+ </script>
984
+ </body>
985
+ </html>
986
+ """