@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/autostart.py ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """Autostart do Smart Tool no logon do Windows — Tarefa Agendada por usuário (sem
3
+ admin), com política `RestartOnFailure` cobrindo o SO reiniciar o processo se ele
4
+ morrer. `schtasks /Create` pode ser bloqueado por política de grupo corporativa
5
+ (máquina AD-joined); nesse caso cai automaticamente para um atalho na pasta Startup
6
+ do usuário — sem a política de restart do Task Scheduler, mas o loop de polling do
7
+ próprio `tray.py` já cobre "se cair, sobe de novo" independente do mecanismo de boot.
8
+ """
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+
14
+ TASK_NAME = os.environ.get("SMART_TOOL_TASK_NAME", "SmartTool-Tray")
15
+
16
+
17
+ class AutostartError(Exception):
18
+ """Mensagem já pronta pra exibir ao usuário — sem stack trace nem caminho interno."""
19
+
20
+
21
+ def _xml_escape(text):
22
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
23
+
24
+
25
+ def _tool_dir():
26
+ return os.path.dirname(os.path.abspath(__file__))
27
+
28
+
29
+ def _tray_path():
30
+ return os.path.join(_tool_dir(), "tray.py")
31
+
32
+
33
+ def _pythonw_path():
34
+ exe = sys.executable
35
+ base, name = os.path.split(exe)
36
+ if name.lower() == "python.exe":
37
+ candidate = os.path.join(base, "pythonw.exe")
38
+ if os.path.isfile(candidate):
39
+ return candidate
40
+ return exe
41
+
42
+
43
+ def _current_user_id():
44
+ domain = os.environ.get("USERDOMAIN", "")
45
+ user = os.environ.get("USERNAME", "")
46
+ return f"{domain}\\{user}" if domain else user
47
+
48
+
49
+ def _task_xml():
50
+ user_id = _xml_escape(_current_user_id())
51
+ pythonw = _xml_escape(_pythonw_path())
52
+ tray = _xml_escape(_tray_path())
53
+ workdir = _xml_escape(_tool_dir())
54
+ return (
55
+ '<?xml version="1.0" encoding="UTF-16"?>\r\n'
56
+ '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\r\n'
57
+ ' <RegistrationInfo>\r\n'
58
+ ' <Description>Smart Tool - MCP daemon + tray icon, starts at user sign-in.</Description>\r\n'
59
+ ' </RegistrationInfo>\r\n'
60
+ ' <Triggers>\r\n'
61
+ ' <LogonTrigger>\r\n'
62
+ ' <Enabled>true</Enabled>\r\n'
63
+ f' <UserId>{user_id}</UserId>\r\n'
64
+ ' </LogonTrigger>\r\n'
65
+ ' </Triggers>\r\n'
66
+ ' <Principals>\r\n'
67
+ ' <Principal id="Author">\r\n'
68
+ f' <UserId>{user_id}</UserId>\r\n'
69
+ ' <LogonType>InteractiveToken</LogonType>\r\n'
70
+ ' <RunLevel>LeastPrivilege</RunLevel>\r\n'
71
+ ' </Principal>\r\n'
72
+ ' </Principals>\r\n'
73
+ ' <Settings>\r\n'
74
+ ' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\r\n'
75
+ ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\r\n'
76
+ ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\r\n'
77
+ ' <AllowHardTerminate>true</AllowHardTerminate>\r\n'
78
+ ' <StartWhenAvailable>true</StartWhenAvailable>\r\n'
79
+ ' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\r\n'
80
+ ' <Enabled>true</Enabled>\r\n'
81
+ ' <Hidden>false</Hidden>\r\n'
82
+ ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\r\n'
83
+ ' <RestartOnFailure>\r\n'
84
+ ' <Interval>PT1M</Interval>\r\n'
85
+ ' <Count>999</Count>\r\n'
86
+ ' </RestartOnFailure>\r\n'
87
+ ' </Settings>\r\n'
88
+ ' <Actions Context="Author">\r\n'
89
+ ' <Exec>\r\n'
90
+ f' <Command>{pythonw}</Command>\r\n'
91
+ f' <Arguments>"{tray}"</Arguments>\r\n'
92
+ f' <WorkingDirectory>{workdir}</WorkingDirectory>\r\n'
93
+ ' </Exec>\r\n'
94
+ ' </Actions>\r\n'
95
+ '</Task>\r\n'
96
+ )
97
+
98
+
99
+ def _startup_dir():
100
+ appdata = os.environ.get("APPDATA", "")
101
+ if not appdata:
102
+ raise AutostartError("APPDATA environment variable not set; could not find the user Startup folder.")
103
+ return os.path.join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
104
+
105
+
106
+ def _startup_script_path():
107
+ return os.path.join(_startup_dir(), "SmartTool-Tray.vbs")
108
+
109
+
110
+ def _vbs_quoted_run(pythonw, tray):
111
+ # WshShell.Run monta a linha de comando com Chr(34) em vez de aspas literais —
112
+ # evita ter que escapar aspas dentro de aspas tanto em Python quanto em VBScript.
113
+ return f'Chr(34) & "{pythonw}" & Chr(34) & " " & Chr(34) & "{tray}" & Chr(34)'
114
+
115
+
116
+ def _install_startup_shortcut():
117
+ os.makedirs(_startup_dir(), exist_ok=True)
118
+ vbs = (
119
+ 'Set shell = CreateObject("WScript.Shell")\r\n'
120
+ f'shell.Run {_vbs_quoted_run(_pythonw_path(), _tray_path())}, 0, False\r\n'
121
+ )
122
+ with open(_startup_script_path(), "w", encoding="utf-8") as f:
123
+ f.write(vbs)
124
+ return {"ok": True, "method": "startup_folder"}
125
+
126
+
127
+ def _remove_startup_shortcut():
128
+ try:
129
+ os.remove(_startup_script_path())
130
+ return True
131
+ except OSError:
132
+ return False
133
+
134
+
135
+ def _task_installed():
136
+ try:
137
+ result = subprocess.run(
138
+ ["schtasks", "/Query", "/TN", TASK_NAME],
139
+ capture_output=True, text=True, timeout=20,
140
+ )
141
+ except (OSError, subprocess.SubprocessError):
142
+ return False
143
+ return result.returncode == 0
144
+
145
+
146
+ def install():
147
+ """Tenta a Tarefa Agendada primeiro (cobre restart automático pelo SO); se
148
+ `schtasks /Create` falhar por qualquer motivo (ex.: bloqueado por politica de
149
+ grupo corporativa, `schtasks` fora do PATH, timeout), cai para o atalho de
150
+ Startup em vez de propagar o erro."""
151
+ fallback_reason = None
152
+ try:
153
+ xml = _task_xml()
154
+ fd, tmp_path = tempfile.mkstemp(suffix=".xml")
155
+ try:
156
+ with os.fdopen(fd, "wb") as f:
157
+ f.write(xml.encode("utf-16"))
158
+ result = subprocess.run(
159
+ ["schtasks", "/Create", "/TN", TASK_NAME, "/XML", tmp_path, "/F"],
160
+ capture_output=True, text=True, timeout=20,
161
+ )
162
+ finally:
163
+ try:
164
+ os.remove(tmp_path)
165
+ except OSError:
166
+ pass
167
+ if result.returncode == 0:
168
+ return {"ok": True, "method": "task"}
169
+ fallback_reason = (result.stderr or result.stdout or "").strip()
170
+ except (OSError, subprocess.SubprocessError) as exc:
171
+ fallback_reason = str(exc)
172
+
173
+ fallback = _install_startup_shortcut()
174
+ fallback["fallback_reason"] = fallback_reason
175
+ return fallback
176
+
177
+
178
+ def remove():
179
+ removed = []
180
+ try:
181
+ result = subprocess.run(
182
+ ["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
183
+ capture_output=True, text=True, timeout=20,
184
+ )
185
+ if result.returncode == 0:
186
+ removed.append("task")
187
+ except (OSError, subprocess.SubprocessError):
188
+ pass
189
+ if _remove_startup_shortcut():
190
+ removed.append("startup_folder")
191
+ return {"ok": True, "removed": removed}
192
+
193
+
194
+ def status():
195
+ if _task_installed():
196
+ return {"installed": True, "method": "task"}
197
+ if os.path.isfile(_startup_script_path()):
198
+ return {"installed": True, "method": "startup_folder"}
199
+ return {"installed": False, "method": None}
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const RUNTIME_DIR = join(homedir(), ".smart-tool", "runtime");
11
+ const UV_VERSION = "0.12.23";
12
+ const UV_ASSET = "uv-x86_64-pc-windows-msvc.zip";
13
+ const UV_SHA256 = "75d05de6762778c31ee183398de7dd15093fad0ed90b1f236d8205ea5ec00c90";
14
+ const UV_DIR = join(RUNTIME_DIR, `uv-${UV_VERSION}`);
15
+ const UV_EXE = join(UV_DIR, "uv.exe");
16
+ const PYTHON_VERSION = "3.12";
17
+ const PYTHON_DIR = join(RUNTIME_DIR, "python");
18
+ const USAGE = `Usage: npx @allansantos-dev/smart-tool install [--target DIR] [--no-start] [--skip-deps]
19
+
20
+ Installs or updates Smart Tool for this Windows user. Run it again with @latest to update.`;
21
+
22
+ function fail(message) {
23
+ console.error(`smart-tool: ${message}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ function run(command, args, options = {}) {
28
+ const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true, ...options });
29
+ if (result.error) fail(`${command} did not start: ${result.error.message}`);
30
+ if (result.status !== 0) fail(`${command} ${args.join(" ")} exited with ${result.status}: ${(result.stderr || result.stdout || "").trim().slice(-2000)}`);
31
+ return (result.stdout || "").trim();
32
+ }
33
+
34
+ async function ensureUv() {
35
+ if (existsSync(UV_EXE)) return;
36
+ const url = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ASSET}`;
37
+ console.log(`Downloading uv ${UV_VERSION} (Python manager)...`);
38
+ const response = await fetch(url);
39
+ if (!response.ok) fail(`download of ${url} failed: HTTP ${response.status}`);
40
+ const archive = Buffer.from(await response.arrayBuffer());
41
+ const digest = createHash("sha256").update(archive).digest("hex");
42
+ if (digest !== UV_SHA256) fail(`${UV_ASSET} checksum mismatch (expected ${UV_SHA256}, got ${digest}).`);
43
+ const scratch = mkdtempSync(join(tmpdir(), "smart-tool-uv-"));
44
+ try {
45
+ const zip = join(scratch, UV_ASSET);
46
+ writeFileSync(zip, archive);
47
+ mkdirSync(UV_DIR, { recursive: true });
48
+ run(join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe"), ["-xf", zip, "-C", UV_DIR]);
49
+ } finally {
50
+ rmSync(scratch, { recursive: true, force: true });
51
+ }
52
+ if (!existsSync(UV_EXE)) fail(`uv.exe not found in ${UV_DIR} after extracting ${UV_ASSET}.`);
53
+ }
54
+
55
+ function ensurePython() {
56
+ const env = { ...process.env, UV_PYTHON_INSTALL_DIR: PYTHON_DIR };
57
+ console.log(`Preparing Python ${PYTHON_VERSION} in ${PYTHON_DIR}...`);
58
+ run(UV_EXE, ["python", "install", PYTHON_VERSION, "--no-bin", "--no-registry"], { env });
59
+ const python = run(UV_EXE, ["python", "find", "--managed-python", "--no-python-downloads", PYTHON_VERSION], { env });
60
+ if (!python.toLowerCase().startsWith(PYTHON_DIR.toLowerCase())) fail(`uv returned a Python outside ${PYTHON_DIR}: ${python}`);
61
+ return python;
62
+ }
63
+
64
+ async function main() {
65
+ const [command, ...rest] = process.argv.slice(2);
66
+ if (command === "--version" || command === "-v") {
67
+ console.log(JSON.parse(readFileSync(join(PACKAGE_DIR, "package.json"), "utf8")).version);
68
+ return;
69
+ }
70
+ if (command !== "install") {
71
+ console.log(USAGE);
72
+ process.exit(command === undefined || command === "--help" || command === "-h" ? 0 : 1);
73
+ }
74
+ if (process.platform !== "win32" || process.arch !== "x64") fail(`Smart Tool runs on Windows x64 only (this is ${process.platform}-${process.arch}).`);
75
+ await ensureUv();
76
+ const python = ensurePython();
77
+ const installer = spawnSync(python, [join(PACKAGE_DIR, "install.py"), "--skip-tests", ...rest], {
78
+ stdio: "inherit",
79
+ cwd: PACKAGE_DIR,
80
+ env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" },
81
+ });
82
+ if (installer.error) fail(`the installer did not start: ${installer.error.message}`);
83
+ process.exit(installer.status ?? 1);
84
+ }
85
+
86
+ main().catch((error) => fail(error.stack || String(error)));
@@ -0,0 +1,232 @@
1
+ """Cliente do navegador residente (web_adapters/browser/browser_service.py): um processo para todos os consumidores."""
2
+ import concurrent.futures
3
+ import json
4
+ import os
5
+ import queue
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ import uuid
10
+
11
+ START_TIMEOUT_S = 45
12
+ START_FAILURE_COOLDOWN_S = 60
13
+ SERVICE_IDLE_S = 300
14
+ # Restart instead of writing to a service that may be exiting on its own idle timer.
15
+ IDLE_RESTART_MARGIN_S = 15
16
+ _IS_WINDOWS = os.name == "nt"
17
+
18
+
19
+ class BrowserCaptcha(RuntimeError):
20
+ pass
21
+
22
+
23
+ class BrowserSkipped(RuntimeError):
24
+ """Self-imposed limit (queue, pacing, pause, startup): not a failure of the search engine."""
25
+
26
+
27
+ class BrowserExited(RuntimeError):
28
+ pass
29
+
30
+
31
+ def _kill_on_close_job():
32
+ import ctypes
33
+ from ctypes import wintypes
34
+
35
+ class IoCounters(ctypes.Structure):
36
+ _fields_ = [(name, ctypes.c_ulonglong) for name in
37
+ ("read_ops", "write_ops", "other_ops", "read_bytes", "write_bytes", "other_bytes")]
38
+
39
+ class BasicLimits(ctypes.Structure):
40
+ _fields_ = [("per_process_time", ctypes.c_longlong), ("per_job_time", ctypes.c_longlong),
41
+ ("flags", wintypes.DWORD), ("min_ws", ctypes.c_size_t), ("max_ws", ctypes.c_size_t),
42
+ ("active_processes", wintypes.DWORD), ("affinity", ctypes.c_size_t),
43
+ ("priority", wintypes.DWORD), ("scheduling", wintypes.DWORD)]
44
+
45
+ class ExtendedLimits(ctypes.Structure):
46
+ _fields_ = [("basic", BasicLimits), ("io", IoCounters), ("process_memory", ctypes.c_size_t),
47
+ ("job_memory", ctypes.c_size_t), ("peak_process_memory", ctypes.c_size_t),
48
+ ("peak_job_memory", ctypes.c_size_t)]
49
+
50
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
51
+ kernel32.CreateJobObjectW.restype = wintypes.HANDLE
52
+ job = kernel32.CreateJobObjectW(None, None)
53
+ if not job:
54
+ raise ctypes.WinError(ctypes.get_last_error())
55
+ limits = ExtendedLimits()
56
+ limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
57
+ if not kernel32.SetInformationJobObject(wintypes.HANDLE(job), 9, ctypes.byref(limits), ctypes.sizeof(limits)):
58
+ raise ctypes.WinError(ctypes.get_last_error())
59
+ return kernel32, job
60
+
61
+
62
+ class BrowserService:
63
+ def __init__(self, executable, script):
64
+ self.executable = executable
65
+ self.script = script
66
+ self.lock = threading.Lock()
67
+ self.start_lock = threading.Lock()
68
+ self.proc = None
69
+ self.writes = None
70
+ self.pending = {}
71
+ self.stderr_tail = ""
72
+ self.last_used = 0.0
73
+ self.start_failure = None
74
+ self.job = None
75
+
76
+ def _alive(self):
77
+ return self.proc is not None and self.proc.poll() is None
78
+
79
+ def _ensure_started(self, deadline):
80
+ with self.lock:
81
+ if self._alive() and time.monotonic() - self.last_used < SERVICE_IDLE_S - IDLE_RESTART_MARGIN_S:
82
+ return self.proc, self.writes
83
+ if not self.start_lock.acquire(timeout=max(0, deadline - time.monotonic())):
84
+ raise BrowserSkipped("resident browser still opening at the deadline")
85
+ try:
86
+ with self.lock:
87
+ if self._alive() and time.monotonic() - self.last_used < SERVICE_IDLE_S - IDLE_RESTART_MARGIN_S:
88
+ return self.proc, self.writes
89
+ stale, self.proc = self.proc, None
90
+ if stale is not None:
91
+ self._kill(stale)
92
+ if self.start_failure and time.monotonic() - self.start_failure[0] < START_FAILURE_COOLDOWN_S:
93
+ raise RuntimeError(self.start_failure[1])
94
+ try:
95
+ proc, writes = self._start()
96
+ except RuntimeError as exc:
97
+ self.start_failure = (time.monotonic(), str(exc))
98
+ raise
99
+ self.start_failure = None
100
+ with self.lock:
101
+ self.proc, self.writes, self.last_used = proc, writes, time.monotonic()
102
+ return proc, writes
103
+ finally:
104
+ self.start_lock.release()
105
+
106
+ def _start(self):
107
+ flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if _IS_WINDOWS else 0
108
+ proc = subprocess.Popen([self.executable, self.script], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
109
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
110
+ creationflags=flags)
111
+ if _IS_WINDOWS:
112
+ self._bind_to_daemon(proc)
113
+ ready = concurrent.futures.Future()
114
+ writes = queue.Queue()
115
+ threading.Thread(target=self._read_stderr, args=(proc,), daemon=True).start()
116
+ threading.Thread(target=self._read_stdout, args=(proc, ready), daemon=True).start()
117
+ threading.Thread(target=self._write_stdin, args=(proc, writes), daemon=True).start()
118
+ try:
119
+ ready.result(timeout=START_TIMEOUT_S)
120
+ except Exception as exc:
121
+ self._kill(proc)
122
+ raise RuntimeError(f"resident browser failed to start: {exc}; {self.stderr_tail[-500:]}") from None
123
+ return proc, writes
124
+
125
+ def _bind_to_daemon(self, proc):
126
+ # Kill-on-close job: the browser tree dies with the daemon even when the daemon is killed hard.
127
+ try:
128
+ if self.job is None:
129
+ self.job = _kill_on_close_job()
130
+ kernel32, job = self.job
131
+ import ctypes
132
+ from ctypes import wintypes
133
+ if not kernel32.AssignProcessToJobObject(wintypes.HANDLE(job), wintypes.HANDLE(proc._handle)):
134
+ raise ctypes.WinError(ctypes.get_last_error())
135
+ except OSError as exc:
136
+ self.stderr_tail += f"\nprocess outside the job object: {exc}\n"
137
+
138
+ def _write_stdin(self, proc, writes):
139
+ while True:
140
+ line = writes.get()
141
+ if line is None:
142
+ return
143
+ try:
144
+ proc.stdin.write(line)
145
+ proc.stdin.flush()
146
+ except (OSError, ValueError):
147
+ return
148
+
149
+ def _read_stdout(self, proc, ready):
150
+ for line in proc.stdout:
151
+ try:
152
+ message = json.loads(line)
153
+ except ValueError:
154
+ continue
155
+ if not isinstance(message, dict):
156
+ continue
157
+ if message.get("ready"):
158
+ if not ready.done():
159
+ ready.set_result(True)
160
+ continue
161
+ with self.lock:
162
+ _owner, future = self.pending.pop(message.get("id"), (None, None))
163
+ if future and not future.done():
164
+ future.set_result(message)
165
+ reason = f"resident browser exited (code {proc.wait()}): {self.stderr_tail[-500:]}"
166
+ if not ready.done():
167
+ ready.set_exception(RuntimeError(reason))
168
+ with self.lock:
169
+ orphans = [key for key, (owner, _) in self.pending.items() if owner is proc]
170
+ futures = [self.pending.pop(key)[1] for key in orphans]
171
+ for future in futures:
172
+ if not future.done():
173
+ future.set_exception(BrowserExited(reason))
174
+
175
+ def _read_stderr(self, proc):
176
+ for line in proc.stderr:
177
+ self.stderr_tail = (self.stderr_tail + line)[-4000:]
178
+
179
+ def _kill(self, proc):
180
+ if proc.poll() is None:
181
+ if _IS_WINDOWS:
182
+ subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True,
183
+ creationflags=subprocess.CREATE_NO_WINDOW)
184
+ else:
185
+ proc.kill()
186
+ try:
187
+ proc.wait(timeout=10)
188
+ except subprocess.TimeoutExpired:
189
+ pass
190
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
191
+ try:
192
+ stream.close()
193
+ except (OSError, ValueError):
194
+ pass
195
+
196
+ def search(self, engine, query, num, timeout_s):
197
+ deadline = time.monotonic() + timeout_s
198
+ proc, writes = self._ensure_started(deadline)
199
+ left = deadline - time.monotonic()
200
+ if left < 1:
201
+ raise BrowserSkipped(f"{engine}: deadline used up while opening the browser")
202
+ request_id = uuid.uuid4().hex
203
+ future = concurrent.futures.Future()
204
+ with self.lock:
205
+ self.pending[request_id] = (proc, future)
206
+ self.last_used = time.monotonic()
207
+ writes.put(json.dumps({"id": request_id, "engine": engine, "query": query, "num": num,
208
+ "expires_at": time.time() + left - 0.5}) + "\n")
209
+ try:
210
+ message = future.result(timeout=left + 2)
211
+ except concurrent.futures.TimeoutError:
212
+ with self.lock:
213
+ self.pending.pop(request_id, None)
214
+ raise TimeoutError(f"{engine}: resident browser did not respond in time") from None
215
+ finally:
216
+ with self.lock:
217
+ self.last_used = time.monotonic()
218
+ if message.get("captcha"):
219
+ raise BrowserCaptcha(message["error"])
220
+ if message.get("skipped"):
221
+ raise BrowserSkipped(message["error"])
222
+ if "error" in message:
223
+ raise RuntimeError(message["error"])
224
+ return message["results"]
225
+
226
+ def close(self):
227
+ with self.lock:
228
+ proc, writes, self.proc = self.proc, self.writes, None
229
+ if writes is not None:
230
+ writes.put(None)
231
+ if proc is not None:
232
+ self._kill(proc)
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env python3
2
+ """Probe de capacidades do Smart Tool (grafo/embedding/rerank), rodado no boot do daemon.
3
+
4
+ Grafo: client-pure, mesmo contrato do Brain Server (`claude-code-boss/.../graph/daemon.js`) —
5
+ lê o registry auto-anunciado em `~/.mcp-memory/run/daemon.json` e faz health-check em
6
+ `{url}/health` (200 ou 503 = viva). Nunca spawna o daemon do grafo; se ausente/offline, falha
7
+ aberto (graph=False), sem lançar exceção.
8
+ """
9
+ import json
10
+ import os
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ import model_client
15
+
16
+ BRAIN_RUN_DIR = os.environ.get("MCP_RUN_DIR") or os.path.join(
17
+ os.path.expanduser("~"), ".mcp-memory", "run"
18
+ )
19
+ BRAIN_REGISTRY_PATH = os.path.join(BRAIN_RUN_DIR, "daemon.json")
20
+
21
+
22
+ class Capabilities:
23
+ def __init__(self, graph, embed, rerank):
24
+ self.graph = graph
25
+ self.embed = embed
26
+ self.rerank = rerank
27
+
28
+ def to_dict(self):
29
+ return {"graph": self.graph, "embed": self.embed, "rerank": self.rerank}
30
+
31
+
32
+ def _probe_graph(timeout=2):
33
+ try:
34
+ with open(BRAIN_REGISTRY_PATH, "r", encoding="utf-8") as f:
35
+ info = json.load(f)
36
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
37
+ return False
38
+ url = info.get("url") if isinstance(info, dict) else None
39
+ if not url:
40
+ return False
41
+ try:
42
+ with urllib.request.urlopen(url.rstrip("/") + "/health", timeout=timeout) as resp:
43
+ return resp.status in (200, 503)
44
+ except (urllib.error.URLError, OSError, ValueError):
45
+ return False
46
+
47
+
48
+ def _probe_embed_rerank():
49
+ token = model_client.get_token()
50
+ data = model_client.fetch("/v1/models", token)
51
+ ids = [row.get("id", "") for row in data.get("data", [])]
52
+ embed = any("embed" in i.lower() for i in ids)
53
+ rerank = any("rerank" in i.lower() for i in ids)
54
+ return embed, rerank
55
+
56
+
57
+ def probe():
58
+ graph = _probe_graph()
59
+ try:
60
+ embed, rerank = _probe_embed_rerank()
61
+ except Exception:
62
+ embed, rerank = False, False
63
+ return Capabilities(graph, embed, rerank)