@tiens.nguyen/gonext-local-worker 1.0.158 → 1.0.162

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.
@@ -5,6 +5,7 @@
5
5
  * writes ~/.gonext/worker.env
6
6
  * - `gonext-local-worker ws-ping-test` — POST worker ws-ping (needs GONEXT_* env)
7
7
  * - `gonext-local-worker simulate-chat [text]` — claim next chat job, push fake reply like the real worker (needs GONEXT_* env)
8
+ * - `gonext-local-worker embed --model <mlx-embed-model> [--port 8085]` — run the MLX embeddings server (RAG); use a separate terminal
8
9
  * - `gonext-local-worker` — starts polling loop (claims jobs and runs models)
9
10
  */
10
11
  import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
@@ -221,6 +222,57 @@ if (args[0] === "ws-ping-test") {
221
222
  process.exit(0);
222
223
  }
223
224
 
225
+ if (args[0] === "embed") {
226
+ // Start the MLX embeddings server (OpenAI-compatible /v1/embeddings) for the RAG
227
+ // feature, in the FOREGROUND of this terminal. Run it in a SEPARATE terminal from the
228
+ // worker (the worker keeps polling; this holds one terminal for the embed model).
229
+ // gonext-local-worker embed --model ~/mlx-models/<embed-model> [--port 8085] [--host 127.0.0.1]
230
+ // Then set web Settings → Agent → RAG → "Embedding server URL" to http://<host>:<port>.
231
+ const flag = (name, fallback) => {
232
+ const i = args.indexOf(name);
233
+ return i >= 0 && typeof args[i + 1] === "string" ? args[i + 1] : fallback;
234
+ };
235
+ let model = flag("--model", process.env.GONEXT_EMBED_MODEL || "");
236
+ const port = flag("--port", process.env.GONEXT_EMBED_PORT || "8085");
237
+ const host = flag("--host", process.env.GONEXT_EMBED_HOST || "127.0.0.1");
238
+ if (!model) {
239
+ console.error(
240
+ "[gonext-worker] embed: --model is required.\n" +
241
+ " Usage: gonext-local-worker embed --model ~/mlx-models/Qwen3-Embedding-8B-4bit-DWQ [--port 8085] [--host 127.0.0.1]\n" +
242
+ " (or set GONEXT_EMBED_MODEL). Then set the RAG embedding server URL to http://<host>:<port> in web Settings."
243
+ );
244
+ process.exit(1);
245
+ }
246
+ // Expand a leading ~ to the home dir (spawn does not do shell expansion).
247
+ if (model.startsWith("~/") || model === "~") {
248
+ model = join(homedir(), model.slice(model.length > 1 ? 2 : 1));
249
+ }
250
+ const python =
251
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
252
+ "python3";
253
+ const script = join(WORKER_DIR, "gonext_mlx_embed.py");
254
+ console.log(
255
+ `[gonext-worker] starting MLX embeddings server → ${python} ${script} --model ${model} --port ${port} --host ${host}`
256
+ );
257
+ const child = spawn(
258
+ python,
259
+ [script, "--model", model, "--port", String(port), "--host", String(host)],
260
+ { stdio: "inherit" }
261
+ );
262
+ child.on("error", (err) => {
263
+ console.error(`[gonext-worker] embed: failed to start python (${python}): ${err.message}`);
264
+ process.exit(1);
265
+ });
266
+ // Forward Ctrl-C to the child so the model unloads cleanly.
267
+ const stop = () => child.kill("SIGINT");
268
+ process.on("SIGINT", stop);
269
+ process.on("SIGTERM", stop);
270
+ // Block the module from continuing to the polling loop; exit when the child exits.
271
+ await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 0))).then(
272
+ (code) => process.exit(code)
273
+ );
274
+ }
275
+
224
276
  if (args[0] === "simulate-chat") {
225
277
  assertWorkerRuntimeConfig("simulate-chat");
226
278
  const reply =
@@ -1310,12 +1310,25 @@ def _embed(base: str, model: str, texts: list) -> list:
1310
1310
 
1311
1311
  # 3) Legacy Ollama /api/embeddings (per text).
1312
1312
  out = []
1313
- for t in texts:
1314
- data = _post(f"{root}/api/embeddings", {"model": model, "prompt": t}, 120)
1315
- out.append(data.get("embedding") or [])
1316
- if any(out):
1313
+ try:
1314
+ for t in texts:
1315
+ data = _post(f"{root}/api/embeddings", {"model": model, "prompt": t}, 120)
1316
+ out.append(data.get("embedding") or [])
1317
+ except (urllib.error.HTTPError, urllib.error.URLError) as e:
1318
+ code = getattr(e, "code", None)
1319
+ raise RuntimeError(
1320
+ f"No embeddings endpoint at {root} (tried /v1/embeddings, /api/embed, /api/embeddings; "
1321
+ f"last HTTP {code if code else getattr(e, 'reason', e)}). Make sure an EMBEDDINGS-capable "
1322
+ f"server is running there and the model '{model}' is available "
1323
+ f"(e.g. `ollama pull {model}`). NOTE: mlx_lm.server does NOT serve embeddings — point the "
1324
+ f"RAG embedding server URL at Ollama (or another embeddings server) instead."
1325
+ )
1326
+ if any(any(v) for v in out):
1317
1327
  return out
1318
- raise RuntimeError(f"No embedding endpoint responded at {root} ({last_err or 'unknown'}).")
1328
+ raise RuntimeError(
1329
+ f"Embeddings server at {root} returned empty vectors for model '{model}' "
1330
+ f"(is '{model}' an embedding model that is installed there?)."
1331
+ )
1319
1332
 
1320
1333
 
1321
1334
  def _cosine(a: list, b: list) -> float:
@@ -1349,6 +1362,24 @@ def _rag_parse_location(loc: str):
1349
1362
  return bucket, prefix.strip("/")
1350
1363
 
1351
1364
 
1365
+ def _rag_read_allowed(path: str) -> str:
1366
+ """Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir, or
1367
+ ~/.gonext) — so the agent's file tools can't read arbitrary files like ~/.ssh."""
1368
+ import os
1369
+ import tempfile
1370
+ rp = os.path.realpath(path)
1371
+ roots = [
1372
+ os.path.realpath(os.getcwd()),
1373
+ os.path.realpath(tempfile.gettempdir()),
1374
+ os.path.realpath(os.path.join(os.path.expanduser("~"), ".gonext")),
1375
+ ]
1376
+ if any(rp == r or rp.startswith(r + os.sep) for r in roots):
1377
+ return rp
1378
+ raise ValueError(
1379
+ "Path is outside the allowed working directories (download/unzip outputs only)."
1380
+ )
1381
+
1382
+
1352
1383
  def _rag_s3_client(region: str, akid: str, secret: str):
1353
1384
  import boto3 # noqa: PLC0415
1354
1385
  return boto3.client(
@@ -1724,22 +1755,29 @@ def run_agent_chat(cfg):
1724
1755
  _tool_num += 1
1725
1756
  _email_num = _tool_num
1726
1757
  if _RAG_AVAILABLE:
1727
- _tool_num += 5 # download_file, unzip_file, rag_index, rag_add, rag_search
1758
+ _tool_num += 7 # download_file, unzip_file, list_dir, read_text_file, rag_index/add/search
1728
1759
  _tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
1729
1760
  _rag_tool_block = (
1730
- " KNOWLEDGE BASE (RAG) — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
1761
+ " FILES & KNOWLEDGE BASE — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
1731
1762
  " - download_file(url) — download a file (e.g. a .zip) to this machine.\n"
1732
1763
  " - unzip_file(zip_path) — extract a downloaded .zip locally.\n"
1733
- " - rag_index(path, source_url) — index the unzipped text/code files into the knowledge "
1734
- "base (embeds to S3). source_url MUST be the ORIGINAL url the user gave.\n"
1764
+ " - list_dir(path) — list the files inside an unzipped folder.\n"
1765
+ " - read_text_file(path) read ONE file's text (e.g. a README) directly. Best for a QUICK "
1766
+ "project summary — you do NOT need to index for that.\n"
1767
+ " - rag_index(path, source_url) — index MANY text/code files into a searchable knowledge "
1768
+ "base (embeds to S3). source_url MUST be the ORIGINAL url the user gave. Use for deep Q&A "
1769
+ "across the whole project.\n"
1735
1770
  " - rag_add(source_url, text) — add extra info the user provides to that knowledge base.\n"
1736
1771
  " - rag_search(source_url, query) — retrieve the most relevant chunks to answer a question.\n"
1772
+ " NOTE: you may ONLY read files with these tools — plain `import os`/`open()` is blocked.\n"
1737
1773
  ) if _RAG_AVAILABLE else ""
1738
1774
  _rag_choose_line = (
1739
- "- user gives a URL to a ZIP of files and asks to summarize/analyze/RAG them -> "
1740
- "download_file(url) THEN unzip_file(zip) THEN rag_index(dir, source_url=url) THEN "
1741
- "rag_search(source_url=url, query=<the user's question>), THEN answer from the results. "
1742
- "For FOLLOW-UP questions about the SAME url, just rag_search (do NOT re-download). "
1775
+ "- user gives a URL to a ZIP and asks to SUMMARIZE it -> download_file(url) THEN unzip_file(zip) "
1776
+ "THEN list_dir(dir) to find the README, THEN read_text_file(that README) THEN answer from it. "
1777
+ "This is the FASTEST path for a summary do NOT index unless deep Q&A across many files is needed.\n"
1778
+ "- user wants deep Q&A across the WHOLE project -> download_file THEN unzip_file THEN "
1779
+ "rag_index(dir, source_url=url) THEN rag_search(source_url=url, query=<question>) THEN answer. "
1780
+ "For FOLLOW-UPs on the SAME url, just rag_search (do NOT re-download). "
1743
1781
  "If the user adds information, rag_add(source_url=url, text=...).\n"
1744
1782
  ) if _RAG_AVAILABLE else ""
1745
1783
  _pdf_read_tool_line = (
@@ -2287,6 +2325,26 @@ def run_agent_chat(cfg):
2287
2325
  in_tok = out_tok = reasoning_len = 0
2288
2326
  first_token_at = None
2289
2327
 
2328
+ # Runaway guard: a degenerate model (seen live: gemma4:26b spewing
2329
+ # "<channel|><thought>" forever) will stream garbage until the client times
2330
+ # out — a multi-minute hang. Abort early if the output either (a) balloons
2331
+ # past a hard char cap, or (b) collapses into a repeating short line. On abort
2332
+ # we return "" so the empty-turn retry re-prompts with the CODE-NOW directive.
2333
+ _MAX_STREAM_CHARS = 16000 # a single agent step never legitimately needs this
2334
+ _stream_buf = [] # every streamed piece (content + reasoning)
2335
+ _stream_chars = 0
2336
+ _runaway = False
2337
+
2338
+ def _looks_runaway() -> bool:
2339
+ tail = "".join(_stream_buf)[-1600:]
2340
+ lines = [ln.strip() for ln in tail.splitlines() if ln.strip()]
2341
+ # 12+ of the last lines are the same short string → degenerate repetition.
2342
+ if len(lines) >= 12:
2343
+ last = lines[-12:]
2344
+ if len(set(last)) <= 2 and all(len(ln) <= 80 for ln in last):
2345
+ return True
2346
+ return False
2347
+
2290
2348
  def _mark_first():
2291
2349
  # First streamed byte (content OR reasoning). The gap t0→here is pure
2292
2350
  # prompt-eval time (server sent nothing before this); everything after is
@@ -2324,13 +2382,33 @@ def run_agent_chat(cfg):
2324
2382
  if rpiece:
2325
2383
  _mark_first()
2326
2384
  reasoning_len += len(rpiece)
2385
+ _stream_buf.append(rpiece)
2386
+ _stream_chars += len(rpiece)
2327
2387
  _emit({"type": "stream", "text": rpiece})
2328
2388
  piece = getattr(delta, "content", None)
2329
2389
  if piece:
2330
2390
  _mark_first()
2331
2391
  parts.append(piece)
2392
+ _stream_buf.append(piece)
2393
+ _stream_chars += len(piece)
2332
2394
  _emit({"type": "stream", "text": piece})
2395
+ # Check the runaway guard periodically (cheap) once enough has streamed.
2396
+ if _stream_chars > 500 and (_stream_chars > _MAX_STREAM_CHARS or _looks_runaway()):
2397
+ _runaway = True
2398
+ reason = "char cap" if _stream_chars > _MAX_STREAM_CHARS else "repeating output"
2399
+ _log(f"streamed generate: ABORTING ({reason}) after {_stream_chars} chars "
2400
+ "— model degenerated; closing stream.")
2401
+ try:
2402
+ stream.close()
2403
+ except Exception: # noqa: BLE001
2404
+ pass
2405
+ break
2333
2406
  content = "".join(parts)
2407
+ if _runaway:
2408
+ # Discard the garbage; "" makes _streamed_generate run the CODE-NOW retry.
2409
+ _emit({"type": "step", "text": "Model output ran away — restarting this step…"})
2410
+ _log(f"streamed generate: discarded {len(content)} runaway chars → empty content")
2411
+ return "", role, in_tok, out_tok, reasoning_len
2334
2412
  _log(f"streamed generate assembled {len(content)} chars "
2335
2413
  f"(in={in_tok} out={out_tok} tokens, reasoning={reasoning_len} chars)")
2336
2414
  return content, role, in_tok, out_tok, reasoning_len
@@ -2665,6 +2743,55 @@ def run_agent_chat(cfg):
2665
2743
  except Exception as e: # noqa: BLE001
2666
2744
  return f"Error: unzip failed: {type(e).__name__}: {e}"
2667
2745
 
2746
+ @tool
2747
+ def list_dir(path: str) -> str:
2748
+ """List files and subfolders under a local directory (from unzip_file). Use to see what files are in the project before reading or indexing them.
2749
+
2750
+ Args:
2751
+ path: local directory path to list.
2752
+ """
2753
+ try:
2754
+ import os
2755
+ root = _rag_read_allowed((path or ".").strip())
2756
+ if os.path.isfile(root):
2757
+ return f"{root} is a file ({os.path.getsize(root)} bytes)."
2758
+ entries = []
2759
+ for dirpath, dirnames, filenames in os.walk(root):
2760
+ dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
2761
+ for fn in filenames:
2762
+ rel = os.path.relpath(os.path.join(dirpath, fn), root)
2763
+ entries.append(rel)
2764
+ if len(entries) >= 300:
2765
+ break
2766
+ if len(entries) >= 300:
2767
+ break
2768
+ entries.sort()
2769
+ more = " …(truncated)" if len(entries) >= 300 else ""
2770
+ return f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
2771
+ except Exception as e: # noqa: BLE001
2772
+ return f"Error: list_dir failed: {type(e).__name__}: {e}"
2773
+
2774
+ @tool
2775
+ def read_text_file(path: str, max_chars: int = 20000) -> str:
2776
+ """Read the text contents of a single local file (e.g. a README) from a downloaded/unzipped project. Use this for a QUICK summary when you don't need full indexing.
2777
+
2778
+ Args:
2779
+ path: local path to the text file (e.g. project_files/tools-hook/README.md).
2780
+ max_chars: maximum characters to return (default 20000).
2781
+ """
2782
+ try:
2783
+ import os
2784
+ rp = _rag_read_allowed((path or "").strip())
2785
+ if not os.path.isfile(rp):
2786
+ return f"Error: not a file: {path}"
2787
+ if os.path.getsize(rp) > 5 * 1024 * 1024:
2788
+ return "Error: file too large to read (> 5 MB)."
2789
+ with open(rp, "r", encoding="utf-8", errors="replace") as fh:
2790
+ data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
2791
+ return data or "(empty file)"
2792
+ except Exception as e: # noqa: BLE001
2793
+ return f"Error: read_text_file failed: {type(e).__name__}: {e}"
2794
+
2668
2795
  @tool
2669
2796
  def rag_index(path: str, source_url: str) -> str:
2670
2797
  """Index a local file or directory of TEXT/code files into the knowledge base for source_url (stores embeddings on S3). Use after unzip_file so the files become searchable. source_url MUST be the original URL the user provided.
@@ -2785,7 +2912,8 @@ def run_agent_chat(cfg):
2785
2912
  if _EMAIL_AVAILABLE:
2786
2913
  agent_tools.append(send_email)
2787
2914
  if _RAG_AVAILABLE:
2788
- agent_tools += [download_file, unzip_file, rag_index, rag_add, rag_search]
2915
+ agent_tools += [download_file, unzip_file, list_dir, read_text_file,
2916
+ rag_index, rag_add, rag_search]
2789
2917
  agent_kwargs = dict(
2790
2918
  tools=agent_tools,
2791
2919
  model=model,
@@ -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())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.158",
3
+ "version": "1.0.162",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "gonext_probe_agent.py",
26
26
  "gonext_agent_chat.py",
27
27
  "gonext_transcribe.py",
28
+ "gonext_mlx_embed.py",
28
29
  "README.md",
29
30
  "launchd/"
30
31
  ],