@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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)
|
package/capabilities.py
ADDED
|
@@ -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)
|