@tiens.nguyen/gonext-local-worker 1.0.267 → 1.0.268

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.
@@ -1948,6 +1948,10 @@ async function runAgentChatJob(job) {
1948
1948
  ragEmbedModel: payload?.ragEmbedModel ?? "",
1949
1949
  ragEmbedUrl: payload?.ragEmbedUrl ?? "",
1950
1950
  ragTopK: payload?.ragTopK ?? 6,
1951
+ // RAG storage backend for this folder (task #97): "local" = on disk under ~/.gonext
1952
+ // (per-folder, no S3 creds), else "cloud" (the user's S3). Terminal sends the folder's
1953
+ // choice; web never sets it → "cloud". python re-validates and defaults to cloud.
1954
+ ragMode: payload?.ragMode === "local" ? "local" : "cloud",
1951
1955
  // Workspaces the agent may read/edit/test code in (see the terminal-vs-web split
1952
1956
  // computed above): the real registry for the terminal, a lone temp folder for web.
1953
1957
  workspaces: jobWorkspaces,
package/gonext-repl.mjs CHANGED
@@ -189,6 +189,7 @@ if (!workerKey || !apiBase) {
189
189
  const COMMANDS = [
190
190
  { name: "/model", desc: "switch the coding model for this session" },
191
191
  { name: "/test-auto", desc: "toggle auto-test (verify & fix until it passes) for this folder" },
192
+ { name: "/rag-local", desc: "toggle storing this folder's RAG knowledge base locally (vs cloud/S3)" },
192
193
  { name: "/server", desc: "pick a deployment server (host/user) for this session" },
193
194
  { name: "/revert", usage: "/revert [runId]", desc: "undo the agent's file edits (latest run)" },
194
195
  { name: "/reset", aliases: ["/new"], desc: "forget this folder's saved conversation and start fresh" },
@@ -611,6 +612,37 @@ async function loadSessionMaxCodeTokens(cwd) {
611
612
  }
612
613
  }
613
614
 
615
+ // RAG storage backend remembered per folder (task #97): "local" | "cloud" | null (never
616
+ // chosen → we ask once at startup). Local keeps the index on disk under ~/.gonext; cloud
617
+ // uses the user's S3 bucket (the original behavior).
618
+ async function loadSessionRagMode(cwd) {
619
+ try {
620
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
621
+ return raw?.ragMode === "local" || raw?.ragMode === "cloud" ? raw.ragMode : null;
622
+ } catch {
623
+ return null;
624
+ }
625
+ }
626
+
627
+ // Persist just the RAG-mode choice without touching history (used at startup, before the
628
+ // full session is loaded). Read-merge-write so nothing else in the file is lost.
629
+ async function saveSessionRagMode(cwd, mode) {
630
+ try {
631
+ let raw = {};
632
+ try {
633
+ raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8")) || {};
634
+ } catch {
635
+ raw = {};
636
+ }
637
+ raw.ragMode = mode;
638
+ raw.updatedAt = new Date().toISOString();
639
+ await mkdir(SESSIONS_DIR, { recursive: true });
640
+ await writeFile(sessionFilePath(cwd), JSON.stringify(raw, null, 2) + "\n");
641
+ } catch (err) {
642
+ console.error(dim(`(rag-mode save failed: ${err.message})`));
643
+ }
644
+ }
645
+
614
646
  async function saveSession(cwd, history) {
615
647
  try {
616
648
  await mkdir(SESSIONS_DIR, { recursive: true });
@@ -624,6 +656,7 @@ async function saveSession(cwd, history) {
624
656
  testAuto: sessionTestAuto,
625
657
  deployServer: selectedServer, // remember the /server pick per folder (task #69)
626
658
  maxCodeTokens: sessionMaxCodeTokens, // peak per-request tokens (task #84)
659
+ ragMode: sessionRagMode, // local vs cloud RAG for this folder (task #97)
627
660
  history: trimmed,
628
661
  },
629
662
  null,
@@ -975,6 +1008,10 @@ let sessionCodingModel = "";
975
1008
  // and fixes → re-tests until it passes before finishing. Default off; remembered per
976
1009
  // folder in the session file; sent to /agent-ask as autoTest so python drives the loop.
977
1010
  let sessionTestAuto = false;
1011
+ // RAG storage backend for THIS folder (task #97): "local" (index on disk under ~/.gonext,
1012
+ // per-folder, no S3 needed) | "cloud" (the user's S3 bucket) | null (never chosen → ask once
1013
+ // at startup when RAG is enabled). Remembered per folder; sent to /agent-ask as ragMode.
1014
+ let sessionRagMode = null;
978
1015
  // Step-budget opt-out (task #80): set once the user picks "yes, don't ask again" at a
979
1016
  // max-step prompt. Session-scoped (not persisted per folder); sent to /agent-ask each turn
980
1017
  // so the agent auto-extends past the step budget without prompting again.
@@ -1146,6 +1183,9 @@ async function runAgentTurn(history) {
1146
1183
  ...(alwaysExtendOnMaxStep ? { alwaysExtendOnMaxStep: true } : {}),
1147
1184
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
1148
1185
  ...(sessionTestAuto ? { autoTest: true } : {}),
1186
+ // RAG storage backend for this folder (task #97): local disk vs cloud/S3. Only sent
1187
+ // once chosen; absent → the API/python default (cloud), preserving old behavior.
1188
+ ...(sessionRagMode ? { ragMode: sessionRagMode } : {}),
1149
1189
  // Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
1150
1190
  ...(selectedServer ? { deployServer: selectedServer } : {}),
1151
1191
  ...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
@@ -1288,15 +1328,25 @@ async function runAgentTurn(history) {
1288
1328
  // "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
1289
1329
  // the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
1290
1330
  const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
1291
- const primary = runningCmd
1292
- ? `Running ${runningCmd}`
1293
- : clickable
1294
- ? fullLiveThought
1331
+ let label, rest;
1332
+ if (clickable) {
1333
+ // Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
1334
+ // space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
1335
+ // Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
1336
+ // split word).
1337
+ const sp = fullLiveThought.lastIndexOf(" ", max - 1);
1338
+ const cut = sp >= Math.floor(max * 0.6) ? sp : max - 1;
1339
+ label = `${fullLiveThought.slice(0, cut).replace(/\s+$/, "")}… (${secs}s)`;
1340
+ rest = fullLiveThought.slice(cut).trim();
1341
+ } else {
1342
+ const primary = runningCmd
1343
+ ? `Running ${runningCmd}`
1295
1344
  : liveThought || (almostDone ? "Almost done" : "Thinking");
1296
- const label = `${fit(primary)}… (${secs}s)`;
1297
- // When clicked, unfold the REMAINDER (what fit() dropped, i.e. from char max-1 on) as up to
1298
- // 4 wrapped GREY lines BETWEEN the thought and the playful-word line.
1299
- const rest = clickable ? fullLiveThought.slice(max - 1).trim() : "";
1345
+ label = `${fit(primary)}… (${secs}s)`;
1346
+ rest = "";
1347
+ }
1348
+ // When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
1349
+ // the playful-word line.
1300
1350
  const expLines = thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
1301
1351
  const drawn = 2 + expLines.length;
1302
1352
  const rows = process.stdout.rows || 24;
@@ -1759,15 +1809,27 @@ async function main() {
1759
1809
  sessionTestAuto = await loadSessionTestAuto(resolve(process.cwd()));
1760
1810
  selectedServer = await loadSessionServer(resolve(process.cwd()));
1761
1811
  sessionMaxCodeTokens = await loadSessionMaxCodeTokens(resolve(process.cwd()));
1812
+ sessionRagMode = await loadSessionRagMode(resolve(process.cwd())); // task #97: local/cloud/null
1762
1813
  // Show which models will answer, straight from user settings (also validates auth).
1763
1814
  try {
1764
1815
  const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
1765
1816
  const p = probe?.payload ?? {};
1817
+ // RAG storage choice (task #97): ask ONCE per folder, and only when RAG is actually
1818
+ // enabled in Settings. Default Yes = LOCAL (kept on this machine, no S3 needed).
1819
+ if (p.ragEnabled && sessionRagMode === null) {
1820
+ const local = await askYesNo(
1821
+ "Store this folder's RAG knowledge base LOCALLY on this machine?\n" +
1822
+ "(Yes = local, kept in ~/.gonext per folder — no S3 needed; No = cloud, your S3 bucket)",
1823
+ true /* default Yes = local */
1824
+ );
1825
+ sessionRagMode = local ? "local" : "cloud";
1826
+ await saveSessionRagMode(resolve(process.cwd()), sessionRagMode);
1827
+ }
1766
1828
  console.log(
1767
1829
  dim(
1768
1830
  `agent model: ${p.agentModelId || probe?.modelKey || "?"}` +
1769
1831
  (p.codingModelId ? ` · coder: ${p.codingModelId}` : "") +
1770
- (p.ragEnabled ? " · RAG on" : "") +
1832
+ (p.ragEnabled ? ` · RAG ${sessionRagMode === "cloud" ? "cloud" : "local"}` : "") +
1771
1833
  ` · auto-test: ${sessionTestAuto ? "on" : "off"}` +
1772
1834
  (selectedServer ? ` · deploy: ${selectedServer.name} (${selectedServer.user}@${selectedServer.host})` : "")
1773
1835
  ) + (sessionTestAuto ? "" : dim(" (/test-auto to enable)"))
@@ -1875,6 +1937,20 @@ async function main() {
1875
1937
  );
1876
1938
  continue;
1877
1939
  }
1940
+ if (line === "/rag-local") {
1941
+ // Toggle this folder's RAG store between local disk and the cloud (S3). task #97.
1942
+ sessionRagMode = sessionRagMode === "local" ? "cloud" : "local";
1943
+ await saveSession(cwd, history); // persist the choice for this folder
1944
+ console.log(
1945
+ (sessionRagMode === "local" ? green("RAG: LOCAL") : dim("RAG: cloud")) +
1946
+ dim(
1947
+ sessionRagMode === "local"
1948
+ ? " — this folder's knowledge base is stored on this machine (~/.gonext), no S3 needed.\n"
1949
+ : " — this folder's knowledge base is stored in your S3 bucket.\n"
1950
+ )
1951
+ );
1952
+ continue;
1953
+ }
1878
1954
  if (line === "/reset" || line === "/new") {
1879
1955
  history.length = 0;
1880
1956
  await clearSession(cwd);
@@ -1757,6 +1757,21 @@ def _rag_base_dir(source_key: str) -> str:
1757
1757
  return _rag_workdir(source_key)
1758
1758
 
1759
1759
 
1760
+ def _rag_local_index_dir(source_url: str) -> str:
1761
+ """Local RAG index dir for a source, scoped PER-FOLDER (task #97): the vector shards
1762
+ live at ~/.gonext/rag/<sha256(activeFolder)>/<sha256(sourceUrl)>/. Keying on the active
1763
+ terminal folder (the SAME sha256 scheme the REPL uses for its session files) isolates
1764
+ each project's knowledge base, so the same URL indexed from two folders stays separate."""
1765
+ import hashlib
1766
+ import os
1767
+ folder = _WS_ACTIVE or os.getcwd()
1768
+ fhash = hashlib.sha256(os.path.realpath(folder).encode("utf-8")).hexdigest()[:32]
1769
+ base = os.path.join(os.path.expanduser("~"), ".gonext", "rag", fhash,
1770
+ _rag_source_key(source_url))
1771
+ os.makedirs(base, exist_ok=True)
1772
+ return base
1773
+
1774
+
1760
1775
  def _rag_download(url: str, dest_path: str = "") -> tuple:
1761
1776
  """Download a public URL with SSRF + size guards — IDEMPOTENT per URL. Every URL
1762
1777
  maps to a deterministic work dir (~/.gonext/rag-work/<urlHash>/); relative
@@ -2827,9 +2842,24 @@ def run_agent_chat(cfg):
2827
2842
  _boto3_ok = True
2828
2843
  except Exception: # noqa: BLE001
2829
2844
  _boto3_ok = False
2830
- _RAG_AVAILABLE = bool(rag_enabled and rag_akid and rag_secret and rag_embed_base and _boto3_ok)
2831
- if rag_enabled and not _boto3_ok:
2832
- _log("RAG requested but boto3 is not installed in the worker python RAG tools disabled")
2845
+ # RAG storage backend (task #97): "local" keeps the index on disk under ~/.gonext,
2846
+ # PER-FOLDER, and needs NO S3 creds / boto3 — only the embedding server. "cloud" (default,
2847
+ # and what the web app always uses) stores it on the user's own S3 bucket via boto3. The
2848
+ # terminal REPL picks this per-folder and sends it as cfg.ragMode.
2849
+ rag_mode = (cfg.get("ragMode") or "cloud").strip().lower()
2850
+ if rag_mode not in ("local", "cloud"):
2851
+ rag_mode = "cloud"
2852
+ _RAG_LOCAL = rag_mode == "local"
2853
+ _RAG_AVAILABLE = bool(
2854
+ rag_enabled and rag_embed_base and (
2855
+ _RAG_LOCAL or (rag_akid and rag_secret and _boto3_ok)
2856
+ )
2857
+ )
2858
+ if rag_enabled and not _RAG_LOCAL and not _boto3_ok:
2859
+ _log("RAG (cloud) requested but boto3 is not installed in the worker python — RAG "
2860
+ "tools disabled (switch to local RAG with /rag-local to avoid needing S3/boto3)")
2861
+ if _RAG_AVAILABLE:
2862
+ _log(f"RAG mode: {'LOCAL (index under ~/.gonext/rag, per-folder)' if _RAG_LOCAL else 'cloud (S3)'}")
2833
2863
 
2834
2864
  # ---- Workspaces (local folders the agent may read/edit/test code in) ----
2835
2865
  # Registered on the Mac via `gonext-local-worker workspace add <path>`; the worker
@@ -2937,6 +2967,59 @@ def run_agent_chat(cfg):
2937
2967
  break
2938
2968
  return out
2939
2969
 
2970
+ # --- Storage backend abstraction (task #97): local disk vs the user's S3, chosen by
2971
+ # ragMode. The three rag_* tools go through these so their logic stays identical. ---
2972
+ def _rag_write_shard(source_url: str, records: list, tag: str) -> None:
2973
+ """Write one JSONL shard of chunk records. tag is 'chunks' (index) or 'chunks-add'."""
2974
+ import os
2975
+ import time as _t
2976
+ body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records)
2977
+ name = f"{tag}-{int(_t.time())}.jsonl"
2978
+ if _RAG_LOCAL:
2979
+ path = os.path.join(_rag_local_index_dir(source_url), name)
2980
+ with open(path, "w", encoding="utf-8") as fh:
2981
+ fh.write(body)
2982
+ return
2983
+ client, bucket, prefix = _rag_s3_and_loc()
2984
+ base = _rag_prefix_for(source_url, prefix)
2985
+ client.put_object(Bucket=bucket, Key=f"{base}/{name}",
2986
+ Body=body.encode("utf-8"), ContentType="application/x-ndjson")
2987
+
2988
+ def _rag_write_manifest(source_url: str, manifest: dict) -> None:
2989
+ import os
2990
+ data = json.dumps(manifest).encode("utf-8")
2991
+ if _RAG_LOCAL:
2992
+ with open(os.path.join(_rag_local_index_dir(source_url), "manifest.json"), "wb") as fh:
2993
+ fh.write(data)
2994
+ return
2995
+ client, bucket, prefix = _rag_s3_and_loc()
2996
+ base = _rag_prefix_for(source_url, prefix)
2997
+ client.put_object(Bucket=bucket, Key=f"{base}/manifest.json",
2998
+ Body=data, ContentType="application/json")
2999
+
3000
+ def _rag_load_all(source_url: str) -> list:
3001
+ """Load every chunk record for a source across all shards (local or S3)."""
3002
+ import glob as _glob
3003
+ import os
3004
+ if _RAG_LOCAL:
3005
+ out = []
3006
+ for fp in sorted(_glob.glob(os.path.join(_rag_local_index_dir(source_url), "chunks*.jsonl"))):
3007
+ try:
3008
+ with open(fp, "r", encoding="utf-8", errors="replace") as fh:
3009
+ for line in fh:
3010
+ line = line.strip()
3011
+ if line:
3012
+ try:
3013
+ out.append(json.loads(line))
3014
+ except json.JSONDecodeError:
3015
+ pass
3016
+ except OSError:
3017
+ continue
3018
+ return out
3019
+ client, bucket, prefix = _rag_s3_and_loc()
3020
+ base = _rag_prefix_for(source_url, prefix)
3021
+ return _rag_load_chunks(client, bucket, base)
3022
+
2940
3023
  def _prior_email_preview() -> bool:
2941
3024
  """True if an earlier assistant turn showed a send_email preview awaiting confirm."""
2942
3025
  for m in reversed(messages[:last_user_idx]):
@@ -3172,7 +3255,7 @@ def run_agent_chat(cfg):
3172
3255
  " - read_text_file(path) — read ONE file's text (e.g. a README) directly. Best for a QUICK "
3173
3256
  "project summary — you do NOT need to index for that.\n"
3174
3257
  " - rag_index(path, source_url) — index MANY text/code files into a searchable knowledge "
3175
- "base (embeds to S3). source_url MUST be the ORIGINAL url the user gave. Use for deep Q&A "
3258
+ "base (embeds the chunks). source_url MUST be the ORIGINAL url the user gave. Use for deep Q&A "
3176
3259
  "across the whole project.\n"
3177
3260
  " - rag_add(source_url, text) — add extra info the user provides to that knowledge base.\n"
3178
3261
  " - rag_search(source_url, query) — retrieve the most relevant chunks to answer a question.\n"
@@ -3312,7 +3395,7 @@ def run_agent_chat(cfg):
3312
3395
  "edit_lines(file, start, end, new_content) to change them, THEN "
3313
3396
  "run_command('…test…') to VERIFY if run permission exists — read failures and fix, "
3314
3397
  "iterate. Finish with final_answer summarizing WHAT you changed (files + lines). "
3315
- "AFTER editing, re-read with grep_repo/read_file_lines (an S3 knowledge base may be "
3398
+ "AFTER editing, re-read with grep_repo/read_file_lines (an indexed knowledge base may be "
3316
3399
  "stale — do not trust rag_search for freshly edited code).\n"
3317
3400
  ) if _WS_AVAILABLE else ""
3318
3401
  _pdf_read_choose_line = (
@@ -4991,14 +5074,16 @@ def run_agent_chat(cfg):
4991
5074
 
4992
5075
  @tool
4993
5076
  def rag_index(path: str, source_url: str) -> str:
4994
- """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.
5077
+ """Index a local file or directory of TEXT/code files into the knowledge base for source_url (stores embeddings in the knowledge base). Use after unzip_file so the files become searchable. source_url MUST be the original URL the user provided.
4995
5078
 
4996
5079
  Args:
4997
5080
  path: local file or directory to index (from unzip_file).
4998
5081
  source_url: the original URL the user gave — used as the knowledge-base key.
4999
5082
  """
5000
5083
  if not _RAG_AVAILABLE:
5001
- msg = "Error: RAG is not configured (enable it + add AWS credentials in Settings)."
5084
+ msg = ("Error: RAG is not configured (enable RAG in Settings"
5085
+ + ("" if _RAG_LOCAL else " and add AWS credentials, or switch to local RAG")
5086
+ + ").")
5002
5087
  _last_obs["text"] = msg
5003
5088
  return msg
5004
5089
  _emit({"type": "step", "text": f"RAG source → {source_url}"})
@@ -5025,21 +5110,16 @@ def run_agent_chat(cfg):
5025
5110
  for r, v in zip(batch, vecs):
5026
5111
  r["embedding"] = v
5027
5112
  _emit({"type": "step", "text": f"Embedded {min(i + 64, len(records))}/{len(records)} chunks…"})
5028
- client, bucket, prefix = _rag_s3_and_loc()
5029
- base = _rag_prefix_for(source_url, prefix)
5030
- shard = f"{base}/chunks-{int(_t.time())}.jsonl"
5031
- body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
5032
- client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
5113
+ _rag_write_shard(source_url, records, "chunks")
5033
5114
  manifest = {
5034
5115
  "sourceUrl": source_url, "embedModel": rag_embed_model,
5035
5116
  "dims": len(records[0].get("embedding") or []),
5036
5117
  "files": files, "chunks": len(records),
5037
5118
  "updatedAt": _t.strftime("%Y-%m-%dT%H:%M:%SZ", _t.gmtime()),
5038
5119
  }
5039
- client.put_object(Bucket=bucket, Key=f"{base}/manifest.json",
5040
- Body=json.dumps(manifest).encode("utf-8"),
5041
- ContentType="application/json")
5042
- out = (f"Indexed {files} files into {len(records)} chunks for {source_url}. "
5120
+ _rag_write_manifest(source_url, manifest)
5121
+ where = "locally" if _RAG_LOCAL else "in the cloud knowledge base"
5122
+ out = (f"Indexed {files} files into {len(records)} chunks for {source_url} ({where}). "
5043
5123
  f"Use rag_search(source_url='{source_url}', query=...) to retrieve, then answer the user.")
5044
5124
  _last_obs["text"] = out
5045
5125
  return out
@@ -5067,16 +5147,11 @@ def run_agent_chat(cfg):
5067
5147
  _emit({"type": "step", "text": f"RAG source → {source_url}"})
5068
5148
  _emit({"type": "step", "text": "Adding info to RAG…"})
5069
5149
  try:
5070
- import time as _t
5071
5150
  records = _rag_chunk_text(text, "user-note", ".txt")
5072
5151
  vecs = _embed(rag_embed_base, rag_embed_model, [r["text"] for r in records])
5073
5152
  for r, v in zip(records, vecs):
5074
5153
  r["embedding"] = v
5075
- client, bucket, prefix = _rag_s3_and_loc()
5076
- base = _rag_prefix_for(source_url, prefix)
5077
- shard = f"{base}/chunks-add-{int(_t.time())}.jsonl"
5078
- body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
5079
- client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
5154
+ _rag_write_shard(source_url, records, "chunks-add")
5080
5155
  out = f"Added {len(records)} chunk(s) to the knowledge base for {source_url}."
5081
5156
  _last_obs["text"] = out
5082
5157
  return out
@@ -5101,9 +5176,7 @@ def run_agent_chat(cfg):
5101
5176
  _emit({"type": "step", "text": f"RAG source → {source_url}"})
5102
5177
  _emit({"type": "step", "text": f"Searching RAG → {query[:60]}"})
5103
5178
  try:
5104
- client, bucket, prefix = _rag_s3_and_loc()
5105
- base = _rag_prefix_for(source_url, prefix)
5106
- chunks = _rag_load_chunks(client, bucket, base)
5179
+ chunks = _rag_load_all(source_url)
5107
5180
  if not chunks:
5108
5181
  msg = f"No knowledge base found for {source_url}. Run rag_index first."
5109
5182
  _last_obs["text"] = msg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.267",
3
+ "version": "1.0.268",
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",