@tiens.nguyen/gonext-local-worker 1.0.246 → 1.0.249
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/gonext-local-worker.mjs +97 -13
- package/gonext-repl.mjs +63 -4
- package/gonext_agent_chat.py +34 -1
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -867,7 +867,7 @@ async function performHttpMeasurement(params) {
|
|
|
867
867
|
* line as they arrive. Resolves when the process exits cleanly; rejects on
|
|
868
868
|
* non-zero exit or timeout. stderr is collected and appended to error messages.
|
|
869
869
|
*/
|
|
870
|
-
function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal) {
|
|
870
|
+
function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal, onTimeout) {
|
|
871
871
|
return new Promise((resolve, reject) => {
|
|
872
872
|
const spawnOpts = { stdio: ["pipe", "pipe", "pipe"] };
|
|
873
873
|
if (extraEnv && Object.keys(extraEnv).length > 0) {
|
|
@@ -878,10 +878,36 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
878
878
|
let lineBuffer = "";
|
|
879
879
|
let timedOut = false;
|
|
880
880
|
let aborted = false;
|
|
881
|
-
|
|
881
|
+
let closed = false;
|
|
882
|
+
let timerLive = true;
|
|
883
|
+
// On the time budget: with no onTimeout handler (probe/legacy) this is a hard cap —
|
|
884
|
+
// kill immediately. With a handler (task #81, interactive terminal) ask the user
|
|
885
|
+
// whether to keep waiting; the child keeps running/streaming during the ~60s prompt.
|
|
886
|
+
// "Keep waiting" disables the cap for the rest of the run; No / no-answer kills it.
|
|
887
|
+
const fireTimeout = async () => {
|
|
888
|
+
if (!timerLive || closed || aborted) return;
|
|
889
|
+
if (typeof onTimeout !== "function") {
|
|
890
|
+
timedOut = true;
|
|
891
|
+
timerLive = false;
|
|
892
|
+
child.kill("SIGKILL");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
let extend = false;
|
|
896
|
+
try {
|
|
897
|
+
extend = await onTimeout();
|
|
898
|
+
} catch {
|
|
899
|
+
extend = false;
|
|
900
|
+
}
|
|
901
|
+
if (closed || aborted || !timerLive) return; // finished/cancelled during the prompt
|
|
902
|
+
if (extend) {
|
|
903
|
+
timerLive = false; // ignore the time budget for the rest of this run
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
882
906
|
timedOut = true;
|
|
907
|
+
timerLive = false;
|
|
883
908
|
child.kill("SIGKILL");
|
|
884
|
-
}
|
|
909
|
+
};
|
|
910
|
+
const timer = setTimeout(fireTimeout, timeoutMs);
|
|
885
911
|
// User-requested cancel (gonext Ctrl+C): kill the Python agent so it stops burning
|
|
886
912
|
// steps/tokens. On close we resolve cleanly (not reject) — the caller checks the
|
|
887
913
|
// cancel flag and leaves the job in its already-"cancelled" state.
|
|
@@ -913,10 +939,14 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
913
939
|
stderr += d;
|
|
914
940
|
});
|
|
915
941
|
child.on("error", (e) => {
|
|
942
|
+
closed = true;
|
|
943
|
+
timerLive = false;
|
|
916
944
|
clearTimeout(timer);
|
|
917
945
|
reject(e);
|
|
918
946
|
});
|
|
919
947
|
child.on("close", (code) => {
|
|
948
|
+
closed = true;
|
|
949
|
+
timerLive = false;
|
|
920
950
|
clearTimeout(timer);
|
|
921
951
|
if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
|
|
922
952
|
// Drain any remaining buffered line
|
|
@@ -1879,6 +1909,9 @@ async function runAgentChatJob(job) {
|
|
|
1879
1909
|
// REPL's "I can show a picker" signal (else python keeps hard-blocking, as on web).
|
|
1880
1910
|
jobId,
|
|
1881
1911
|
interactiveApproval: payload?.interactiveApproval === true,
|
|
1912
|
+
// Session opt-in (task #80): auto-extend past the step budget without re-prompting
|
|
1913
|
+
// (the REPL sets this once the user picks "yes, don't ask again").
|
|
1914
|
+
alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
|
|
1882
1915
|
// Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
|
|
1883
1916
|
autoTest: payload?.autoTest === true,
|
|
1884
1917
|
// Deploy target chosen via the terminal /server picker (task #69). Host/user only.
|
|
@@ -1922,15 +1955,27 @@ async function runAgentChatJob(job) {
|
|
|
1922
1955
|
// re-validates). Terminal = its launch cwd; web = the temp scratch folder.
|
|
1923
1956
|
activeWorkspace: jobActiveWorkspace,
|
|
1924
1957
|
});
|
|
1925
|
-
//
|
|
1926
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
//
|
|
1929
|
-
//
|
|
1930
|
-
//
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
|
|
1958
|
+
// Agent run time budget (task #81). Default 60 min; user-configurable in web Settings
|
|
1959
|
+
// (agentTimeoutMinutes → payload.agentTimeoutMs). Multi-step ReAct on a 14B/31B with a
|
|
1960
|
+
// cold prompt cache — or a remote Ollama box slow-cold-loading a big model — can run
|
|
1961
|
+
// for many minutes per step, so a generous budget is normal. On expiry the TERMINAL
|
|
1962
|
+
// (interactive) asks the user whether to keep waiting (onAgentTimeout below); "yes"
|
|
1963
|
+
// ignores the budget for the rest of the run. WEB has no picker AND a hard WS deadline
|
|
1964
|
+
// (1_860_000 ms), so it's capped at 30 min and just aborts — our clean timeout error
|
|
1965
|
+
// then reaches the user instead of a generic WS timeout. On a hard stop the python
|
|
1966
|
+
// child is SIGKILLed (shows as a BrokenPipeError in the model server log — expected).
|
|
1967
|
+
// Liveness during long model calls comes from the python heartbeat (a keepalive step
|
|
1968
|
+
// chunk every 45s), not from this cap.
|
|
1969
|
+
const DEFAULT_AGENT_TIMEOUT_MS = 3_600_000; // 1 hour
|
|
1970
|
+
const WEB_AGENT_TIMEOUT_CAP_MS = 1_800_000; // stay under the web WS deadline
|
|
1971
|
+
const isInteractiveTimeout = payload?.interactiveApproval === true;
|
|
1972
|
+
const requestedTimeoutMs =
|
|
1973
|
+
Number.isFinite(payload?.agentTimeoutMs) && payload.agentTimeoutMs > 0
|
|
1974
|
+
? payload.agentTimeoutMs
|
|
1975
|
+
: DEFAULT_AGENT_TIMEOUT_MS;
|
|
1976
|
+
const timeoutMs = isInteractiveTimeout
|
|
1977
|
+
? requestedTimeoutMs
|
|
1978
|
+
: Math.min(requestedTimeoutMs, WEB_AGENT_TIMEOUT_CAP_MS);
|
|
1934
1979
|
|
|
1935
1980
|
let inThink = false;
|
|
1936
1981
|
let finalText = "";
|
|
@@ -1982,6 +2027,45 @@ async function runAgentChatJob(job) {
|
|
|
1982
2027
|
}, 1500);
|
|
1983
2028
|
cancelAc.signal.addEventListener("abort", () => { cancelled = true; }, { once: true });
|
|
1984
2029
|
|
|
2030
|
+
// Task #81: when the time budget is reached in the TERMINAL, ask the user whether to
|
|
2031
|
+
// keep waiting instead of just aborting. Reuses the #67 approval plumbing
|
|
2032
|
+
// (approval-request → poll approvalDecision) with a __TIMEOUT__:: sentinel the REPL
|
|
2033
|
+
// recognizes and renders as a Yes/No picker (auto-No at 60s). Returns true = keep
|
|
2034
|
+
// waiting (disable the cap), false = stop. Only wired for interactive jobs (web never
|
|
2035
|
+
// reaches here — it isn't passed as onTimeout).
|
|
2036
|
+
const onAgentTimeout = async () => {
|
|
2037
|
+
if (!isInteractiveTimeout || !jobId) return false;
|
|
2038
|
+
const approvalId = `timeout-${Date.now()}`;
|
|
2039
|
+
const mins = Math.round(timeoutMs / 60000);
|
|
2040
|
+
const command = `__TIMEOUT__::The agent has run for ${mins} min (the time budget). Keep waiting?`;
|
|
2041
|
+
try {
|
|
2042
|
+
const r = await workerFetch(`/api/worker/jobs/${jobId}/approval-request`, {
|
|
2043
|
+
method: "POST",
|
|
2044
|
+
body: JSON.stringify({ id: approvalId, command }),
|
|
2045
|
+
});
|
|
2046
|
+
if (!r.ok) return false;
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
// Poll for the user's Yes/No up to ~65s (the REPL auto-answers No at 60s).
|
|
2051
|
+
const deadline = Date.now() + 65_000;
|
|
2052
|
+
while (Date.now() < deadline) {
|
|
2053
|
+
if (cancelAc.signal.aborted) return false;
|
|
2054
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2055
|
+
try {
|
|
2056
|
+
const r = await workerFetch(`/api/worker/jobs/${jobId}`);
|
|
2057
|
+
if (r.ok) {
|
|
2058
|
+
const j = await r.json().catch(() => ({}));
|
|
2059
|
+
const dec = j?.approvalDecision;
|
|
2060
|
+
if (dec && dec.id === approvalId) return dec.allow === true;
|
|
2061
|
+
}
|
|
2062
|
+
} catch {
|
|
2063
|
+
/* transient poll error — keep trying */
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
return false; // no answer in time → treat as No (stop)
|
|
2067
|
+
};
|
|
2068
|
+
|
|
1985
2069
|
try {
|
|
1986
2070
|
await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
|
|
1987
2071
|
if (event.type === "log" && typeof event.text === "string") {
|
|
@@ -2036,7 +2120,7 @@ async function runAgentChatJob(job) {
|
|
|
2036
2120
|
enqueueText(event.text);
|
|
2037
2121
|
}
|
|
2038
2122
|
}
|
|
2039
|
-
}, pdfEnv, cancelAc.signal);
|
|
2123
|
+
}, pdfEnv, cancelAc.signal, isInteractiveTimeout ? onAgentTimeout : undefined);
|
|
2040
2124
|
} finally {
|
|
2041
2125
|
clearInterval(cancelPoll);
|
|
2042
2126
|
}
|
package/gonext-repl.mjs
CHANGED
|
@@ -778,8 +778,39 @@ function onApprovalKey(key) {
|
|
|
778
778
|
// Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
|
|
779
779
|
// (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
|
|
780
780
|
// under the python side's 180s wait); Enter is instant since Yes is preselected.
|
|
781
|
-
|
|
782
|
-
|
|
781
|
+
// The command carries a "__MAXSTEP__::<message>" sentinel when it's the step-budget
|
|
782
|
+
// continue prompt (task #80) rather than a risky-command approval. That case shows a
|
|
783
|
+
// 3-option picker (Yes / Yes-don't-ask-again / No); a normal command shows Yes/No.
|
|
784
|
+
// Returns { allow, dontAsk } — dontAsk is only ever true for the max-step "don't ask
|
|
785
|
+
// again this session" choice (the caller then stops sending the prompt).
|
|
786
|
+
const MAXSTEP_PREFIX = "__MAXSTEP__::";
|
|
787
|
+
const TIMEOUT_PREFIX = "__TIMEOUT__::";
|
|
788
|
+
async function approvalPrompt(command) {
|
|
789
|
+
// Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
|
|
790
|
+
// keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
|
|
791
|
+
if (command.startsWith(TIMEOUT_PREFIX)) {
|
|
792
|
+
const label = command.slice(TIMEOUT_PREFIX.length);
|
|
793
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
794
|
+
process.stdout.write("\n" + yellow("⏳ " + label) + dim(" (no answer in 60s = stop)") + "\n");
|
|
795
|
+
const idx = await pickFromList(
|
|
796
|
+
["Yes, keep waiting", "No, stop now"],
|
|
797
|
+
0,
|
|
798
|
+
{ timeoutMs: 60000, timeoutIndex: 1 }
|
|
799
|
+
);
|
|
800
|
+
return { allow: idx === 0, dontAsk: false };
|
|
801
|
+
}
|
|
802
|
+
const isMax = command.startsWith(MAXSTEP_PREFIX);
|
|
803
|
+
if (isMax) {
|
|
804
|
+
const label = command.slice(MAXSTEP_PREFIX.length);
|
|
805
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
806
|
+
process.stdout.write("\n" + yellow("⏭ " + label) + "\n");
|
|
807
|
+
const idx = await pickFromList(
|
|
808
|
+
["Yes, keep going", "Yes, and don't ask again this session", "No, stop here"],
|
|
809
|
+
0
|
|
810
|
+
);
|
|
811
|
+
return { allow: idx === 0 || idx === 1, dontAsk: idx === 1 };
|
|
812
|
+
}
|
|
813
|
+
const allow = await new Promise((resolve) => {
|
|
783
814
|
if (!process.stdin.isTTY) {
|
|
784
815
|
process.stdout.write(dim(`\n(approval needed for: ${command} — no interactive terminal, skipping)\n`));
|
|
785
816
|
resolve(false);
|
|
@@ -799,6 +830,7 @@ function approvalPrompt(command) {
|
|
|
799
830
|
orig(v);
|
|
800
831
|
};
|
|
801
832
|
});
|
|
833
|
+
return { allow, dontAsk: false };
|
|
802
834
|
}
|
|
803
835
|
// Generic arrow-key list picker (↑/↓ + Enter) — used by /server so you navigate instead
|
|
804
836
|
// of typing a number. Same mechanism as the Yes/No approval picker above, generalized to
|
|
@@ -839,7 +871,7 @@ function onListKey(key) {
|
|
|
839
871
|
finishList(-1);
|
|
840
872
|
}
|
|
841
873
|
}
|
|
842
|
-
function pickFromList(items, initialSel = 0) {
|
|
874
|
+
function pickFromList(items, initialSel = 0, opts = {}) {
|
|
843
875
|
return new Promise((resolve) => {
|
|
844
876
|
if (!process.stdin.isTTY || items.length === 0) {
|
|
845
877
|
resolve(-1);
|
|
@@ -850,6 +882,20 @@ function pickFromList(items, initialSel = 0) {
|
|
|
850
882
|
listResolve = resolve;
|
|
851
883
|
listPickerActive = true;
|
|
852
884
|
drawList(false);
|
|
885
|
+
// Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
|
|
886
|
+
// user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
|
|
887
|
+
// listResolve so any real selection clears the timer.
|
|
888
|
+
if (opts && opts.timeoutMs > 0) {
|
|
889
|
+
const to = setTimeout(
|
|
890
|
+
() => finishList(opts.timeoutIndex ?? -1),
|
|
891
|
+
opts.timeoutMs
|
|
892
|
+
);
|
|
893
|
+
const orig = listResolve;
|
|
894
|
+
listResolve = (v) => {
|
|
895
|
+
clearTimeout(to);
|
|
896
|
+
orig(v);
|
|
897
|
+
};
|
|
898
|
+
}
|
|
853
899
|
});
|
|
854
900
|
}
|
|
855
901
|
|
|
@@ -860,6 +906,10 @@ let sessionCodingModel = "";
|
|
|
860
906
|
// and fixes → re-tests until it passes before finishing. Default off; remembered per
|
|
861
907
|
// folder in the session file; sent to /agent-ask as autoTest so python drives the loop.
|
|
862
908
|
let sessionTestAuto = false;
|
|
909
|
+
// Step-budget opt-out (task #80): set once the user picks "yes, don't ask again" at a
|
|
910
|
+
// max-step prompt. Session-scoped (not persisted per folder); sent to /agent-ask each turn
|
|
911
|
+
// so the agent auto-extends past the step budget without prompting again.
|
|
912
|
+
let alwaysExtendOnMaxStep = false;
|
|
863
913
|
// Deploy target chosen via /server (task #69): {name, host, user} or null. Session-scoped;
|
|
864
914
|
// sent to /agent-ask as deployServer so the agent deploys to this host with KEY auth.
|
|
865
915
|
let selectedServer = null;
|
|
@@ -965,6 +1015,9 @@ async function runAgentTurn(history) {
|
|
|
965
1015
|
// The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
|
|
966
1016
|
// asks instead of hard-blocking it (interactive command-approval gate).
|
|
967
1017
|
interactiveApproval: true,
|
|
1018
|
+
// Step-budget opt-out (task #80): once the user chose "don't ask again", auto-extend
|
|
1019
|
+
// past the step budget without prompting.
|
|
1020
|
+
...(alwaysExtendOnMaxStep ? { alwaysExtendOnMaxStep: true } : {}),
|
|
968
1021
|
// Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
|
|
969
1022
|
...(sessionTestAuto ? { autoTest: true } : {}),
|
|
970
1023
|
// Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
|
|
@@ -1409,7 +1462,13 @@ async function runAgentTurn(history) {
|
|
|
1409
1462
|
) {
|
|
1410
1463
|
lastApprovalId = job.pendingApproval.id;
|
|
1411
1464
|
clearStatus();
|
|
1412
|
-
const allow = await approvalPrompt(job.pendingApproval.command || "");
|
|
1465
|
+
const { allow, dontAsk } = await approvalPrompt(job.pendingApproval.command || "");
|
|
1466
|
+
// "Yes, don't ask again this session" (max-step prompt only): remember it so every
|
|
1467
|
+
// later turn sends alwaysExtendOnMaxStep and the agent auto-extends silently (#80).
|
|
1468
|
+
if (dontAsk) {
|
|
1469
|
+
alwaysExtendOnMaxStep = true;
|
|
1470
|
+
process.stdout.write(dim(" (won't ask again about the step budget this session)\n"));
|
|
1471
|
+
}
|
|
1413
1472
|
try {
|
|
1414
1473
|
await fetch(`${apiBase}/api/worker/jobs/${jobId}/approval`, {
|
|
1415
1474
|
method: "POST",
|
package/gonext_agent_chat.py
CHANGED
|
@@ -2340,7 +2340,10 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason):
|
|
|
2340
2340
|
except Exception as e: # noqa: BLE001
|
|
2341
2341
|
_log(f"approval-request failed: {e} — treating as deny")
|
|
2342
2342
|
return False
|
|
2343
|
-
|
|
2343
|
+
if command.startswith("__MAXSTEP__::"):
|
|
2344
|
+
_emit({"type": "step", "text": "Awaiting your choice: continue past the step budget?"})
|
|
2345
|
+
else:
|
|
2346
|
+
_emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
|
|
2344
2347
|
deadline = _t.time() + 180 # 3 min; absent user → deny (never auto-run something risky)
|
|
2345
2348
|
while _t.time() < deadline:
|
|
2346
2349
|
_t.sleep(1.2)
|
|
@@ -5396,8 +5399,38 @@ def run_agent_chat(cfg):
|
|
|
5396
5399
|
_log(f"compact system prompt unavailable ({_e}); using smolagents default")
|
|
5397
5400
|
agent = _ToolAgent(**agent_kwargs)
|
|
5398
5401
|
_agent_ref["a"] = agent # let step_callback trim this agent's step memory (#63)
|
|
5402
|
+
# At the step budget the run ends WITHOUT final_answer (_maxsteps_partial hit). Offer
|
|
5403
|
+
# to keep going instead of dead-ending (task #80): a terminal user says Yes → extend
|
|
5404
|
+
# the budget to a large ceiling and CONTINUE with the existing memory (reset=False,
|
|
5405
|
+
# so the model keeps all prior observations). smolagents captures max_steps as a
|
|
5406
|
+
# LOCAL at run() time, so we can't bump it mid-loop — we re-enter run() with a raised
|
|
5407
|
+
# agent.max_steps instead. The real bound is the 30-min worker job cap, so 1000 just
|
|
5408
|
+
# means "run to completion". 'Yes, don't ask again' is handled REPL-side: it flips a
|
|
5409
|
+
# session flag so the NEXT turn arrives with alwaysExtendOnMaxStep and we auto-extend
|
|
5410
|
+
# without prompting.
|
|
5411
|
+
_MAXSTEP_CEILING = 1000
|
|
5412
|
+
_always_extend = bool(cfg.get("alwaysExtendOnMaxStep"))
|
|
5399
5413
|
with contextlib.redirect_stdout(sys.stderr):
|
|
5400
5414
|
result = agent.run(task_with_hint)
|
|
5415
|
+
while _maxsteps_partial.get("hit"):
|
|
5416
|
+
if _always_extend:
|
|
5417
|
+
_log(f"max-steps reached → auto-extending to {_MAXSTEP_CEILING} "
|
|
5418
|
+
"(don't-ask-again session flag)")
|
|
5419
|
+
_do_extend = True
|
|
5420
|
+
elif _interactive_approval and _job_id and pdf_api_base and pdf_worker_key:
|
|
5421
|
+
_do_extend = _ws_request_approval(
|
|
5422
|
+
pdf_api_base, pdf_worker_key, _job_id,
|
|
5423
|
+
f"__MAXSTEP__::Reached the step budget ({agent.max_steps}). "
|
|
5424
|
+
"Keep going?", "max-steps")
|
|
5425
|
+
else:
|
|
5426
|
+
_do_extend = False # web / no interactive channel → current wrap-up
|
|
5427
|
+
if not _do_extend:
|
|
5428
|
+
break
|
|
5429
|
+
_maxsteps_partial["hit"] = False
|
|
5430
|
+
agent.max_steps = _MAXSTEP_CEILING
|
|
5431
|
+
_emit({"type": "step", "text": "Continuing — step budget extended…"})
|
|
5432
|
+
_log(f"continuing run with raised budget {agent.max_steps}")
|
|
5433
|
+
result = agent.run(task_with_hint, reset=False)
|
|
5401
5434
|
# Final formatting — NO extra summarizer model call. In multi-step mode the
|
|
5402
5435
|
# agent's own final_answer() (or the max-steps fallback above) already holds the
|
|
5403
5436
|
# synthesized answer; we just strip the internal hint tags we appended to tool
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.249",
|
|
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",
|