@tiens.nguyen/gonext-cli 1.0.350
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/README.md +52 -0
- package/coding-key-prompt.mjs +47 -0
- package/daemon-control.mjs +232 -0
- package/device-login.mjs +141 -0
- package/gonext-cli.mjs +3216 -0
- package/gonext-repl.mjs +2648 -0
- package/gonext_agent_chat.py +7576 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/model-doctor.mjs +571 -0
- package/package.json +53 -0
- package/thinking_words.txt +1003 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
GoNext MLX embeddings server — an OpenAI-compatible /v1/embeddings endpoint for MLX
|
|
4
|
+
embedding models (e.g. Qwen3-Embedding-8B), which `mlx_lm.server` does NOT provide.
|
|
5
|
+
|
|
6
|
+
The GoNext worker's RAG tools call `POST {url}/v1/embeddings` first, so pointing the
|
|
7
|
+
"RAG embedding server URL" (web Settings → Agent → RAG) at this server makes MLX
|
|
8
|
+
embeddings work with no other changes.
|
|
9
|
+
|
|
10
|
+
Run:
|
|
11
|
+
python3 gonext_mlx_embed.py --model ~/mlx-models/Qwen3-Embedding-8B-4bit-DWQ --port 8085
|
|
12
|
+
|
|
13
|
+
Then set the RAG embedding server URL to http://127.0.0.1:8085
|
|
14
|
+
|
|
15
|
+
Endpoints:
|
|
16
|
+
POST /v1/embeddings {"model": <ignored>, "input": "text" | ["t1","t2",...]}
|
|
17
|
+
→ {"object":"list","data":[{"object":"embedding","index":i,
|
|
18
|
+
"embedding":[...]}], "model": <name>, "usage": {...}}
|
|
19
|
+
GET /v1/models → the single loaded model
|
|
20
|
+
GET /health → {"status":"ok"}
|
|
21
|
+
|
|
22
|
+
Pooling: last-token hidden state (Qwen3-Embedding convention), L2-normalized.
|
|
23
|
+
Requires: mlx, mlx_lm (already used by the worker). No extra deps.
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
|
|
32
|
+
import mlx.core as mx
|
|
33
|
+
from mlx_lm import load
|
|
34
|
+
|
|
35
|
+
# Cap tokens per input so a pathologically long chunk can't OOM / stall the GPU.
|
|
36
|
+
MAX_TOKENS = 8192
|
|
37
|
+
|
|
38
|
+
_model = None
|
|
39
|
+
_tok = None
|
|
40
|
+
_inner = None # the transformer body that returns hidden states
|
|
41
|
+
_model_name = "mlx-embedding"
|
|
42
|
+
_lock = threading.Lock() # MLX eval isn't guaranteed thread-safe; serialize inference
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load(model_path: str) -> None:
|
|
46
|
+
global _model, _tok, _inner, _model_name
|
|
47
|
+
print(f"[gonext-mlx-embed] loading {model_path} …", flush=True)
|
|
48
|
+
_model, _tok = load(model_path)
|
|
49
|
+
# mlx_lm wraps the transformer body as `.model` (returns hidden states); `_model(x)`
|
|
50
|
+
# would return logits from the LM head, which we do NOT want.
|
|
51
|
+
_inner = getattr(_model, "model", None) or getattr(_model, "language_model", None)
|
|
52
|
+
if _inner is None:
|
|
53
|
+
raise RuntimeError("Could not find the transformer body on the loaded model.")
|
|
54
|
+
_model_name = os.path.basename(os.path.normpath(model_path))
|
|
55
|
+
# Warm + sanity-check the embedding path so a bad model fails fast at startup.
|
|
56
|
+
v = _embed_one("warmup")
|
|
57
|
+
print(f"[gonext-mlx-embed] ready — {_model_name}, dim={len(v)}", flush=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _embed_one(text: str) -> list:
|
|
61
|
+
ids = _tok.encode(text or " ")
|
|
62
|
+
if not ids:
|
|
63
|
+
ids = _tok.encode(" ")
|
|
64
|
+
if len(ids) > MAX_TOKENS:
|
|
65
|
+
ids = ids[:MAX_TOKENS]
|
|
66
|
+
h = _inner(mx.array([ids])) # (1, seq, hidden)
|
|
67
|
+
v = h[0, -1, :] # last-token pooling
|
|
68
|
+
v = v / mx.maximum(mx.linalg.norm(v), 1e-9) # L2 normalize
|
|
69
|
+
mx.eval(v)
|
|
70
|
+
return [float(x) for x in v.tolist()]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _embed_batch(texts: list) -> list:
|
|
74
|
+
with _lock:
|
|
75
|
+
out = [_embed_one(t) for t in texts]
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Handler(BaseHTTPRequestHandler):
|
|
80
|
+
protocol_version = "HTTP/1.1"
|
|
81
|
+
|
|
82
|
+
def log_message(self, *a): # quieter logs
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def _send(self, code: int, obj: dict) -> None:
|
|
86
|
+
body = json.dumps(obj).encode("utf-8")
|
|
87
|
+
self.send_response(code)
|
|
88
|
+
self.send_header("Content-Type", "application/json")
|
|
89
|
+
self.send_header("Content-Length", str(len(body)))
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(body)
|
|
92
|
+
|
|
93
|
+
def do_GET(self):
|
|
94
|
+
if self.path == "/health":
|
|
95
|
+
self._send(200, {"status": "ok", "model": _model_name})
|
|
96
|
+
elif self.path in ("/v1/models", "/models"):
|
|
97
|
+
self._send(200, {"object": "list", "data": [
|
|
98
|
+
{"id": _model_name, "object": "model", "owned_by": "gonext-mlx-embed"}
|
|
99
|
+
]})
|
|
100
|
+
else:
|
|
101
|
+
self._send(404, {"error": {"message": f"unknown path {self.path}"}})
|
|
102
|
+
|
|
103
|
+
def do_POST(self):
|
|
104
|
+
if self.path not in ("/v1/embeddings", "/embeddings"):
|
|
105
|
+
self._send(404, {"error": {"message": f"unknown path {self.path}"}})
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
n = int(self.headers.get("Content-Length", 0) or 0)
|
|
109
|
+
payload = json.loads(self.rfile.read(n) or b"{}")
|
|
110
|
+
except Exception as e: # noqa: BLE001
|
|
111
|
+
self._send(400, {"error": {"message": f"bad JSON: {e}"}})
|
|
112
|
+
return
|
|
113
|
+
inp = payload.get("input")
|
|
114
|
+
if inp is None:
|
|
115
|
+
self._send(400, {"error": {"message": "missing 'input'"}})
|
|
116
|
+
return
|
|
117
|
+
texts = [inp] if isinstance(inp, str) else list(inp)
|
|
118
|
+
if not all(isinstance(t, str) for t in texts):
|
|
119
|
+
self._send(400, {"error": {"message": "'input' must be a string or list of strings"}})
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
vecs = _embed_batch(texts)
|
|
123
|
+
except Exception as e: # noqa: BLE001
|
|
124
|
+
self._send(500, {"error": {"message": f"embedding failed: {type(e).__name__}: {e}"}})
|
|
125
|
+
return
|
|
126
|
+
total_tokens = sum(len(_tok.encode(t or " ")) for t in texts)
|
|
127
|
+
self._send(200, {
|
|
128
|
+
"object": "list",
|
|
129
|
+
"data": [
|
|
130
|
+
{"object": "embedding", "index": i, "embedding": v}
|
|
131
|
+
for i, v in enumerate(vecs)
|
|
132
|
+
],
|
|
133
|
+
"model": payload.get("model") or _model_name,
|
|
134
|
+
"usage": {"prompt_tokens": total_tokens, "total_tokens": total_tokens},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main():
|
|
139
|
+
ap = argparse.ArgumentParser(description="OpenAI-compatible MLX embeddings server.")
|
|
140
|
+
ap.add_argument("--model", required=True, help="Path to the MLX embedding model dir.")
|
|
141
|
+
ap.add_argument("--port", type=int, default=8085)
|
|
142
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
143
|
+
args = ap.parse_args()
|
|
144
|
+
_load(os.path.expanduser(args.model))
|
|
145
|
+
srv = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
146
|
+
print(f"[gonext-mlx-embed] serving /v1/embeddings on http://{args.host}:{args.port}", flush=True)
|
|
147
|
+
try:
|
|
148
|
+
srv.serve_forever()
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
print("\n[gonext-mlx-embed] shutting down", flush=True)
|
|
151
|
+
srv.shutdown()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gonext probe agent (agentic mode for the gonext local worker).
|
|
4
|
+
|
|
5
|
+
Reads a JSON probe config on stdin and writes a single JSON object on stdout:
|
|
6
|
+
|
|
7
|
+
in: {"request": {"method","url"},
|
|
8
|
+
"measurement": {"status","statusText","latencyMs","headers",
|
|
9
|
+
"bodySnippet","bodyBytes"} | null,
|
|
10
|
+
"category": str, "error": str | null,
|
|
11
|
+
"agentBaseURL","agentApiKey","agentModelId"}
|
|
12
|
+
out: {"agentSummary": str, "error": str | null}
|
|
13
|
+
|
|
14
|
+
The worker performs the authoritative HTTP measurement (reliable TLS via Node
|
|
15
|
+
fetch) and passes it here; this script drives a smolagents agent on the selected
|
|
16
|
+
local model to produce the natural-language health assessment. The agent's
|
|
17
|
+
`send_request` tool returns the already-measured result, so there is exactly one
|
|
18
|
+
network call (the worker's) and the measured status stays the source of truth.
|
|
19
|
+
|
|
20
|
+
smolagents/rich writes its console UI to stdout, so we redirect stdout to stderr
|
|
21
|
+
during the agent run and only emit our JSON result on stdout.
|
|
22
|
+
|
|
23
|
+
See docs/plans/smolagents-local-worker-http-probe.md.
|
|
24
|
+
"""
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def describe(measurement, category, error):
|
|
31
|
+
if not measurement or error:
|
|
32
|
+
return f"The request did not get a response ({category}): {error or 'unknown error'}."
|
|
33
|
+
return (
|
|
34
|
+
f"HTTP {measurement.get('status')} {measurement.get('statusText', '')} "
|
|
35
|
+
f"({category}) in {measurement.get('latencyMs')} ms. "
|
|
36
|
+
f"Body preview: {str(measurement.get('bodySnippet', ''))[:400]}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_agent_summary(cfg):
|
|
41
|
+
"""Returns (summary, error). Never raises."""
|
|
42
|
+
try:
|
|
43
|
+
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
44
|
+
except Exception as e: # noqa: BLE001
|
|
45
|
+
return "", f"smolagents not installed ({e})"
|
|
46
|
+
|
|
47
|
+
request = cfg.get("request") or {}
|
|
48
|
+
method = (request.get("method") or "GET").upper()
|
|
49
|
+
url = request.get("url") or ""
|
|
50
|
+
measurement = cfg.get("measurement")
|
|
51
|
+
category = cfg.get("category") or ""
|
|
52
|
+
error = cfg.get("error")
|
|
53
|
+
measured_text = describe(measurement, category, error)
|
|
54
|
+
|
|
55
|
+
@tool
|
|
56
|
+
def send_request() -> str:
|
|
57
|
+
"""Send the configured HTTP request to the target endpoint and return its
|
|
58
|
+
status code and a short body preview."""
|
|
59
|
+
return measured_text
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
model = OpenAIServerModel(
|
|
63
|
+
model_id=cfg.get("agentModelId") or "",
|
|
64
|
+
api_base=cfg.get("agentBaseURL") or "",
|
|
65
|
+
api_key=cfg.get("agentApiKey") or "local",
|
|
66
|
+
)
|
|
67
|
+
agent = CodeAgent(tools=[send_request], model=model, max_steps=4)
|
|
68
|
+
task = (
|
|
69
|
+
f"Use send_request to call {method} {url}. Then report the HTTP status "
|
|
70
|
+
"code, classify it (2xx success, 3xx redirect, 4xx client error, 5xx "
|
|
71
|
+
"server error, or network failure), and give a one-sentence health "
|
|
72
|
+
"assessment of the endpoint."
|
|
73
|
+
)
|
|
74
|
+
# smolagents/rich logs to stdout; keep stdout clean for our JSON result.
|
|
75
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
76
|
+
summary = str(agent.run(task)).strip()
|
|
77
|
+
return summary, None
|
|
78
|
+
except Exception as e: # noqa: BLE001
|
|
79
|
+
return "", f"agent run failed ({e})"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main():
|
|
83
|
+
try:
|
|
84
|
+
cfg = json.load(sys.stdin)
|
|
85
|
+
except Exception as e: # noqa: BLE001
|
|
86
|
+
json.dump({"agentSummary": "", "error": f"invalid input: {e}"}, sys.stdout)
|
|
87
|
+
return
|
|
88
|
+
summary, agent_error = run_agent_summary(cfg)
|
|
89
|
+
json.dump({"agentSummary": summary, "error": agent_error}, sys.stdout)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Voice transcription for the GoNext local worker (Audio support, task #21).
|
|
3
|
+
|
|
4
|
+
Reads a JSON job on stdin: {"audioPath": str, "model": str, "language": str?}.
|
|
5
|
+
Transcribes with mlx-whisper and prints {"text": "..."} on stdout.
|
|
6
|
+
|
|
7
|
+
Design notes:
|
|
8
|
+
- The browser sends a 16 kHz mono 16-bit PCM WAV, so we decode it with the
|
|
9
|
+
stdlib `wave` module + numpy and hand mlx-whisper a float32 array. Passing an
|
|
10
|
+
array (not a file path) skips mlx-whisper's ffmpeg-based loader, so NO ffmpeg
|
|
11
|
+
install is required on the worker.
|
|
12
|
+
- The model is resolved by HF repo id ("mlx-community/<model>") from the HF hub
|
|
13
|
+
cache (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). This is the
|
|
14
|
+
same location the wizard installs to and the readiness probe checks.
|
|
15
|
+
- On failure we exit non-zero and print a machine-parseable reason token as the
|
|
16
|
+
first stderr line: "lib-missing" | "model-missing" | "audio-error" | "error".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import wave
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TARGET_SR = 16000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fail(reason: str, detail: str = "") -> None:
|
|
28
|
+
sys.stderr.write(reason + "\n")
|
|
29
|
+
if detail:
|
|
30
|
+
sys.stderr.write(detail + "\n")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_wav_mono_16k(path: str):
|
|
35
|
+
"""Decode a PCM WAV to a float32 mono array at 16 kHz without ffmpeg."""
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
with wave.open(path, "rb") as w:
|
|
39
|
+
n_channels = w.getnchannels()
|
|
40
|
+
sampwidth = w.getsampwidth()
|
|
41
|
+
framerate = w.getframerate()
|
|
42
|
+
n_frames = w.getnframes()
|
|
43
|
+
raw = w.readframes(n_frames)
|
|
44
|
+
|
|
45
|
+
if sampwidth == 2:
|
|
46
|
+
audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
|
47
|
+
elif sampwidth == 4:
|
|
48
|
+
audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0
|
|
49
|
+
elif sampwidth == 1:
|
|
50
|
+
audio = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f"unsupported WAV sample width: {sampwidth} bytes")
|
|
53
|
+
|
|
54
|
+
if n_channels > 1:
|
|
55
|
+
audio = audio.reshape(-1, n_channels).mean(axis=1)
|
|
56
|
+
|
|
57
|
+
if framerate != TARGET_SR and audio.size > 0:
|
|
58
|
+
# Linear resample; the browser already sends 16 kHz so this is a safety net.
|
|
59
|
+
duration = audio.size / float(framerate)
|
|
60
|
+
target_len = int(round(duration * TARGET_SR))
|
|
61
|
+
if target_len > 0:
|
|
62
|
+
src_x = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
|
|
63
|
+
dst_x = np.linspace(0.0, 1.0, num=target_len, endpoint=False)
|
|
64
|
+
audio = np.interp(dst_x, src_x, audio).astype(np.float32)
|
|
65
|
+
|
|
66
|
+
return np.ascontiguousarray(audio, dtype=np.float32)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
try:
|
|
71
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
72
|
+
except Exception as e: # noqa: BLE001
|
|
73
|
+
fail("error", f"invalid job json: {e}")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
audio_path = str(payload.get("audioPath") or "").strip()
|
|
77
|
+
model = str(payload.get("model") or "whisper-large-v3-turbo").strip()
|
|
78
|
+
language = str(payload.get("language") or "").strip() or None
|
|
79
|
+
if not audio_path:
|
|
80
|
+
fail("error", "missing audioPath")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import mlx_whisper # noqa: F401
|
|
85
|
+
except Exception as e: # noqa: BLE001
|
|
86
|
+
fail("lib-missing", str(e))
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
audio = load_wav_mono_16k(audio_path)
|
|
91
|
+
except Exception as e: # noqa: BLE001
|
|
92
|
+
fail("audio-error", str(e))
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
repo = f"mlx-community/{model}"
|
|
96
|
+
try:
|
|
97
|
+
result = mlx_whisper.transcribe(
|
|
98
|
+
audio,
|
|
99
|
+
path_or_hf_repo=repo,
|
|
100
|
+
task="transcribe",
|
|
101
|
+
language=language,
|
|
102
|
+
)
|
|
103
|
+
except Exception as e: # noqa: BLE001
|
|
104
|
+
msg = str(e).lower()
|
|
105
|
+
if any(
|
|
106
|
+
token in msg
|
|
107
|
+
for token in (
|
|
108
|
+
"not found",
|
|
109
|
+
"repository",
|
|
110
|
+
"couldn't find",
|
|
111
|
+
"could not find",
|
|
112
|
+
"no such file",
|
|
113
|
+
"connection",
|
|
114
|
+
"offline",
|
|
115
|
+
"resolve",
|
|
116
|
+
"404",
|
|
117
|
+
)
|
|
118
|
+
):
|
|
119
|
+
fail("model-missing", str(e))
|
|
120
|
+
else:
|
|
121
|
+
fail("error", str(e))
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
text = str(result.get("text", "")).strip() if isinstance(result, dict) else ""
|
|
125
|
+
sys.stdout.write(json.dumps({"text": text}))
|
|
126
|
+
sys.stdout.flush()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|