@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0
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 +23 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -3
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +82 -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 +251 -0
- package/install_runtime.py +249 -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 +35 -6
- 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/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
|
@@ -0,0 +1,82 @@
|
|
|
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], { stdio: "inherit", cwd: PACKAGE_DIR });
|
|
78
|
+
if (installer.error) fail(`the installer did not start: ${installer.error.message}`);
|
|
79
|
+
process.exit(installer.status ?? 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
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)
|
package/client_hooks.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import daemon_launcher
|
|
9
|
+
import endpoint_sync
|
|
10
|
+
|
|
11
|
+
HOOK_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hooks", "pretool_router.py")
|
|
12
|
+
MARKER = "pretool_router.py"
|
|
13
|
+
HTTP_PATH = "/hooks/pretool"
|
|
14
|
+
HTTP_TIMEOUT_S = 15
|
|
15
|
+
HOME = os.path.expanduser("~")
|
|
16
|
+
PREVIEW_TTL_S = 600
|
|
17
|
+
CLIENTS = {
|
|
18
|
+
"claude": {"label": "Claude Code", "names": {"claude-code"}, "files": [os.path.join(endpoint_sync.claude_dir(), "settings.json")],
|
|
19
|
+
"docs": "https://code.claude.com/docs/en/hooks", "event": "PreToolUse", "matcher": "Grep|Glob|Read|Bash|WebSearch|WebFetch", "http": True},
|
|
20
|
+
"codex": {"label": "Codex", "prefix": "codex", "files": [os.path.join(HOME, ".codex", "hooks.json"), os.path.join(HOME, ".codex", "config.toml")],
|
|
21
|
+
"docs": "https://learn.chatgpt.com/docs/hooks", "event": "PreToolUse", "matcher": "Bash",
|
|
22
|
+
"after_install": "Codex only runs new hooks after the user approves the definition in /hooks."},
|
|
23
|
+
"cursor": {"label": "Cursor", "prefix": "cursor", "files": [os.path.join(HOME, ".cursor", "hooks.json")],
|
|
24
|
+
"docs": "https://cursor.com/docs/agent/hooks", "event": "preToolUse", "matcher": "Shell|Read|Grep",
|
|
25
|
+
"blocked": "In Cursor, `allow` approves the tool without the user and empty output blocks: there is no documented neutral "
|
|
26
|
+
"response, so the hook is not installed automatically."},
|
|
27
|
+
}
|
|
28
|
+
_PREVIEWS = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def identify(client_info):
|
|
32
|
+
name = str((client_info or {}).get("name") or "").casefold()
|
|
33
|
+
return next((key for key, spec in CLIENTS.items()
|
|
34
|
+
if name in spec.get("names", ()) or (spec.get("prefix") and name.startswith(spec["prefix"]))), None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _our_hook(hook):
|
|
38
|
+
return isinstance(hook, dict) and (MARKER in str(hook.get("command", "")) or HTTP_PATH in str(hook.get("url", "")))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ours(group):
|
|
42
|
+
return isinstance(group, dict) and isinstance(group.get("hooks"), list) and any(map(_our_hook, group["hooks"]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _without_ours(groups):
|
|
46
|
+
"""Drops only our hook entries; a group shared with other hooks keeps them and disappears only when emptied."""
|
|
47
|
+
kept = []
|
|
48
|
+
for group in groups:
|
|
49
|
+
if not _ours(group):
|
|
50
|
+
kept.append(group)
|
|
51
|
+
continue
|
|
52
|
+
others = [hook for hook in group["hooks"] if not _our_hook(hook)]
|
|
53
|
+
if others:
|
|
54
|
+
kept.append({**group, "hooks": others})
|
|
55
|
+
return kept
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def installed(client):
|
|
59
|
+
"""Our hook is present with the current matcher; an entry with an older matcher counts as missing."""
|
|
60
|
+
spec = CLIENTS[client]
|
|
61
|
+
for path in spec["files"]:
|
|
62
|
+
try:
|
|
63
|
+
with open(path, encoding="utf-8") as stream:
|
|
64
|
+
text = stream.read()
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
continue
|
|
67
|
+
if MARKER not in text and HTTP_PATH not in text:
|
|
68
|
+
continue
|
|
69
|
+
if not path.endswith(".json"):
|
|
70
|
+
return True
|
|
71
|
+
data = json.loads(text)
|
|
72
|
+
hooks = data.get("hooks") if isinstance(data, dict) else None
|
|
73
|
+
groups = hooks.get(spec["event"]) if isinstance(hooks, dict) else None
|
|
74
|
+
expected = _handler(client)
|
|
75
|
+
return isinstance(groups, list) and any(
|
|
76
|
+
group.get("matcher") == spec["matcher"] and expected in group["hooks"] for group in groups if _ours(group))
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def status(client_info):
|
|
81
|
+
client = identify(client_info)
|
|
82
|
+
if client is None:
|
|
83
|
+
return {"client": None, "name": (client_info or {}).get("name"), "installed": None, "error": None}
|
|
84
|
+
spec = CLIENTS[client]
|
|
85
|
+
try:
|
|
86
|
+
state, error = installed(client), None
|
|
87
|
+
except (OSError, ValueError) as exc:
|
|
88
|
+
state, error = None, f"{type(exc).__name__}: {exc}"[:200]
|
|
89
|
+
return {"client": client, "name": client_info.get("name"), "installed": state, "error": error,
|
|
90
|
+
"files": spec["files"], "docs": spec["docs"]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def notice(state):
|
|
94
|
+
if state["client"] is None:
|
|
95
|
+
return (f"Unrecognized MCP client ({state['name'] or 'no clientInfo'}): Smart Tool does not check the hook "
|
|
96
|
+
"that redirects Grep/Read to smart_search and WebSearch/WebFetch to web_search/web_fetch in this client.")
|
|
97
|
+
spec = CLIENTS[state["client"]]
|
|
98
|
+
if state["error"]:
|
|
99
|
+
return f"Could not check the Smart Tool hook in {spec['files'][0]}: {state['error']}"
|
|
100
|
+
if state["installed"]:
|
|
101
|
+
return None
|
|
102
|
+
action = spec.get("blocked") or ("If the user asks, use project_manage action=install_hook_preview, show the "
|
|
103
|
+
"change and confirm with action=install_hook (makes a backup).")
|
|
104
|
+
return (f"Smart Tool hook missing or outdated in {spec['label']} ({spec['files'][0]}): Grep/Read/Bash are not "
|
|
105
|
+
f"redirected to smart_search, nor WebSearch/WebFetch to web_search/web_fetch. {action} Docs: {spec['docs']}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _shell_safe(path):
|
|
109
|
+
"""Unquoted path that runs the same in cmd, PowerShell and bash (the Codex docs don't name the shell): forward
|
|
110
|
+
slashes (bash eats backslashes), no spaces (8.3 short name), no quotes (PowerShell reads a quoted head as a string)."""
|
|
111
|
+
if " " in path and sys.platform == "win32":
|
|
112
|
+
import ctypes
|
|
113
|
+
buffer = ctypes.create_unicode_buffer(1024)
|
|
114
|
+
if ctypes.windll.kernel32.GetShortPathNameW(path, buffer, len(buffer)):
|
|
115
|
+
path = buffer.value
|
|
116
|
+
if " " in path:
|
|
117
|
+
raise ValueError(f"Path contains a space and has no 8.3 short name ({path}); the hook was not installed.")
|
|
118
|
+
return path.replace("\\", "/")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _command(client):
|
|
122
|
+
return f"{_shell_safe(sys.executable)} {_shell_safe(HOOK_SCRIPT)} --client {client}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _handler(client):
|
|
126
|
+
"""Claude Code posts the payload straight to the warm daemon (`type: "http"`, no process per tool call); other
|
|
127
|
+
clients run the thin command that forwards to the same route."""
|
|
128
|
+
if CLIENTS[client].get("http"):
|
|
129
|
+
registry = daemon_launcher.read_registry()
|
|
130
|
+
if not registry or not registry.get("url"):
|
|
131
|
+
raise ValueError("Smart Tool daemon has no registered URL; start the daemon before installing the hook.")
|
|
132
|
+
return {"type": "http", "url": f"{registry['url']}{HTTP_PATH}?client={client}", "timeout": HTTP_TIMEOUT_S}
|
|
133
|
+
return {"type": "command", "command": _command(client), "timeout": HTTP_TIMEOUT_S}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _plan(client_info):
|
|
137
|
+
client = identify(client_info)
|
|
138
|
+
if client is None:
|
|
139
|
+
raise ValueError(f"Unrecognized MCP client ({(client_info or {}).get('name') or 'no clientInfo'}); "
|
|
140
|
+
"the hook is only installed for Claude Code or Codex, from that client's own session.")
|
|
141
|
+
spec = CLIENTS[client]
|
|
142
|
+
if spec.get("blocked"):
|
|
143
|
+
raise ValueError(spec["blocked"] + f" Configure it manually if you want: {spec['docs']}")
|
|
144
|
+
path = os.path.realpath(spec["files"][0])
|
|
145
|
+
try:
|
|
146
|
+
with open(path, encoding="utf-8") as stream:
|
|
147
|
+
data = json.load(stream)
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
data = {}
|
|
150
|
+
if not isinstance(data, dict) or not isinstance(data.setdefault("hooks", {}), dict):
|
|
151
|
+
raise ValueError(f"{path} does not have the expected format (object with 'hooks'); nothing was changed.")
|
|
152
|
+
groups = data["hooks"].setdefault(spec["event"], [])
|
|
153
|
+
if not isinstance(groups, list):
|
|
154
|
+
raise ValueError(f"{path}: hooks.{spec['event']} is not a list; nothing was changed.")
|
|
155
|
+
groups[:] = _without_ours(groups)
|
|
156
|
+
entry = {"matcher": spec["matcher"], "hooks": [_handler(client)]}
|
|
157
|
+
return client, spec, path, data, groups, entry
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def preview(client_info):
|
|
161
|
+
client, spec, path, _data, _groups, entry = _plan(client_info)
|
|
162
|
+
if installed(client):
|
|
163
|
+
return {"client": client, "installed": True, "file": path, "change": None}
|
|
164
|
+
token = secrets.token_urlsafe(12)
|
|
165
|
+
_PREVIEWS[token] = {"client": client, "at": time.time()}
|
|
166
|
+
return {"client": client, "installed": False, "file": path, "event": spec["event"], "change": entry,
|
|
167
|
+
"token": token, "expires_s": PREVIEW_TTL_S, "after_install": spec.get("after_install")}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def install(client_info, token, confirm):
|
|
171
|
+
planned = _PREVIEWS.pop(token or "", None)
|
|
172
|
+
client, spec, path, data, groups, entry = _plan(client_info)
|
|
173
|
+
if confirm is not True or not planned or planned["client"] != client or time.time() - planned["at"] > PREVIEW_TTL_S:
|
|
174
|
+
raise ValueError("Installing requires the install_hook_preview token from this session (valid for 10 min) and confirm=true.")
|
|
175
|
+
if installed(client):
|
|
176
|
+
return {"client": client, "installed": True, "changed": False, "file": path}
|
|
177
|
+
groups.append(entry)
|
|
178
|
+
backup = None
|
|
179
|
+
if os.path.exists(path):
|
|
180
|
+
backup = f"{path}.smart-tool-backup-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
181
|
+
shutil.copy2(path, backup)
|
|
182
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
183
|
+
temporary = path + ".smart-tool-tmp"
|
|
184
|
+
with open(temporary, "w", encoding="utf-8") as stream:
|
|
185
|
+
json.dump(data, stream, indent=2, ensure_ascii=False)
|
|
186
|
+
os.replace(temporary, path)
|
|
187
|
+
if not installed(client):
|
|
188
|
+
if backup:
|
|
189
|
+
shutil.copy2(backup, path)
|
|
190
|
+
raise RuntimeError(f"The hook does not appear in {path} after writing; the file was restored.")
|
|
191
|
+
return {"client": client, "installed": True, "changed": True, "file": path, "backup": backup,
|
|
192
|
+
"after_install": spec.get("after_install")}
|