@tiens.nguyen/gonext-local-worker 1.0.195 → 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.
@@ -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,28 +15,38 @@
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`;
36
43
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
37
44
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
38
45
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
39
- const blue = (s) => `\x1b[34m${s}\x1b[0m`;
46
+ // ANSI 90 ("bright black") a reliable neutral grey across terminal themes, unlike
47
+ // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
48
+ const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
49
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
40
50
  const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
41
51
 
42
52
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
@@ -45,7 +55,7 @@ const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
45
55
  const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
46
56
  const isFenceLine = (line) => line.trim() === "~~~";
47
57
  // The model's raw Python code blob is delimited by <code>/</code> lines. We don't print
48
- // the tag lines themselves, but the code IN BETWEEN prints in a distinct color (blue)
58
+ // the tag lines themselves, but the code IN BETWEEN prints in a distinct grey (codeColor)
49
59
  // instead of dim prose, so it visually separates from the "Thought:" reasoning.
50
60
  const isCodeOpenLine = (line) => line.trim() === "<code>";
51
61
  const isCodeCloseLine = (line) => line.trim() === "</code>";
@@ -63,6 +73,19 @@ const isRoutingLine = (line) =>
63
73
  /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
64
74
  line.trim()
65
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);
66
89
 
67
90
  // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
68
91
  // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
@@ -88,9 +111,11 @@ if (argv.includes("--help") || argv.includes("-h")) {
88
111
  " -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
89
112
  " -h, --help this help\n\n" +
90
113
  "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
91
- "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" +
92
115
  "Questions are enqueued like web questions and executed by the RUNNING\n" +
93
- "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."
94
119
  );
95
120
  process.exit(0);
96
121
  }
@@ -127,16 +152,26 @@ if (!workerKey || !apiBase) {
127
152
  // readline stops managing the line/cursor itself; the OS's own canonical-mode terminal
128
153
  // still echoes keystrokes normally, and 'line' events fire exactly the same.
129
154
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
155
+ // Side effect of terminal:false above: readline no longer intercepts arrow keys for
156
+ // command-history recall, so pressing ↑/↓/→/← (or Home/End/Delete) sends the RAW escape
157
+ // bytes straight through as literal input — the OS's own canonical line discipline
158
+ // doesn't understand them either, so they get echoed and end up IN the submitted line
159
+ // once Enter is pressed (seen live: a task sent to the model started with a literal
160
+ // "\x1b[A" from an accidental up-arrow press). Strip CSI escape sequences (ESC '[' …
161
+ // final-byte) from every submitted line so stray arrow-key presses can't corrupt task
162
+ // text sent to the model.
163
+ const stripAnsiEscapes = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
130
164
  const _lineQueue = [];
131
165
  let _lineWaiter = null;
132
166
  let _stdinClosed = false;
133
167
  rl.on("line", (l) => {
168
+ const clean = stripAnsiEscapes(l);
134
169
  if (_lineWaiter) {
135
170
  const w = _lineWaiter;
136
171
  _lineWaiter = null;
137
- w(l);
172
+ w(clean);
138
173
  } else {
139
- _lineQueue.push(l);
174
+ _lineQueue.push(clean);
140
175
  }
141
176
  });
142
177
  rl.on("close", () => {
@@ -206,6 +241,60 @@ async function ensureWorkspace() {
206
241
  );
207
242
  }
208
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
+
209
298
  // ---------- fetch the ready-to-run agent payload from user settings ----------
210
299
  async function fetchAgentPayload(messages) {
211
300
  const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
@@ -359,9 +448,10 @@ async function runAgentTurn(history) {
359
448
  // Split newly-arrived text into lines: heartbeats + ~~~ fences + the post-tool-call step
360
449
  // summary ("tool(...) | → result", redundant with the daemon's own terminal) are
361
450
  // swallowed; the model's <code>/</code> TAG lines are hidden but the code body between
362
- // them prints in blue (not dim, so it stands out from "Thought:" prose); blank lines
363
- // keep spacing; everything else commits dim to scrollback.
451
+ // them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
452
+ // blank lines keep spacing; everything else commits dim to scrollback.
364
453
  let inCode = false;
454
+ let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
365
455
  let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
366
456
  // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
367
457
  // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
@@ -391,6 +481,35 @@ async function runAgentTurn(history) {
391
481
  lastContentAt = Date.now();
392
482
  continue;
393
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
+ }
394
513
  if (!inCode && isCodeOpenLine(line)) {
395
514
  stampIfThinking(); // hold/refresh the stamp; the code body commits it below
396
515
  inCode = true;
@@ -400,7 +519,7 @@ async function runAgentTurn(history) {
400
519
  if (isCodeCloseLine(line)) { inCode = false; lastContentAt = Date.now(); continue; }
401
520
  if (line.trim() === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
402
521
  onContent();
403
- process.stdout.write(blue(line) + "\n");
522
+ process.stdout.write(codeColor(line) + "\n");
404
523
  lastWasBlank = false;
405
524
  continue;
406
525
  }
@@ -510,7 +629,14 @@ async function main() {
510
629
  }
511
630
  console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
512
631
 
513
- const history = [];
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
+ }
514
640
  // With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
515
641
  // scanning (rl.on("SIGINT", …) would never fire) — it now delivers a real OS SIGINT to
516
642
  // the process instead, which we catch directly here. Same behavior as before.
@@ -538,7 +664,18 @@ async function main() {
538
664
  const line = lineRaw.trim();
539
665
  if (line === "/exit" || line === "/quit") break;
540
666
  if (line === "/help") {
541
- console.log(dim(" /exit — quit · /revert [runId] — undo the agent's file edits (latest run)"));
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"));
542
679
  continue;
543
680
  }
544
681
  if (line.startsWith("/revert")) {
@@ -559,6 +696,10 @@ async function main() {
559
696
  const { text: answer, shown } = await runAgentTurn(history);
560
697
  if (answer) {
561
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);
562
703
  // A plain-reply answer already streamed live in the loop above — printing it
563
704
  // again here would just duplicate it; a blank line is enough for spacing.
564
705
  console.log(shown ? "" : `\n\n${answer}\n`);
@@ -297,6 +297,15 @@ _EMAIL_CONFIRM = re.compile(
297
297
  re.IGNORECASE,
298
298
  )
299
299
 
300
+ # A bare "continue"-style nudge — matched WHOLE-MESSAGE (anchored) so it only fires on a
301
+ # short, unambiguous continuation cue, never a longer sentence that happens to contain
302
+ # one of these words. See _continuation_pending().
303
+ _CONTINUE_CUE = re.compile(
304
+ r"^(please\s+)?(continue|keep\s+going|go\s+on|resume|carry\s+on|try\s+again)"
305
+ r"[\s.!?]*$",
306
+ re.IGNORECASE,
307
+ )
308
+
300
309
 
301
310
  def _email_allowed(addr, allow):
302
311
  """True if addr matches an allow-list entry — an exact address or a bare domain."""
@@ -590,6 +599,29 @@ _AGENT_KEYWORDS = re.compile(
590
599
  re.IGNORECASE,
591
600
  )
592
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
+
593
625
  # Pure conversational openers/closers that never need a tool — a greeting, a thank-you, a
594
626
  # "who are you". Matching the WHOLE message (anchored) means "hi, fetch https://…" won't
595
627
  # match. These skip BOTH the model router call AND the heavy tool preamble → an instant
@@ -1835,11 +1867,137 @@ def _workspace_overview(roots, max_entries: int = 40) -> str:
1835
1867
  return "\n".join(parts)
1836
1868
 
1837
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
+
1838
1988
  def _ws_write_allowed(path: str) -> str:
1839
1989
  """Resolve `path` for WRITING: must be inside a registered workspace and never
1840
1990
  inside its .git/ internals (agents must not corrupt version control)."""
1841
1991
  import os
1842
- rp = os.path.realpath(path)
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))
1843
2001
  w = _ws_for_path(rp)
1844
2002
  if w is None:
1845
2003
  raise ValueError(
@@ -1853,7 +2011,7 @@ def _ws_write_allowed(path: str) -> str:
1853
2011
 
1854
2012
 
1855
2013
  _WS_RUN_ID = None # backup folder for this run, created lazily on first change
1856
- _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}]
1857
2015
 
1858
2016
 
1859
2017
  def _ws_backup_root() -> str:
@@ -2220,6 +2378,45 @@ def run_agent_chat(cfg):
2220
2378
  return bool(_EMAIL_AVAILABLE and _EMAIL_CONFIRM.search(latest_user_text or "")
2221
2379
  and _prior_email_preview())
2222
2380
 
2381
+ def _prior_turn_was_interrupted() -> bool:
2382
+ """True if the immediately preceding assistant turn is one of OUR OWN
2383
+ interrupted-investigation markers (the honest degrade-fallback note, or the
2384
+ max-steps 'ran out of steps' note) — i.e. a genuine tool-use attempt started but
2385
+ didn't finish, as opposed to a normal completed answer."""
2386
+ for m in reversed(messages[:last_user_idx]):
2387
+ if m.get("role") == "assistant":
2388
+ c = m.get("content") or ""
2389
+ return ("ran out of steps before finishing" in c
2390
+ or "couldn't finish the full investigation" in c)
2391
+ return False
2392
+
2393
+ def _continuation_pending() -> bool:
2394
+ """A bare 'continue'/'keep going' after an interrupted investigation must reach
2395
+ the AGENT, not plain chat — plain chat has zero tool access and can never
2396
+ actually continue a stalled grep_repo/read_file_lines investigation, which is
2397
+ exactly the reported bug: 'continue' silently degraded to a tool-less reply that
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."""
2406
+ return bool(_CONTINUE_CUE.match((latest_user_text or "").strip())
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))
2419
+
2223
2420
  # Route: does this task need HTTP tool use? A trivial greeting/thank-you/"who are you"
2224
2421
  # is caught LOCALLY (no model classifier call, no tool preamble) → straight to a fast
2225
2422
  # small-prompt reply. Otherwise ask the model classifier.
@@ -2236,6 +2433,20 @@ def run_agent_chat(cfg):
2236
2433
  _log("router → YES (email confirm pending)")
2237
2434
  _emit({"type": "step", "text": "→ Agent mode (email confirm)"})
2238
2435
  needs_agent = True
2436
+ # A bare "continue" after WE reported an interrupted investigation must reach the
2437
+ # agent too — plain chat has zero tool access, so it can only apologize again, never
2438
+ # actually resume the grep_repo/read_file_lines work (the reported bug).
2439
+ if not needs_agent and _continuation_pending():
2440
+ _log("router → YES (continuation of an interrupted investigation)")
2441
+ _emit({"type": "step", "text": "→ Agent mode (continuing)"})
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
2239
2450
 
2240
2451
  if not needs_agent:
2241
2452
  _log("router: plain chat (no HTTP needed)")
@@ -2479,6 +2690,42 @@ def run_agent_chat(cfg):
2479
2690
  "create_pdf / final_answer.\n"
2480
2691
  "- Do NOT put final_answer outside the code block.\n\n"
2481
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
+
2482
2729
  # Lead with the TASK so the weak model anchors on what's actually being asked —
2483
2730
  # not on the tool reference below. (Previously the hint led with the date, and the
2484
2731
  # 3B model treated every message as a date question.)
@@ -2496,6 +2743,13 @@ def run_agent_chat(cfg):
2496
2743
  # tool returned (no extra model call) if the loop ends without final_answer.
2497
2744
  _last_obs: dict = {"text": ""}
2498
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
+
2499
2753
  # Deterministic loop-breaker. Weak models often repeat the SAME search/fetch over and
2500
2754
  # over (hoping for a "more complete" result) and never converge, burning the whole step
2501
2755
  # budget. We remember each (tool, arg) signature; on a repeat we DON'T re-run it —
@@ -2869,6 +3123,11 @@ def run_agent_chat(cfg):
2869
3123
  _last_obs["text"] = out
2870
3124
  return out
2871
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
+
2872
3131
  def step_callback(step_log):
2873
3132
  step_num = getattr(step_log, "step_number", "?")
2874
3133
 
@@ -2890,6 +3149,22 @@ def run_agent_chat(cfg):
2890
3149
  text = _summarise_step(step_log)
2891
3150
  except Exception as e: # noqa: BLE001
2892
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)
2893
3168
  # Skip emitting if there's nothing beyond the tool name — the tool already
2894
3169
  # emitted its own step event with the actual response above.
2895
3170
  if not text or text.rstrip().endswith("| →"):
@@ -3327,6 +3602,7 @@ def run_agent_chat(cfg):
3327
3602
  _log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
3328
3603
  except Exception as e: # noqa: BLE001
3329
3604
  _log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
3605
+ _maxsteps_partial["hit"] = True
3330
3606
  text = (_last_obs.get("text") or "").strip()
3331
3607
  if text:
3332
3608
  _log(f"max-steps fallback (last tool obs) → {text[:80]}")
@@ -3739,11 +4015,17 @@ def run_agent_chat(cfg):
3739
4015
  new_lines = new_content.splitlines(keepends=True)
3740
4016
  if new_lines and not new_lines[-1].endswith("\n"):
3741
4017
  new_lines[-1] += "\n"
4018
+ old_lines = lines[s - 1:e]
3742
4019
  lines[s - 1:e] = new_lines
3743
4020
  with open(rp, "w", encoding="utf-8") as fh:
3744
4021
  fh.writelines(lines)
3745
4022
  _ws_record_change("edit", rp, backup)
3746
- _emit({"type": "step", "text": f"Edited {os.path.basename(rp)} lines {s}-{e}"})
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
+ )})
3747
4029
  shown = "".join(f"{i}: {lines[i - 1]}" for i in
3748
4030
  range(max(1, s - 2), min(len(lines), s + len(new_lines) + 1) + 1))
3749
4031
  out = (f"Replaced lines {s}-{e} of {rp} with {len(new_lines)} line(s). "
@@ -3787,7 +4069,15 @@ def run_agent_chat(cfg):
3787
4069
  with open(rp, "w", encoding="utf-8") as fh:
3788
4070
  fh.write(text.replace(old_string, new_string, 1))
3789
4071
  _ws_record_change("edit", rp, backup)
3790
- _emit({"type": "step", "text": f"Edited {os.path.basename(rp)}"})
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
+ )})
3791
4081
  out = f"Replaced 1 occurrence in {rp}. Backup saved."
3792
4082
  _last_obs["text"] = out
3793
4083
  return out
@@ -3815,7 +4105,12 @@ def run_agent_chat(cfg):
3815
4105
  with open(rp, "w", encoding="utf-8") as fh:
3816
4106
  fh.write(content)
3817
4107
  _ws_record_change("create", rp, None)
3818
- _emit({"type": "step", "text": f"Created {os.path.basename(rp)}"})
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
+ )})
3819
4114
  out = f"Created {rp} ({len(content)} chars)."
3820
4115
  _last_obs["text"] = out
3821
4116
  return out
@@ -3824,6 +4119,39 @@ def run_agent_chat(cfg):
3824
4119
  _last_obs["text"] = msg
3825
4120
  return msg
3826
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
+
3827
4155
  @tool
3828
4156
  def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
3829
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.
@@ -3890,7 +4218,7 @@ def run_agent_chat(cfg):
3890
4218
  rag_index, rag_add, rag_search]
3891
4219
  if _WS_AVAILABLE:
3892
4220
  agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
3893
- create_file, run_command]
4221
+ create_file, create_folder, run_command]
3894
4222
  # list_dir/read_text_file are needed for workspace browsing even when RAG
3895
4223
  # (S3 indexing) isn't configured.
3896
4224
  if not _RAG_AVAILABLE:
@@ -3939,6 +4267,16 @@ def run_agent_chat(cfg):
3939
4267
  final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
3940
4268
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
3941
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)
3942
4280
  except Exception as e: # noqa: BLE001
3943
4281
  # Config errors (wrong coding-model name/URL) must reach the user VERBATIM —
3944
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.195",
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",