@tiens.nguyen/gonext-local-worker 1.0.196 → 1.0.197
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 +7 -1
- package/gonext-repl.mjs +135 -6
- package/gonext_agent_chat.py +309 -10
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -288,7 +288,7 @@ if (args[0] === "workspace") {
|
|
|
288
288
|
// gonext-local-worker workspace revert → latest run
|
|
289
289
|
// gonext-local-worker workspace revert <runId> → a specific run (see backups dir)
|
|
290
290
|
const { readdirSync, existsSync: ex } = await import("node:fs");
|
|
291
|
-
const { copyFile, unlink } = await import("node:fs/promises");
|
|
291
|
+
const { copyFile, unlink, rmdir } = await import("node:fs/promises");
|
|
292
292
|
const backupsRoot = join(homedir(), ".gonext", "workspace-backups");
|
|
293
293
|
let runId = args[2] || "";
|
|
294
294
|
if (!runId) {
|
|
@@ -321,6 +321,12 @@ if (args[0] === "workspace") {
|
|
|
321
321
|
await unlink(c.path);
|
|
322
322
|
deleted += 1;
|
|
323
323
|
console.log(` deleted ${c.path}`);
|
|
324
|
+
} else if (c.action === "create_dir" && ex(c.path)) {
|
|
325
|
+
// rmdir only removes an EMPTY dir — a revert must never destroy content
|
|
326
|
+
// the user (or a later run) put inside the created folder afterwards.
|
|
327
|
+
await rmdir(c.path);
|
|
328
|
+
deleted += 1;
|
|
329
|
+
console.log(` removed ${c.path}/`);
|
|
324
330
|
}
|
|
325
331
|
} catch (err) {
|
|
326
332
|
failed += 1;
|
package/gonext-repl.mjs
CHANGED
|
@@ -15,21 +15,28 @@
|
|
|
15
15
|
* logs, exactly like a web question. The REPL polls the job and streams the persisted
|
|
16
16
|
* chunks live into the terminal.
|
|
17
17
|
*
|
|
18
|
+
* Conversation history is saved to ~/.gonext/sessions/<hash-of-folder-path>.json after
|
|
19
|
+
* every turn and reloaded on the next `gonext` in the SAME folder — so closing the
|
|
20
|
+
* terminal and coming back later still has context (e.g. "continue" after an
|
|
21
|
+
* interrupted investigation actually resumes it). /reset clears it.
|
|
22
|
+
*
|
|
18
23
|
* Requires the worker daemon to be running (it claims the job).
|
|
19
|
-
* Commands: /exit /revert [runId] /help Ctrl-C stops following the current run.
|
|
24
|
+
* Commands: /exit /reset /revert [runId] /help Ctrl-C stops following the current run.
|
|
20
25
|
*/
|
|
21
|
-
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
26
|
+
import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
|
|
22
27
|
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
23
28
|
import { spawn } from "node:child_process";
|
|
24
29
|
import { homedir } from "node:os";
|
|
25
30
|
import { dirname, join, resolve, basename } from "node:path";
|
|
26
31
|
import { fileURLToPath } from "node:url";
|
|
27
32
|
import { createInterface } from "node:readline";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
28
34
|
import dotenv from "dotenv";
|
|
29
35
|
|
|
30
36
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
|
31
37
|
const ENV_FILE = join(homedir(), ".gonext", "worker.env");
|
|
32
38
|
const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
|
|
39
|
+
const SESSIONS_DIR = join(homedir(), ".gonext", "sessions");
|
|
33
40
|
|
|
34
41
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
35
42
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
@@ -39,6 +46,7 @@ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
|
39
46
|
// ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
|
|
40
47
|
// code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
|
|
41
48
|
const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
49
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
42
50
|
const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
|
|
43
51
|
|
|
44
52
|
// The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
|
|
@@ -65,6 +73,19 @@ const isRoutingLine = (line) =>
|
|
|
65
73
|
/^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
|
|
66
74
|
line.trim()
|
|
67
75
|
);
|
|
76
|
+
// The edit tools (edit_lines/edit_file/create_file) emit a multi-line "edit card"
|
|
77
|
+
// as one step event — a small stable line protocol rendered here as a colored diff
|
|
78
|
+
// (bold header, red '-' / green '+' lines, dim line numbers). Shapes must stay in
|
|
79
|
+
// sync with _render_edit_card in gonext_agent_chat.py:
|
|
80
|
+
// ⏺ Update(XeroForwardService.java)
|
|
81
|
+
// └ Replaced lines 19-19 with 1 line
|
|
82
|
+
// 19 - ...old...
|
|
83
|
+
// 19 + ...new...
|
|
84
|
+
// … (+3 more)
|
|
85
|
+
const isEditCardHeader = (line) => /^⏺ (Update|Create)\(.+\)/.test(line);
|
|
86
|
+
const EDIT_DIFF_RE = /^(\s{4})(\d+) ([-+]) (.*)$/;
|
|
87
|
+
const isEditCardBody = (line) =>
|
|
88
|
+
/^\s*└ /.test(line) || EDIT_DIFF_RE.test(line) || /^\s{4}… /.test(line);
|
|
68
89
|
|
|
69
90
|
// Playful words for the local "thinking…" ticker, reused verbatim from the worker's
|
|
70
91
|
// thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
|
|
@@ -90,9 +111,11 @@ if (argv.includes("--help") || argv.includes("-h")) {
|
|
|
90
111
|
" -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
|
|
91
112
|
" -h, --help this help\n\n" +
|
|
92
113
|
"Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
|
|
93
|
-
"Inside the REPL: /exit · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
|
|
114
|
+
"Inside the REPL: /exit · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
|
|
94
115
|
"Questions are enqueued like web questions and executed by the RUNNING\n" +
|
|
95
|
-
"gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs
|
|
116
|
+
"gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs.\n" +
|
|
117
|
+
"Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
|
|
118
|
+
"you run `gonext` in the same folder; /reset forgets it and starts fresh."
|
|
96
119
|
);
|
|
97
120
|
process.exit(0);
|
|
98
121
|
}
|
|
@@ -218,6 +241,60 @@ async function ensureWorkspace() {
|
|
|
218
241
|
);
|
|
219
242
|
}
|
|
220
243
|
|
|
244
|
+
// ---------- conversation persistence, keyed by folder ----------
|
|
245
|
+
// Without this, closing the terminal (or restarting `gonext`) loses ALL context — the
|
|
246
|
+
// next session starts from a blank slate even though the USER remembers the earlier
|
|
247
|
+
// investigation. "continue" (and general follow-ups) only make sense across restarts if
|
|
248
|
+
// there's something real on disk to continue FROM. Same hash-keying scheme the worker's
|
|
249
|
+
// python side already uses for per-URL RAG work dirs (sha256, 32 hex chars), applied
|
|
250
|
+
// here to the workspace path instead so returning to the SAME folder reloads the SAME
|
|
251
|
+
// conversation.
|
|
252
|
+
function sessionFilePath(cwd) {
|
|
253
|
+
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
|
|
254
|
+
return join(SESSIONS_DIR, `${hash}.json`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Cap what's kept on disk — the worker already truncates what it actually SENDS to the
|
|
258
|
+
// model by a char budget (see "history: N prior turn(s), M chars (budget 8000)" in its
|
|
259
|
+
// logs), so persisting more than that budget could ever use is just unbounded growth
|
|
260
|
+
// for a long-lived folder with no benefit.
|
|
261
|
+
const MAX_PERSISTED_MESSAGES = 40;
|
|
262
|
+
|
|
263
|
+
async function loadSession(cwd) {
|
|
264
|
+
try {
|
|
265
|
+
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
266
|
+
return Array.isArray(raw?.history) ? raw.history : [];
|
|
267
|
+
} catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function saveSession(cwd, history) {
|
|
273
|
+
try {
|
|
274
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
275
|
+
const trimmed = history.slice(-MAX_PERSISTED_MESSAGES);
|
|
276
|
+
await writeFile(
|
|
277
|
+
sessionFilePath(cwd),
|
|
278
|
+
JSON.stringify(
|
|
279
|
+
{ cwd, updatedAt: new Date().toISOString(), history: trimmed },
|
|
280
|
+
null,
|
|
281
|
+
2
|
|
282
|
+
) + "\n"
|
|
283
|
+
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// Non-fatal — losing session persistence should never crash or block the REPL.
|
|
286
|
+
console.error(dim(`(session save failed: ${err.message})`));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function clearSession(cwd) {
|
|
291
|
+
try {
|
|
292
|
+
await unlink(sessionFilePath(cwd));
|
|
293
|
+
} catch {
|
|
294
|
+
// Nothing on disk to clear — fine.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
221
298
|
// ---------- fetch the ready-to-run agent payload from user settings ----------
|
|
222
299
|
async function fetchAgentPayload(messages) {
|
|
223
300
|
const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
|
|
@@ -374,6 +451,7 @@ async function runAgentTurn(history) {
|
|
|
374
451
|
// them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
|
|
375
452
|
// blank lines keep spacing; everything else commits dim to scrollback.
|
|
376
453
|
let inCode = false;
|
|
454
|
+
let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
|
|
377
455
|
let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
|
|
378
456
|
// Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
|
|
379
457
|
// a long plain-reply answer stream character-by-character instead of waiting for a "\n"
|
|
@@ -403,6 +481,35 @@ async function runAgentTurn(history) {
|
|
|
403
481
|
lastContentAt = Date.now();
|
|
404
482
|
continue;
|
|
405
483
|
}
|
|
484
|
+
// Edit card (a file change made by the agent's edit tools) — rendered as a
|
|
485
|
+
// colored diff instead of dim prose. Checked before the <code> handling: the
|
|
486
|
+
// card is a step event, never part of the model's raw code stream, and the ⏺
|
|
487
|
+
// header is distinctive enough to never collide with generated Python.
|
|
488
|
+
if (isEditCardHeader(line)) {
|
|
489
|
+
onContent();
|
|
490
|
+
inEditCard = true;
|
|
491
|
+
process.stdout.write(green("⏺") + bold(line.slice(1)) + "\n");
|
|
492
|
+
lastWasBlank = false;
|
|
493
|
+
lastContentAt = Date.now();
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (inEditCard) {
|
|
497
|
+
if (isEditCardBody(line)) {
|
|
498
|
+
onContent();
|
|
499
|
+
const m = EDIT_DIFF_RE.exec(line);
|
|
500
|
+
if (m) {
|
|
501
|
+
// " 19 - old text" → dim line number, red/green signed content.
|
|
502
|
+
const paintDiff = m[3] === "+" ? green : red;
|
|
503
|
+
process.stdout.write(`${m[1]}${dim(m[2])} ${paintDiff(`${m[3]} ${m[4]}`)}\n`);
|
|
504
|
+
} else {
|
|
505
|
+
process.stdout.write(dim(line) + "\n"); // "└ summary" / "… (+N more)"
|
|
506
|
+
}
|
|
507
|
+
lastWasBlank = false;
|
|
508
|
+
lastContentAt = Date.now();
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
inEditCard = false; // card ended — fall through to normal handling
|
|
512
|
+
}
|
|
406
513
|
if (!inCode && isCodeOpenLine(line)) {
|
|
407
514
|
stampIfThinking(); // hold/refresh the stamp; the code body commits it below
|
|
408
515
|
inCode = true;
|
|
@@ -522,7 +629,14 @@ async function main() {
|
|
|
522
629
|
}
|
|
523
630
|
console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
|
|
524
631
|
|
|
525
|
-
const
|
|
632
|
+
const cwd = resolve(process.cwd());
|
|
633
|
+
const history = await loadSession(cwd);
|
|
634
|
+
if (history.length > 0) {
|
|
635
|
+
const turns = history.filter((m) => m.role === "user").length;
|
|
636
|
+
console.log(
|
|
637
|
+
dim(`Resumed session: ${turns} prior turn(s) from last time in this folder — /reset to start fresh.\n`)
|
|
638
|
+
);
|
|
639
|
+
}
|
|
526
640
|
// With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
|
|
527
641
|
// scanning (rl.on("SIGINT", …) would never fire) — it now delivers a real OS SIGINT to
|
|
528
642
|
// the process instead, which we catch directly here. Same behavior as before.
|
|
@@ -550,7 +664,18 @@ async function main() {
|
|
|
550
664
|
const line = lineRaw.trim();
|
|
551
665
|
if (line === "/exit" || line === "/quit") break;
|
|
552
666
|
if (line === "/help") {
|
|
553
|
-
console.log(
|
|
667
|
+
console.log(
|
|
668
|
+
dim(
|
|
669
|
+
" /exit — quit · /revert [runId] — undo the agent's file edits (latest run) · " +
|
|
670
|
+
"/reset — forget this folder's saved conversation and start fresh"
|
|
671
|
+
)
|
|
672
|
+
);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (line === "/reset" || line === "/new") {
|
|
676
|
+
history.length = 0;
|
|
677
|
+
await clearSession(cwd);
|
|
678
|
+
console.log(dim("Session cleared — starting fresh.\n"));
|
|
554
679
|
continue;
|
|
555
680
|
}
|
|
556
681
|
if (line.startsWith("/revert")) {
|
|
@@ -571,6 +696,10 @@ async function main() {
|
|
|
571
696
|
const { text: answer, shown } = await runAgentTurn(history);
|
|
572
697
|
if (answer) {
|
|
573
698
|
history.push({ role: "assistant", content: answer });
|
|
699
|
+
// Persist after every successful turn (not just on clean exit) — an ungraceful
|
|
700
|
+
// termination (killed terminal, force-quit) would otherwise lose the whole
|
|
701
|
+
// session even though most of it completed fine.
|
|
702
|
+
await saveSession(cwd, history);
|
|
574
703
|
// A plain-reply answer already streamed live in the loop above — printing it
|
|
575
704
|
// again here would just duplicate it; a blank line is enough for spacing.
|
|
576
705
|
console.log(shown ? "" : `\n\n${answer}\n`);
|
package/gonext_agent_chat.py
CHANGED
|
@@ -599,6 +599,29 @@ _AGENT_KEYWORDS = re.compile(
|
|
|
599
599
|
re.IGNORECASE,
|
|
600
600
|
)
|
|
601
601
|
|
|
602
|
+
# File/folder ACTION requests ("create a folder abc", "delete test.txt", "rename X to
|
|
603
|
+
# Y", "list the files") are agent work whenever a workspace is registered. The generic
|
|
604
|
+
# keyword list above cannot carry bare verbs like "create"/"delete" (they'd misroute
|
|
605
|
+
# ordinary chat for non-workspace users), and the weak model classifier reliably
|
|
606
|
+
# misreads these imperatives as how-to questions — answering with INSTRUCTIONS (a
|
|
607
|
+
# mkdir tutorial) instead of DOING the action (the reported bug, task #40). Verb and
|
|
608
|
+
# object noun must appear within one clause (no sentence punctuation between them).
|
|
609
|
+
_WS_ACTION_RE = re.compile(
|
|
610
|
+
r"\b(?:create|make|add|new|delete|remove|rename|move|copy|list|show)\b"
|
|
611
|
+
r"[^.?!]{0,60}?"
|
|
612
|
+
r"\b(?:folders?|director(?:y|ies)|dirs?|files?|subfolders?)\b"
|
|
613
|
+
r"|\bmkdir\b",
|
|
614
|
+
re.IGNORECASE,
|
|
615
|
+
)
|
|
616
|
+
# Genuine how-to QUESTIONS about files/folders ("how do I create a folder in
|
|
617
|
+
# Windows?") must stay plain chat — the user wants an explanation, not the action.
|
|
618
|
+
# Deliberately FIRST-PERSON only ("can I…"): "can/could YOU create a folder" is a
|
|
619
|
+
# polite request for the agent to DO it, not a how-to question.
|
|
620
|
+
_HOWTO_PREFIX_RE = re.compile(
|
|
621
|
+
r"^(?:how|what|why|when|where|which|explain|can\s+i|could\s+i|should\s+i|is\s+it|does)\b",
|
|
622
|
+
re.IGNORECASE,
|
|
623
|
+
)
|
|
624
|
+
|
|
602
625
|
# Pure conversational openers/closers that never need a tool — a greeting, a thank-you, a
|
|
603
626
|
# "who are you". Matching the WHOLE message (anchored) means "hi, fetch https://…" won't
|
|
604
627
|
# match. These skip BOTH the model router call AND the heavy tool preamble → an instant
|
|
@@ -1844,11 +1867,137 @@ def _workspace_overview(roots, max_entries: int = 40) -> str:
|
|
|
1844
1867
|
return "\n".join(parts)
|
|
1845
1868
|
|
|
1846
1869
|
|
|
1870
|
+
def _turn_checkpoint_path(workspace: str) -> str:
|
|
1871
|
+
"""Path to the interrupted-turn checkpoint for a given workspace. Keyed by a hash
|
|
1872
|
+
of the workspace path (same sha256-slice convention as _rag_source_key) — but a
|
|
1873
|
+
separate file/purpose from the REPL's own ~/.gonext/sessions: this holds one
|
|
1874
|
+
in-PROGRESS turn's step trace, written incrementally as the agent works, not
|
|
1875
|
+
completed Q&A history."""
|
|
1876
|
+
import hashlib
|
|
1877
|
+
import os
|
|
1878
|
+
h = hashlib.sha256(workspace.encode("utf-8")).hexdigest()[:32]
|
|
1879
|
+
base = os.path.join(os.path.expanduser("~"), ".gonext", "agent-turns")
|
|
1880
|
+
os.makedirs(base, exist_ok=True)
|
|
1881
|
+
return os.path.join(base, f"{h}.json")
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def _write_turn_checkpoint(workspace: str, task: str, steps: list) -> None:
|
|
1885
|
+
"""Overwrite the checkpoint for this workspace with the CURRENT turn's step trace so
|
|
1886
|
+
far. Called after every agent step (see step_callback). If the process dies mid-turn
|
|
1887
|
+
(network drop, kill, crash) before any final answer is ever emitted, this file
|
|
1888
|
+
survives on disk and the NEXT turn in this workspace recovers it instead of silently
|
|
1889
|
+
re-investigating from scratch — see _load_and_clear_turn_checkpoint."""
|
|
1890
|
+
import json
|
|
1891
|
+
import os
|
|
1892
|
+
try:
|
|
1893
|
+
path = _turn_checkpoint_path(workspace)
|
|
1894
|
+
tmp = path + ".tmp"
|
|
1895
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
1896
|
+
json.dump({"task": task, "steps": steps}, f)
|
|
1897
|
+
os.replace(tmp, path)
|
|
1898
|
+
except Exception as e: # noqa: BLE001
|
|
1899
|
+
_log(f"turn checkpoint write failed (non-fatal): {e}")
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def _load_and_clear_turn_checkpoint(workspace: str):
|
|
1903
|
+
"""Read a leftover checkpoint (if any) from a prior turn in this workspace that
|
|
1904
|
+
never reached a graceful finish, then delete it — its content is about to be folded
|
|
1905
|
+
into the NEW turn's context, and the new turn starts writing its own fresh
|
|
1906
|
+
checkpoint from here. Returns None if there's nothing to recover."""
|
|
1907
|
+
import json
|
|
1908
|
+
import os
|
|
1909
|
+
path = _turn_checkpoint_path(workspace)
|
|
1910
|
+
data = None
|
|
1911
|
+
try:
|
|
1912
|
+
with open(path, encoding="utf-8") as f:
|
|
1913
|
+
data = json.load(f)
|
|
1914
|
+
except FileNotFoundError:
|
|
1915
|
+
return None
|
|
1916
|
+
except Exception as e: # noqa: BLE001
|
|
1917
|
+
_log(f"turn checkpoint read failed (non-fatal): {e}")
|
|
1918
|
+
return None
|
|
1919
|
+
finally:
|
|
1920
|
+
try:
|
|
1921
|
+
os.remove(path)
|
|
1922
|
+
except OSError:
|
|
1923
|
+
pass
|
|
1924
|
+
return data
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def _turn_checkpoint_exists(workspace: str) -> bool:
|
|
1928
|
+
"""Non-consuming probe: does an interrupted turn survive on disk for this
|
|
1929
|
+
workspace? Routing needs to KNOW there's work to resume (to force agent mode on a
|
|
1930
|
+
bare 'continue') without destroying it — the read-and-delete consume happens later,
|
|
1931
|
+
only on the agent path, where the recovered trace is actually fed to a model."""
|
|
1932
|
+
import os
|
|
1933
|
+
return os.path.exists(_turn_checkpoint_path(workspace))
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def _clear_turn_checkpoint(workspace: str) -> None:
|
|
1937
|
+
"""Delete the checkpoint once a turn reaches ANY graceful finish (full success, the
|
|
1938
|
+
max-steps fallback, or a successful degrade-to-plain-reply) — that turn's findings
|
|
1939
|
+
are already captured in the final answer pushed to conversation history, so the raw
|
|
1940
|
+
step trace is no longer needed for crash recovery."""
|
|
1941
|
+
import os
|
|
1942
|
+
try:
|
|
1943
|
+
os.remove(_turn_checkpoint_path(workspace))
|
|
1944
|
+
except OSError:
|
|
1945
|
+
pass
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def _render_edit_card(action: str, path: str, summary: str,
|
|
1949
|
+
removed=None, added=None,
|
|
1950
|
+
max_removed: int = 8, max_added: int = 12) -> str:
|
|
1951
|
+
"""Multi-line terminal-friendly summary of a file edit, emitted as ONE step event.
|
|
1952
|
+
|
|
1953
|
+
The line shapes are a small stable protocol the REPL colorizes (bold header,
|
|
1954
|
+
red '-' lines, green '+' lines, dim line numbers) — keep them in sync with the
|
|
1955
|
+
isEditCardHeader/EDIT_DIFF_RE regexes in gonext-repl.mjs. The web thinking panel
|
|
1956
|
+
shows the same text as-is (the 4-space indent renders as a monospace block in
|
|
1957
|
+
markdown), so no web change is needed.
|
|
1958
|
+
|
|
1959
|
+
⏺ Update(XeroForwardService.java)
|
|
1960
|
+
└ Replaced lines 19-19 with 1 line
|
|
1961
|
+
19 - ...old text...
|
|
1962
|
+
19 + ...new text...
|
|
1963
|
+
"""
|
|
1964
|
+
import os
|
|
1965
|
+
lines = [f"⏺ {action}({os.path.basename(path)})", f" └ {summary}"]
|
|
1966
|
+
|
|
1967
|
+
def _fmt(entries, sign, cap):
|
|
1968
|
+
out = []
|
|
1969
|
+
for i, (no, txt) in enumerate(entries):
|
|
1970
|
+
if i >= cap:
|
|
1971
|
+
out.append(f" … (+{len(entries) - cap} more)")
|
|
1972
|
+
break
|
|
1973
|
+
# NOT _clip() — that collapses ALL whitespace, destroying the code's
|
|
1974
|
+
# leading indentation, which a diff needs to stay readable.
|
|
1975
|
+
t = txt.rstrip()
|
|
1976
|
+
if len(t) > 160:
|
|
1977
|
+
t = t[:159] + "…"
|
|
1978
|
+
out.append(f" {no} {sign} {t}")
|
|
1979
|
+
return out
|
|
1980
|
+
|
|
1981
|
+
if removed:
|
|
1982
|
+
lines += _fmt(removed, "-", max_removed)
|
|
1983
|
+
if added:
|
|
1984
|
+
lines += _fmt(added, "+", max_added)
|
|
1985
|
+
return "\n".join(lines)
|
|
1986
|
+
|
|
1987
|
+
|
|
1847
1988
|
def _ws_write_allowed(path: str) -> str:
|
|
1848
1989
|
"""Resolve `path` for WRITING: must be inside a registered workspace and never
|
|
1849
1990
|
inside its .git/ internals (agents must not corrupt version control)."""
|
|
1850
1991
|
import os
|
|
1851
|
-
|
|
1992
|
+
p = (path or "").strip()
|
|
1993
|
+
# Anchor RELATIVE paths to the active/first workspace root — the worker daemon's
|
|
1994
|
+
# own cwd is ~, so a bare "abc" would otherwise resolve to ~/abc and be rejected
|
|
1995
|
+
# even though the user plainly means <workspace>/abc. Mirrors _rag_read_allowed.
|
|
1996
|
+
if p and not os.path.isabs(os.path.expanduser(p)):
|
|
1997
|
+
base = _WS_ACTIVE or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "")
|
|
1998
|
+
if base:
|
|
1999
|
+
p = os.path.join(base, p)
|
|
2000
|
+
rp = os.path.realpath(os.path.expanduser(p))
|
|
1852
2001
|
w = _ws_for_path(rp)
|
|
1853
2002
|
if w is None:
|
|
1854
2003
|
raise ValueError(
|
|
@@ -1862,7 +2011,7 @@ def _ws_write_allowed(path: str) -> str:
|
|
|
1862
2011
|
|
|
1863
2012
|
|
|
1864
2013
|
_WS_RUN_ID = None # backup folder for this run, created lazily on first change
|
|
1865
|
-
_WS_CHANGES: list = [] # [{"action": "edit"|"create", "path": str, "backup": str|None}]
|
|
2014
|
+
_WS_CHANGES: list = [] # [{"action": "edit"|"create"|"create_dir", "path": str, "backup": str|None}]
|
|
1866
2015
|
|
|
1867
2016
|
|
|
1868
2017
|
def _ws_backup_root() -> str:
|
|
@@ -2246,11 +2395,27 @@ def run_agent_chat(cfg):
|
|
|
2246
2395
|
the AGENT, not plain chat — plain chat has zero tool access and can never
|
|
2247
2396
|
actually continue a stalled grep_repo/read_file_lines investigation, which is
|
|
2248
2397
|
exactly the reported bug: 'continue' silently degraded to a tool-less reply that
|
|
2249
|
-
could only apologize, never resume.
|
|
2250
|
-
|
|
2251
|
-
|
|
2398
|
+
could only apologize, never resume. Two independent interruption signals:
|
|
2399
|
+
(a) our own interrupted-investigation marker in the prior assistant turn (the
|
|
2400
|
+
max-steps note or the degrade-fallback note); (b) an on-disk turn checkpoint for
|
|
2401
|
+
this workspace — a CRASHED turn leaves NO history marker at all (the REPL
|
|
2402
|
+
discards failed turns entirely, by design — task #35), so the checkpoint file is
|
|
2403
|
+
the only surviving evidence there is work to resume. Gated tightly (short
|
|
2404
|
+
continuation phrase + a genuine interruption signal) so it doesn't misfire on an
|
|
2405
|
+
unrelated 'continue' after a normal, already-complete answer."""
|
|
2252
2406
|
return bool(_CONTINUE_CUE.match((latest_user_text or "").strip())
|
|
2253
|
-
and _prior_turn_was_interrupted()
|
|
2407
|
+
and (_prior_turn_was_interrupted()
|
|
2408
|
+
or (_WS_ACTIVE and _turn_checkpoint_exists(_WS_ACTIVE))))
|
|
2409
|
+
|
|
2410
|
+
def _ws_action_pending() -> bool:
|
|
2411
|
+
"""An imperative file/folder action ("create a folder abc") with a registered
|
|
2412
|
+
workspace must reach the AGENT — plain chat can only answer with mkdir
|
|
2413
|
+
instructions (the reported task-#40 bug). How-to QUESTIONS are excluded: the
|
|
2414
|
+
user asking "how do I create a folder in Windows?" wants an explanation."""
|
|
2415
|
+
t = (latest_user_text or "").strip()
|
|
2416
|
+
return bool(_WS_AVAILABLE
|
|
2417
|
+
and not _HOWTO_PREFIX_RE.match(t)
|
|
2418
|
+
and _WS_ACTION_RE.search(t))
|
|
2254
2419
|
|
|
2255
2420
|
# Route: does this task need HTTP tool use? A trivial greeting/thank-you/"who are you"
|
|
2256
2421
|
# is caught LOCALLY (no model classifier call, no tool preamble) → straight to a fast
|
|
@@ -2275,6 +2440,13 @@ def run_agent_chat(cfg):
|
|
|
2275
2440
|
_log("router → YES (continuation of an interrupted investigation)")
|
|
2276
2441
|
_emit({"type": "step", "text": "→ Agent mode (continuing)"})
|
|
2277
2442
|
needs_agent = True
|
|
2443
|
+
# An imperative file/folder action with a workspace registered must be DONE with
|
|
2444
|
+
# tools, not answered with terminal instructions — the classifier misreads these
|
|
2445
|
+
# as how-to questions (task #40: "create a folder abc" got a mkdir tutorial).
|
|
2446
|
+
if not needs_agent and _ws_action_pending():
|
|
2447
|
+
_log("router → YES (workspace file/folder action)")
|
|
2448
|
+
_emit({"type": "step", "text": "→ Agent mode (workspace action)"})
|
|
2449
|
+
needs_agent = True
|
|
2278
2450
|
|
|
2279
2451
|
if not needs_agent:
|
|
2280
2452
|
_log("router: plain chat (no HTTP needed)")
|
|
@@ -2518,6 +2690,42 @@ def run_agent_chat(cfg):
|
|
|
2518
2690
|
"create_pdf / final_answer.\n"
|
|
2519
2691
|
"- Do NOT put final_answer outside the code block.\n\n"
|
|
2520
2692
|
)
|
|
2693
|
+
# Recover an interrupted prior turn's step trace, if one survives on disk (crash/
|
|
2694
|
+
# connection drop mid-investigation — see _write_turn_checkpoint). Consumed HERE,
|
|
2695
|
+
# inside the agent path only, and not up front before routing: task_text feeds only
|
|
2696
|
+
# the agent prompt (plain chat sends the raw `messages` list to _plain_reply), so an
|
|
2697
|
+
# earlier consume would let any plain-chat-routed message delete the checkpoint
|
|
2698
|
+
# without any model ever seeing it — silently destroying the recovery data.
|
|
2699
|
+
if _WS_ACTIVE:
|
|
2700
|
+
_ck = _load_and_clear_turn_checkpoint(_WS_ACTIVE)
|
|
2701
|
+
if _ck and _ck.get("steps"):
|
|
2702
|
+
_ck_steps = _ck["steps"]
|
|
2703
|
+
# Newest-first budget walk (same pattern as the history condenser above):
|
|
2704
|
+
# the latest steps matter most for resuming, and an uncapped 12-step trace
|
|
2705
|
+
# (~25K chars at the per-step clip limits) would dominate prompt-eval on a
|
|
2706
|
+
# slow coding server — the user's box already takes 3+ min at half that.
|
|
2707
|
+
_kept, _used = [], 0
|
|
2708
|
+
for _i, _s in enumerate(reversed(_ck_steps)):
|
|
2709
|
+
if not isinstance(_s, dict):
|
|
2710
|
+
_s = {"did": str(_s), "found": ""}
|
|
2711
|
+
_seg = (f"Step {_s.get('step', len(_ck_steps) - _i)}:\n"
|
|
2712
|
+
f" Did: {_s.get('did', '')}\n Found: {_s.get('found', '')}")
|
|
2713
|
+
if _used + len(_seg) > 5000 and _kept:
|
|
2714
|
+
_kept.append(f"(…{len(_ck_steps) - len(_kept)} earlier step(s) omitted)")
|
|
2715
|
+
break
|
|
2716
|
+
_kept.append(_seg)
|
|
2717
|
+
_used += len(_seg)
|
|
2718
|
+
_kept.reverse()
|
|
2719
|
+
_log(f"recovered {len(_ck_steps)} step(s) from an interrupted prior turn "
|
|
2720
|
+
f"in this workspace ({_used} chars folded into the task)")
|
|
2721
|
+
task_text += (
|
|
2722
|
+
"\n\nNOTE: your previous attempt at a task in this workspace was "
|
|
2723
|
+
"interrupted before finishing (crash or connection drop) — here is "
|
|
2724
|
+
"exactly what was already done and found. Do NOT repeat these steps; "
|
|
2725
|
+
"continue the investigation/edit from here:\n"
|
|
2726
|
+
f"Previous task: {_ck.get('task', '')}\n" + "\n".join(_kept)
|
|
2727
|
+
)
|
|
2728
|
+
|
|
2521
2729
|
# Lead with the TASK so the weak model anchors on what's actually being asked —
|
|
2522
2730
|
# not on the tool reference below. (Previously the hint led with the date, and the
|
|
2523
2731
|
# 3B model treated every message as a date question.)
|
|
@@ -2535,6 +2743,13 @@ def run_agent_chat(cfg):
|
|
|
2535
2743
|
# tool returned (no extra model call) if the loop ends without final_answer.
|
|
2536
2744
|
_last_obs: dict = {"text": ""}
|
|
2537
2745
|
|
|
2746
|
+
# Set by provide_final_answer when it had to synthesize a PARTIAL answer at the
|
|
2747
|
+
# step budget. Read by the graceful-finish checkpoint clear: a max-steps ending
|
|
2748
|
+
# surfaces only the LAST observation into history, so the full step trace must
|
|
2749
|
+
# survive on disk for a follow-up "continue" to genuinely resume — clearing it
|
|
2750
|
+
# there would leave the next turn a ~500-char condensed history line instead.
|
|
2751
|
+
_maxsteps_partial: dict = {"hit": False}
|
|
2752
|
+
|
|
2538
2753
|
# Deterministic loop-breaker. Weak models often repeat the SAME search/fetch over and
|
|
2539
2754
|
# over (hoping for a "more complete" result) and never converge, burning the whole step
|
|
2540
2755
|
# budget. We remember each (tool, arg) signature; on a repeat we DON'T re-run it —
|
|
@@ -2908,6 +3123,11 @@ def run_agent_chat(cfg):
|
|
|
2908
3123
|
_last_obs["text"] = out
|
|
2909
3124
|
return out
|
|
2910
3125
|
|
|
3126
|
+
# Growing record of this turn's steps, persisted to disk after each one via
|
|
3127
|
+
# _write_turn_checkpoint (inside step_callback below) — see _load_and_clear_turn_
|
|
3128
|
+
# checkpoint above for how a crashed/interrupted turn gets recovered on the next run.
|
|
3129
|
+
_turn_steps: list = []
|
|
3130
|
+
|
|
2911
3131
|
def step_callback(step_log):
|
|
2912
3132
|
step_num = getattr(step_log, "step_number", "?")
|
|
2913
3133
|
|
|
@@ -2929,6 +3149,22 @@ def run_agent_chat(cfg):
|
|
|
2929
3149
|
text = _summarise_step(step_log)
|
|
2930
3150
|
except Exception as e: # noqa: BLE001
|
|
2931
3151
|
text = f"Step: {e}"
|
|
3152
|
+
# Checkpoint every real step to disk so an interrupted turn (crash/connection
|
|
3153
|
+
# drop) can be recovered on the next run in this workspace. Deliberately uses
|
|
3154
|
+
# the FULL model_output + FULL _last_obs here, NOT the one-line `text` summary
|
|
3155
|
+
# above — `text` is clipped to a single first line for the live terminal display
|
|
3156
|
+
# and would lose most of what the step actually found (e.g. a grep_repo hit list
|
|
3157
|
+
# is up to 100 lines; `_summarise_step` keeps only the "N hits" header line).
|
|
3158
|
+
# `_last_obs["text"]` already holds the CURRENT step's full tool output — every
|
|
3159
|
+
# workspace/RAG tool sets it on every return path (see task #38) specifically so
|
|
3160
|
+
# the max-steps fallback can surface it; reusing it here for the same reason.
|
|
3161
|
+
if _WS_ACTIVE and (model_output or _last_obs.get("text")):
|
|
3162
|
+
_turn_steps.append({
|
|
3163
|
+
"step": step_num,
|
|
3164
|
+
"did": _clip(str(model_output or "").strip(), 600),
|
|
3165
|
+
"found": _clip(str(_last_obs.get("text") or "").strip(), 1500),
|
|
3166
|
+
})
|
|
3167
|
+
_write_turn_checkpoint(_WS_ACTIVE, latest_user_text, _turn_steps)
|
|
2932
3168
|
# Skip emitting if there's nothing beyond the tool name — the tool already
|
|
2933
3169
|
# emitted its own step event with the actual response above.
|
|
2934
3170
|
if not text or text.rstrip().endswith("| →"):
|
|
@@ -3366,6 +3602,7 @@ def run_agent_chat(cfg):
|
|
|
3366
3602
|
_log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
|
|
3367
3603
|
except Exception as e: # noqa: BLE001
|
|
3368
3604
|
_log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
|
|
3605
|
+
_maxsteps_partial["hit"] = True
|
|
3369
3606
|
text = (_last_obs.get("text") or "").strip()
|
|
3370
3607
|
if text:
|
|
3371
3608
|
_log(f"max-steps fallback (last tool obs) → {text[:80]}")
|
|
@@ -3778,11 +4015,17 @@ def run_agent_chat(cfg):
|
|
|
3778
4015
|
new_lines = new_content.splitlines(keepends=True)
|
|
3779
4016
|
if new_lines and not new_lines[-1].endswith("\n"):
|
|
3780
4017
|
new_lines[-1] += "\n"
|
|
4018
|
+
old_lines = lines[s - 1:e]
|
|
3781
4019
|
lines[s - 1:e] = new_lines
|
|
3782
4020
|
with open(rp, "w", encoding="utf-8") as fh:
|
|
3783
4021
|
fh.writelines(lines)
|
|
3784
4022
|
_ws_record_change("edit", rp, backup)
|
|
3785
|
-
_emit({"type": "step", "text":
|
|
4023
|
+
_emit({"type": "step", "text": _render_edit_card(
|
|
4024
|
+
"Update", rp,
|
|
4025
|
+
f"Replaced lines {s}-{e} with {len(new_lines)} line(s)",
|
|
4026
|
+
removed=[(s + i, t) for i, t in enumerate(old_lines)],
|
|
4027
|
+
added=[(s + i, t) for i, t in enumerate(new_lines)],
|
|
4028
|
+
)})
|
|
3786
4029
|
shown = "".join(f"{i}: {lines[i - 1]}" for i in
|
|
3787
4030
|
range(max(1, s - 2), min(len(lines), s + len(new_lines) + 1) + 1))
|
|
3788
4031
|
out = (f"Replaced lines {s}-{e} of {rp} with {len(new_lines)} line(s). "
|
|
@@ -3826,7 +4069,15 @@ def run_agent_chat(cfg):
|
|
|
3826
4069
|
with open(rp, "w", encoding="utf-8") as fh:
|
|
3827
4070
|
fh.write(text.replace(old_string, new_string, 1))
|
|
3828
4071
|
_ws_record_change("edit", rp, backup)
|
|
3829
|
-
|
|
4072
|
+
start_no = text[:text.index(old_string)].count("\n") + 1
|
|
4073
|
+
old_ls = old_string.splitlines() or [""]
|
|
4074
|
+
new_ls = new_string.splitlines() or [""]
|
|
4075
|
+
_emit({"type": "step", "text": _render_edit_card(
|
|
4076
|
+
"Update", rp,
|
|
4077
|
+
f"Replaced {len(old_ls)} line(s) at line {start_no}",
|
|
4078
|
+
removed=[(start_no + i, t) for i, t in enumerate(old_ls)],
|
|
4079
|
+
added=[(start_no + i, t) for i, t in enumerate(new_ls)],
|
|
4080
|
+
)})
|
|
3830
4081
|
out = f"Replaced 1 occurrence in {rp}. Backup saved."
|
|
3831
4082
|
_last_obs["text"] = out
|
|
3832
4083
|
return out
|
|
@@ -3854,7 +4105,12 @@ def run_agent_chat(cfg):
|
|
|
3854
4105
|
with open(rp, "w", encoding="utf-8") as fh:
|
|
3855
4106
|
fh.write(content)
|
|
3856
4107
|
_ws_record_change("create", rp, None)
|
|
3857
|
-
|
|
4108
|
+
content_ls = content.splitlines() or [""]
|
|
4109
|
+
_emit({"type": "step", "text": _render_edit_card(
|
|
4110
|
+
"Create", rp,
|
|
4111
|
+
f"Added {len(content_ls)} line(s)",
|
|
4112
|
+
added=[(i + 1, t) for i, t in enumerate(content_ls)],
|
|
4113
|
+
)})
|
|
3858
4114
|
out = f"Created {rp} ({len(content)} chars)."
|
|
3859
4115
|
_last_obs["text"] = out
|
|
3860
4116
|
return out
|
|
@@ -3863,6 +4119,39 @@ def run_agent_chat(cfg):
|
|
|
3863
4119
|
_last_obs["text"] = msg
|
|
3864
4120
|
return msg
|
|
3865
4121
|
|
|
4122
|
+
@tool
|
|
4123
|
+
def create_folder(path: str) -> str:
|
|
4124
|
+
"""Create a folder (and any missing parent folders) inside a registered workspace. Use this to ACTUALLY create a folder/directory the user asked for — never answer with mkdir instructions.
|
|
4125
|
+
|
|
4126
|
+
Args:
|
|
4127
|
+
path: new folder path inside a workspace (a relative path resolves against the workspace root).
|
|
4128
|
+
"""
|
|
4129
|
+
try:
|
|
4130
|
+
rp = _ws_write_allowed((path or "").strip())
|
|
4131
|
+
import os
|
|
4132
|
+
if os.path.isdir(rp):
|
|
4133
|
+
msg = f"Folder already exists: {rp}"
|
|
4134
|
+
_last_obs["text"] = msg
|
|
4135
|
+
return msg
|
|
4136
|
+
if os.path.exists(rp):
|
|
4137
|
+
msg = f"Error: {path} already exists and is a file, not a folder."
|
|
4138
|
+
_last_obs["text"] = msg
|
|
4139
|
+
return msg
|
|
4140
|
+
os.makedirs(rp)
|
|
4141
|
+
# "create_dir" (not "create") — revert removes it with rmdir, which only
|
|
4142
|
+
# deletes EMPTY dirs, so a revert can never destroy content the user
|
|
4143
|
+
# added inside it afterwards. unlink (the "create" revert) fails on dirs.
|
|
4144
|
+
_ws_record_change("create_dir", rp, None)
|
|
4145
|
+
_emit({"type": "step", "text": _render_edit_card(
|
|
4146
|
+
"Create", rp, "New folder")})
|
|
4147
|
+
out = f"Created folder {rp}."
|
|
4148
|
+
_last_obs["text"] = out
|
|
4149
|
+
return out
|
|
4150
|
+
except Exception as ex: # noqa: BLE001
|
|
4151
|
+
msg = f"Error: create_folder failed: {type(ex).__name__}: {ex}"
|
|
4152
|
+
_last_obs["text"] = msg
|
|
4153
|
+
return msg
|
|
4154
|
+
|
|
3866
4155
|
@tool
|
|
3867
4156
|
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
|
|
3868
4157
|
"""Run a build/test command inside a workspace that has run permission (e.g. 'npm test', 'mvn test', 'pytest'). Use to VERIFY code changes; read the output and fix failures.
|
|
@@ -3929,7 +4218,7 @@ def run_agent_chat(cfg):
|
|
|
3929
4218
|
rag_index, rag_add, rag_search]
|
|
3930
4219
|
if _WS_AVAILABLE:
|
|
3931
4220
|
agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
|
|
3932
|
-
create_file, run_command]
|
|
4221
|
+
create_file, create_folder, run_command]
|
|
3933
4222
|
# list_dir/read_text_file are needed for workspace browsing even when RAG
|
|
3934
4223
|
# (S3 indexing) isn't configured.
|
|
3935
4224
|
if not _RAG_AVAILABLE:
|
|
@@ -3978,6 +4267,16 @@ def run_agent_chat(cfg):
|
|
|
3978
4267
|
final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
|
|
3979
4268
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
3980
4269
|
_emit({"type": "final", "text": final_text})
|
|
4270
|
+
# Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
|
|
4271
|
+
# final_answer, or a deterministic PDF delivery) — that content is now in the
|
|
4272
|
+
# answer pushed to conversation history, so the raw trace is no longer needed.
|
|
4273
|
+
# Kept when the turn ended PARTIALLY, in either way this can happen:
|
|
4274
|
+
# - max-steps fallback (_maxsteps_partial): its answer surfaces only the LAST
|
|
4275
|
+
# observation, so a follow-up "continue" needs the full trace on disk;
|
|
4276
|
+
# - degrade-to-plain-reply (except block below): its note carries only a
|
|
4277
|
+
# top-level file listing, not the step trace — same reasoning, never cleared.
|
|
4278
|
+
if _WS_ACTIVE and not _maxsteps_partial["hit"]:
|
|
4279
|
+
_clear_turn_checkpoint(_WS_ACTIVE)
|
|
3981
4280
|
except Exception as e: # noqa: BLE001
|
|
3982
4281
|
# Config errors (wrong coding-model name/URL) must reach the user VERBATIM —
|
|
3983
4282
|
# they contain the fix (the server's real model list). smolagents wraps model
|
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.197",
|
|
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",
|