@tiens.nguyen/gu-cli 1.0.745 → 1.0.747
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/conversation-archive.mjs +100 -0
- package/gonext_agent_chat.py +178 -9
- package/gu-repl.mjs +70 -17
- package/package.json +2 -1
- package/terminal-layout.mjs +2 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY MESSAGE OF A FOLDER'S CONVERSATION, KEPT (task #278).
|
|
3
|
+
*
|
|
4
|
+
* ~/.gonext/sessions/archive/<folder hash>.jsonl — append-only, one JSON object per line
|
|
5
|
+
*
|
|
6
|
+
* The session file keeps only the last 40 messages (what the model is sent), and /reset used to
|
|
7
|
+
* delete even those — with the folder's settings — and delete the database copy too: no archive,
|
|
8
|
+
* no undo. The user avoided /reset because of it ("i still need to keep the history").
|
|
9
|
+
*
|
|
10
|
+
* So the archive is a LOG: each message is appended once, the first time it is saved; a /reset
|
|
11
|
+
* appends a marker and the conversation starts over. Nothing is ever rewritten, so a crash
|
|
12
|
+
* mid-write can cost at most the line being written. The model's context is unchanged — this is
|
|
13
|
+
* for the user.
|
|
14
|
+
*
|
|
15
|
+
* {"type":"message","at":"…","role":"user","content":"…"}
|
|
16
|
+
* {"type":"reset","at":"…","messages":12}
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export const archiveDir = (home = homedir()) => join(home, ".gonext", "sessions", "archive");
|
|
24
|
+
|
|
25
|
+
/** The same folder hash the session file uses (gu-repl.mjs sessionFilePath). */
|
|
26
|
+
export function archivePathFor(cwd, home = homedir()) {
|
|
27
|
+
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
|
|
28
|
+
return join(archiveDir(home), `${hash}.jsonl`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function exists(p) {
|
|
32
|
+
try {
|
|
33
|
+
await stat(p);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function appendLines(path, objs) {
|
|
41
|
+
if (!objs.length) return;
|
|
42
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
43
|
+
await appendFile(path, objs.map((o) => JSON.stringify(o)).join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const asLine = (m, at) => ({ type: "message", at, role: String(m?.role ?? ""), content: String(m?.content ?? "") });
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A folder opened for the first time since the archive existed: copy in the history it already
|
|
50
|
+
* has, so nothing from before this feature is lost either. → how many messages are now archived
|
|
51
|
+
* (the caller's starting point for `appendNew`). An archive that already exists is left alone.
|
|
52
|
+
*/
|
|
53
|
+
export async function seedArchive(cwd, history, { home = homedir(), now = () => new Date().toISOString() } = {}) {
|
|
54
|
+
const path = archivePathFor(cwd, home);
|
|
55
|
+
const list = Array.isArray(history) ? history : [];
|
|
56
|
+
if (!(await exists(path))) await appendLines(path, list.map((m) => asLine(m, now())));
|
|
57
|
+
return list.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append the messages after `archivedUpTo` (an index into `history`). → the new index. A history
|
|
62
|
+
* that SHRANK (a question dropped by #247's rules, a trim) just moves the index back — what is
|
|
63
|
+
* archived stays archived.
|
|
64
|
+
*/
|
|
65
|
+
export async function appendNew(cwd, history, archivedUpTo, { home = homedir(), now = () => new Date().toISOString() } = {}) {
|
|
66
|
+
const list = Array.isArray(history) ? history : [];
|
|
67
|
+
if (list.length <= archivedUpTo) return list.length;
|
|
68
|
+
await appendLines(archivePathFor(cwd, home), list.slice(archivedUpTo).map((m) => asLine(m, now())));
|
|
69
|
+
return list.length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Mark where a /reset started a new conversation. */
|
|
73
|
+
export async function markReset(cwd, messages, { home = homedir(), now = () => new Date().toISOString() } = {}) {
|
|
74
|
+
await appendLines(archivePathFor(cwd, home), [{ type: "reset", at: now(), messages }]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The whole log, parsed; a damaged line is skipped, never fatal. */
|
|
78
|
+
export async function readArchive(cwd, { home = homedir() } = {}) {
|
|
79
|
+
let text = "";
|
|
80
|
+
try {
|
|
81
|
+
text = await readFile(archivePathFor(cwd, home), "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const line of text.split("\n")) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
try {
|
|
89
|
+
out.push(JSON.parse(line));
|
|
90
|
+
} catch {
|
|
91
|
+
/* a half-written last line after a crash — skip it */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The database copy of an archived conversation: its own record, beside the live one. */
|
|
98
|
+
export function archivedWorkspaceKey(liveKey, at) {
|
|
99
|
+
return `${liveKey}:archived:${at}`;
|
|
100
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -6115,6 +6115,59 @@ def _shell_op_tokens(command):
|
|
|
6115
6115
|
return [t for t in toks if t in _SHELL_OP_TOKENS or _REDIRECT_TOKEN_RE.match(t)]
|
|
6116
6116
|
|
|
6117
6117
|
|
|
6118
|
+
# How far explore() (task #276) may search before it must answer. Its own steps are cheap —
|
|
6119
|
+
# a fresh, small context — but it is still a model call each.
|
|
6120
|
+
_EXPLORE_MAX_STEPS = 12
|
|
6121
|
+
|
|
6122
|
+
|
|
6123
|
+
def _and_chain_parts(command):
|
|
6124
|
+
"""`A && B && C` → ["A", "B", "C"]; anything else → None. (task #274)
|
|
6125
|
+
|
|
6126
|
+
SAVING STEPS WITHOUT A SHELL. A build → install → launch sequence was one run_command call
|
|
6127
|
+
per command, and every call is a full model step that resends the whole prompt — a 29-step
|
|
6128
|
+
Android turn cost ~600k input tokens. Claude Code runs such a line in one Bash call; gu
|
|
6129
|
+
keeps its no-shell rule (an injected `; curl … | sh` stays impossible by construction) and
|
|
6130
|
+
instead runs an `&&` chain ITSELF: each part through the normal policy, in order, stopping
|
|
6131
|
+
at the first failure — which is exactly what `&&` means.
|
|
6132
|
+
|
|
6133
|
+
Only unquoted `&&` separates; quotes and backslashes are respected, so `git commit -m 'a &&
|
|
6134
|
+
b'` is one command. ANY other shell syntax (|, ;, ||, &, <, >, `, $( ) → None, so the caller's
|
|
6135
|
+
existing refusal explains it. An empty part (`a && && b`, `a &&`) → None too.
|
|
6136
|
+
"""
|
|
6137
|
+
s = command or ""
|
|
6138
|
+
parts, cur, q, saw, i = [], [], None, False, 0
|
|
6139
|
+
while i < len(s):
|
|
6140
|
+
c = s[i]
|
|
6141
|
+
if q:
|
|
6142
|
+
cur.append(c)
|
|
6143
|
+
if c == q:
|
|
6144
|
+
q = None
|
|
6145
|
+
elif c == "\\" and q == '"' and i + 1 < len(s):
|
|
6146
|
+
cur.append(s[i + 1])
|
|
6147
|
+
i += 1
|
|
6148
|
+
elif c in "'\"":
|
|
6149
|
+
q = c
|
|
6150
|
+
cur.append(c)
|
|
6151
|
+
elif c == "\\" and i + 1 < len(s):
|
|
6152
|
+
cur.append(c)
|
|
6153
|
+
cur.append(s[i + 1])
|
|
6154
|
+
i += 1
|
|
6155
|
+
elif s.startswith("&&", i):
|
|
6156
|
+
parts.append("".join(cur).strip())
|
|
6157
|
+
cur, saw = [], True
|
|
6158
|
+
i += 2
|
|
6159
|
+
continue
|
|
6160
|
+
elif c in "|;&<>`" or s.startswith("$(", i):
|
|
6161
|
+
return None
|
|
6162
|
+
else:
|
|
6163
|
+
cur.append(c)
|
|
6164
|
+
i += 1
|
|
6165
|
+
parts.append("".join(cur).strip())
|
|
6166
|
+
if q or not saw or any(not p for p in parts):
|
|
6167
|
+
return None
|
|
6168
|
+
return parts
|
|
6169
|
+
|
|
6170
|
+
|
|
6118
6171
|
def _shell_syntax_error(command, argv):
|
|
6119
6172
|
"""Why this command cannot do what it says, or None. (task #204)
|
|
6120
6173
|
|
|
@@ -6166,10 +6219,13 @@ def _shell_syntax_error(command, argv):
|
|
|
6166
6219
|
|
|
6167
6220
|
found = _shell_op_tokens(command)
|
|
6168
6221
|
ops = [t for t in found if t in _SHELL_OP_TOKENS]
|
|
6222
|
+
# Name what is actually unsupported: in `a && b | c` the && is fine, the | is not (#274).
|
|
6223
|
+
ops = [o for o in ops if o != "&&"] or ops
|
|
6169
6224
|
if ops:
|
|
6170
6225
|
return ("Error: that command did NOT fully run. " + _no_shell + "'" + ops[0]
|
|
6171
6226
|
+ "' was passed as a literal argument and everything after it never executed. "
|
|
6172
|
-
"
|
|
6227
|
+
"Only && chains work (they run in order and stop at the first failure); for anything "
|
|
6228
|
+
"else run one command per call, and use the cwd argument to choose the directory.")
|
|
6173
6229
|
|
|
6174
6230
|
redirects = [t for t in found if _REDIRECT_TOKEN_RE.match(t)]
|
|
6175
6231
|
if redirects:
|
|
@@ -7920,7 +7976,7 @@ def run_agent_chat(cfg):
|
|
|
7920
7976
|
# change", the agent saved the task and then went on to do it (scaffold rule 8).
|
|
7921
7977
|
+ "WORK LIKE A SENIOR DEVELOPER — you OWN the task you were GIVEN until it DEMONSTRABLY works:\n"
|
|
7922
7978
|
" * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
|
|
7923
|
-
"state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
|
|
7979
|
+
"state-changing step (start/build/install/edit), VERIFY the intended EFFECT — in the same call when you can (`build && test`) — "
|
|
7924
7980
|
"(server responds, artifact exists, test green, file contains the change) before "
|
|
7925
7981
|
"moving on or finishing.\n"
|
|
7926
7982
|
" * On failure: read the error, fix the ROOT CAUSE (edit the code/config, "
|
|
@@ -7943,10 +7999,18 @@ def run_agent_chat(cfg):
|
|
|
7943
7999
|
"truncated and slow every later step) → edit_lines to change it → run_command to "
|
|
7944
8000
|
"VERIFY. edit_file only for a byte-exact unique string; create_file(overwrite=True) "
|
|
7945
8001
|
"replaces a whole file in ONE call (no read+edit just to rewrite it).\n"
|
|
7946
|
-
"
|
|
7947
|
-
"
|
|
7948
|
-
"
|
|
7949
|
-
"
|
|
8002
|
+
" EXPLORE FIRST for a broad question about the code (where is X handled, how does Y "
|
|
8003
|
+
"work, what would a change to Z touch): explore(question) searches in a SEPARATE "
|
|
8004
|
+
"context and returns a short summary with path:line references — its greps and reads "
|
|
8005
|
+
"do not pile up here. Use grep_repo/read_file_lines directly when you already know "
|
|
8006
|
+
"the file or the exact string.\n"
|
|
8007
|
+
" run_command CHAINS: put dependent commands in ONE call with && — e.g. "
|
|
8008
|
+
"run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
|
|
8009
|
+
"— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
|
|
8010
|
+
"chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
|
|
8011
|
+
"NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
|
|
8012
|
+
"interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
|
|
8013
|
+
"stop_server closes it.\n"
|
|
7950
8014
|
" DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
|
|
7951
8015
|
"hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
|
|
7952
8016
|
"can, else writes deploy.sh and returns the exact commands). KEY auth ONLY "
|
|
@@ -8049,8 +8113,10 @@ def run_agent_chat(cfg):
|
|
|
8049
8113
|
f"Solve the TASK step by step (up to {max_steps} steps). At EACH step write a "
|
|
8050
8114
|
"brief Thought, then ONE code block. BATCH INDEPENDENT LOOKUPS into that block — "
|
|
8051
8115
|
"several reads, greps or listings whose arguments you already know, printing each "
|
|
8052
|
-
"result under a header (up to about 8).
|
|
8053
|
-
"
|
|
8116
|
+
"result under a header (up to about 8). A CHANGE AND ITS CHECK MAY SHARE A BLOCK "
|
|
8117
|
+
"(task #275): e.g. edit_lines(...) then run_command(\"cd app && npm run build && npm test\") "
|
|
8118
|
+
"— print every result and read them all, because a tool that fails RETURNS an error "
|
|
8119
|
+
"instead of stopping the block. Keep UNRELATED changes in separate blocks. Take a separate "
|
|
8054
8120
|
"step only when a call depends on a result you have not seen yet (e.g. web_search to "
|
|
8055
8121
|
"find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
|
|
8056
8122
|
"When you have enough to answer, call final_answer(<your answer>) in a code block. "
|
|
@@ -11705,7 +11771,7 @@ def run_agent_chat(cfg):
|
|
|
11705
11771
|
"""Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test', 'python3 app.py'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING and its URL is reported. If it is a DESKTOP/GUI app (opens a window — tkinter, pygame, PyQt…), it is LAUNCHED and left running: its window appears on the user's Mac; tell them it's open. Both are stoppable via stop_server. Also allows DEPLOY commands (ssh/scp/rsync) — use non-interactive KEY auth (-o BatchMode=yes); never put a password in the command (there is no stdin). Note: an interactive CLI that reads stdin (input()) cannot be driven here — it will fail with EOFError; make it non-interactive or give the user the command to run themselves.
|
|
11706
11772
|
|
|
11707
11773
|
Args:
|
|
11708
|
-
command: the command line.
|
|
11774
|
+
command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
|
|
11709
11775
|
workdir: directory to run in. Empty = first registered workspace.
|
|
11710
11776
|
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
11711
11777
|
"""
|
|
@@ -11754,6 +11820,55 @@ def run_agent_chat(cfg):
|
|
|
11754
11820
|
msg = "Error: empty command."
|
|
11755
11821
|
_last_obs["text"] = msg
|
|
11756
11822
|
return msg
|
|
11823
|
+
# AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
|
|
11824
|
+
# allowlist, risk gate, approval, server detection, all of it — in order, and
|
|
11825
|
+
# the first failure stops the rest. `cd DIR` sets the folder for what follows.
|
|
11826
|
+
_chain = _and_chain_parts(command)
|
|
11827
|
+
if _chain:
|
|
11828
|
+
_chain_start = _t.time()
|
|
11829
|
+
_cur_wd = wd
|
|
11830
|
+
_outs = []
|
|
11831
|
+
_stopped = ""
|
|
11832
|
+
for _k, _part in enumerate(_chain, 1):
|
|
11833
|
+
_hdr = f"── [{_k}/{len(_chain)}] {_part}"
|
|
11834
|
+
try:
|
|
11835
|
+
_pargv = shlex.split(_part)
|
|
11836
|
+
except ValueError as _e:
|
|
11837
|
+
_outs.append(f"{_hdr}\nError: {_e}")
|
|
11838
|
+
_stopped = _part
|
|
11839
|
+
break
|
|
11840
|
+
if _pargv and _pargv[0] == "cd":
|
|
11841
|
+
_target = _pargv[1] if len(_pargv) == 2 else ""
|
|
11842
|
+
_new = os.path.normpath(os.path.join(_cur_wd, os.path.expanduser(_target))) if _target else ""
|
|
11843
|
+
try:
|
|
11844
|
+
_new = _rag_read_allowed(_new) if _new else ""
|
|
11845
|
+
except Exception: # noqa: BLE001
|
|
11846
|
+
_new = ""
|
|
11847
|
+
if not _new or not os.path.isdir(_new):
|
|
11848
|
+
_outs.append(f"{_hdr}\nError: no such folder: {_target or '(none given)'}")
|
|
11849
|
+
_stopped = _part
|
|
11850
|
+
break
|
|
11851
|
+
_cur_wd = _new
|
|
11852
|
+
_outs.append(f"{_hdr}\nnow in {_cur_wd}")
|
|
11853
|
+
continue
|
|
11854
|
+
# The executor gives a whole code block ~660s; keep the chain inside it.
|
|
11855
|
+
_left = 640 - (_t.time() - _chain_start)
|
|
11856
|
+
if _left < 5:
|
|
11857
|
+
_outs.append(f"{_hdr}\nError: out of time for this chain (~10 min) — run the rest in another call")
|
|
11858
|
+
_stopped = _part
|
|
11859
|
+
break
|
|
11860
|
+
_res = str(run_command(_part, _cur_wd, int(min(timeout_seconds or 180, 600, _left))))
|
|
11861
|
+
_outs.append(f"{_hdr}\n{_res}")
|
|
11862
|
+
if _res.startswith(("FAILED", "Error")):
|
|
11863
|
+
_stopped = _part
|
|
11864
|
+
break
|
|
11865
|
+
_rest = _chain[len(_outs):]
|
|
11866
|
+
_summary = (f"CHAIN STOPPED at [{len(_outs)}/{len(_chain)}] — the parts after it did NOT run"
|
|
11867
|
+
+ (": " + " && ".join(_rest) if _rest else "")
|
|
11868
|
+
if _stopped else f"CHAIN DONE — all {len(_chain)} parts ran")
|
|
11869
|
+
result = _summary + "\n" + "\n".join(_outs)
|
|
11870
|
+
_last_obs["text"] = result
|
|
11871
|
+
return result
|
|
11757
11872
|
# REFUSE SHELL SYNTAX BEFORE SPAWNING (task #204). Without this, `cd X && Y`
|
|
11758
11873
|
# executes the `cd` PROGRAM — which exits 0 with no output — and the step is
|
|
11759
11874
|
# reported as a success that did nothing. Refusing costs one step; the observed
|
|
@@ -12180,6 +12295,60 @@ def run_agent_chat(cfg):
|
|
|
12180
12295
|
# (S3 indexing) isn't configured.
|
|
12181
12296
|
if not _RAG_AVAILABLE:
|
|
12182
12297
|
agent_tools += [list_dir, read_text_file]
|
|
12298
|
+
|
|
12299
|
+
# EXPLORE (task #276) — Claude Code's Explore subagent, for gu. Every grep and read
|
|
12300
|
+
# used to land in THIS loop and be re-sent on every later step. explore() searches
|
|
12301
|
+
# in a SEPARATE agent with a fresh context and hands back only a summary.
|
|
12302
|
+
# READ-ONLY BY CONSTRUCTION: its tool list has no edit, write, command or network
|
|
12303
|
+
# tool, so it cannot change anything whatever the text says.
|
|
12304
|
+
_explore_tools = [symbol_context, grep_repo, read_file_lines, list_dir, read_text_file]
|
|
12305
|
+
if _RAG_AVAILABLE:
|
|
12306
|
+
_explore_tools.append(rag_search_workspace)
|
|
12307
|
+
|
|
12308
|
+
@tool
|
|
12309
|
+
def explore(question: str) -> str:
|
|
12310
|
+
"""Answer a question about the code in a SEPARATE, read-only search — use it for broad questions ("where is X handled?", "how does Y work?", "which files would a change to Z touch?") that would otherwise take several greps and reads here. It returns a short summary with path:line references; its searching does not fill this conversation. It cannot edit or run anything.
|
|
12311
|
+
|
|
12312
|
+
Args:
|
|
12313
|
+
question: what to find out, e.g. "where is the splash screen logo set, and which files would changing it touch?"
|
|
12314
|
+
"""
|
|
12315
|
+
q = (question or "").strip()
|
|
12316
|
+
if not q:
|
|
12317
|
+
return "Error: explore needs a question."
|
|
12318
|
+
_emit({"type": "step", "text": f"Exploring → {_clip(q, 70)}"})
|
|
12319
|
+
sub_kwargs = dict(tools=list(_explore_tools), model=model, max_steps=_EXPLORE_MAX_STEPS)
|
|
12320
|
+
if agent_kwargs.get("prompt_templates"):
|
|
12321
|
+
sub_kwargs["prompt_templates"] = agent_kwargs["prompt_templates"]
|
|
12322
|
+
sub_kwargs["executor_kwargs"] = {"timeout_seconds": 300}
|
|
12323
|
+
sub_kwargs["additional_authorized_imports"] = ["json", "re"]
|
|
12324
|
+
# A PLAIN CodeAgent, not _ToolAgent: the turn's max-steps fallback and completion
|
|
12325
|
+
# gate belong to the main loop and must not fire (or mutate its state) in here.
|
|
12326
|
+
sub = CodeAgent(**sub_kwargs)
|
|
12327
|
+
where = _WS_ACTIVE or _default_ws_root()
|
|
12328
|
+
sub_task = (
|
|
12329
|
+
f"EXPLORE (read-only) the code in {where} and answer:\n{q}\n\n"
|
|
12330
|
+
"Use only the tools you have and BATCH independent lookups into one block. "
|
|
12331
|
+
"Finish with final_answer(<summary>): the answer in a few sentences, then the "
|
|
12332
|
+
"relevant files as `path:start-end — what is there`, one per line. At most "
|
|
12333
|
+
"about 250 words; never paste whole files.")
|
|
12334
|
+
try:
|
|
12335
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
12336
|
+
out = sub.run(sub_task)
|
|
12337
|
+
except Exception as e: # noqa: BLE001
|
|
12338
|
+
msg = f"Error: explore failed: {type(e).__name__}: {_clip(str(e), 200)}"
|
|
12339
|
+
_last_obs["text"] = msg
|
|
12340
|
+
return msg
|
|
12341
|
+
n = sum(1 for s in getattr(sub.memory, "steps", []) if type(s).__name__ == "ActionStep")
|
|
12342
|
+
_emit({"type": "step", "text": f"Explored → {n} step(s), in its own context"})
|
|
12343
|
+
_log(f"explore: {n} sub-step(s), {len(str(out))} chars back")
|
|
12344
|
+
text = str(out).strip()
|
|
12345
|
+
if len(text) > 4000:
|
|
12346
|
+
text = text[:4000] + "\n… [summary cut]"
|
|
12347
|
+
result = f"[explore: {n} step(s) in a separate context]\n{text}"
|
|
12348
|
+
_last_obs["text"] = result
|
|
12349
|
+
return result
|
|
12350
|
+
|
|
12351
|
+
agent_tools.append(explore)
|
|
12183
12352
|
# SKILLS (task #269): only offered when at least one exists, like every tool above.
|
|
12184
12353
|
if _skills:
|
|
12185
12354
|
@tool
|
package/gu-repl.mjs
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* Conversation history is saved to ~/.gonext/sessions/<hash-of-folder-path>.json after
|
|
19
19
|
* every turn and reloaded on the next `gu` in the SAME folder — so closing the
|
|
20
20
|
* terminal and coming back later still has context (e.g. "continue" after an
|
|
21
|
-
* interrupted investigation actually resumes it). /
|
|
21
|
+
* interrupted investigation actually resumes it). /new clears it.
|
|
22
22
|
*
|
|
23
23
|
* Requires the worker daemon to be running (it claims the job).
|
|
24
|
-
* Commands: /exit /model /
|
|
24
|
+
* Commands: /exit /model /new /help Ctrl-C stops following the current run.
|
|
25
25
|
*/
|
|
26
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
27
27
|
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
@@ -122,6 +122,7 @@ import {
|
|
|
122
122
|
} from "./terminal-layout.mjs";
|
|
123
123
|
import { completedFlush } from "./completed-flush.mjs";
|
|
124
124
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
125
|
+
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
125
126
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
126
127
|
import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
127
128
|
import { readHooks } from "./hooks-config.mjs";
|
|
@@ -576,11 +577,12 @@ if (cliVerdict.kind === "run") {
|
|
|
576
577
|
" -v, --verbose show RAG index-file activity in the bottom bar (each shard written)\n" +
|
|
577
578
|
" -h, --help this help\n\n" +
|
|
578
579
|
"Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
|
|
579
|
-
"Inside the REPL: /exit · /model · /
|
|
580
|
+
"Inside the REPL: /exit · /model · /new · /help — Ctrl-C stops following a turn.\n" +
|
|
580
581
|
"Questions are enqueued like web questions and executed by the RUNNING\n" +
|
|
581
582
|
"gu-cli daemon — its terminal shows the full [gu-agent] logs.\n" +
|
|
582
583
|
"Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
|
|
583
|
-
"you run `gu` in the same folder; /
|
|
584
|
+
"you run `gu` in the same folder; /new starts a new conversation and keeps the old one\n" +
|
|
585
|
+
"in ~/.gonext/sessions/archive."
|
|
584
586
|
);
|
|
585
587
|
process.exit(0);
|
|
586
588
|
}
|
|
@@ -813,6 +815,9 @@ const COMMANDS = [
|
|
|
813
815
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
814
816
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
815
817
|
{ name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
|
|
818
|
+
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
819
|
+
// for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
|
|
820
|
+
{ name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
|
|
816
821
|
{ name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
|
|
817
822
|
{ name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
|
|
818
823
|
{ name: "/help", desc: "list these commands" },
|
|
@@ -4372,15 +4377,16 @@ async function resetGlobalOutputTokens() {
|
|
|
4372
4377
|
}
|
|
4373
4378
|
}
|
|
4374
4379
|
|
|
4375
|
-
async function pushWorkspaceSync(cwd, history, { del = false } = {}) {
|
|
4380
|
+
async function pushWorkspaceSync(cwd, history, { del = false, key = "", name = "" } = {}) {
|
|
4376
4381
|
if (!wsSync.enabled) return;
|
|
4382
|
+
// `key` = a record of its own (an archived conversation, #278): sent whole, not cut to 40.
|
|
4377
4383
|
const body = del
|
|
4378
4384
|
? { workspaceKey: workspaceKeyFor(cwd), delete: true }
|
|
4379
4385
|
: {
|
|
4380
|
-
workspaceKey: workspaceKeyFor(cwd),
|
|
4381
|
-
name: wsSync.name || basename(cwd),
|
|
4386
|
+
workspaceKey: key || workspaceKeyFor(cwd),
|
|
4387
|
+
name: name || wsSync.name || basename(cwd),
|
|
4382
4388
|
path: cwd,
|
|
4383
|
-
history: history.slice(-MAX_PERSISTED_MESSAGES),
|
|
4389
|
+
history: key ? history : history.slice(-MAX_PERSISTED_MESSAGES),
|
|
4384
4390
|
};
|
|
4385
4391
|
try {
|
|
4386
4392
|
await fetch(`${apiBase}/api/worker/workspace-sync`, {
|
|
@@ -4923,17 +4929,55 @@ async function saveSession(cwd, history, { unfinishedTask = sessionUnfinishedTas
|
|
|
4923
4929
|
// Non-fatal — losing session persistence should never crash or block the REPL.
|
|
4924
4930
|
console.error(dim(`(session save failed: ${err.message})`));
|
|
4925
4931
|
}
|
|
4932
|
+
// EVERY message is kept in the folder's archive log (#278) — the session file keeps only
|
|
4933
|
+
// the last MAX_PERSISTED_MESSAGES, and those used to be the only copy.
|
|
4934
|
+
try {
|
|
4935
|
+
archivedUpTo = await appendNew(cwd, history, archivedUpTo);
|
|
4936
|
+
} catch (err) {
|
|
4937
|
+
console.error(dim(`(conversation archive write failed: ${err.message})`));
|
|
4938
|
+
}
|
|
4926
4939
|
// Mirror to the cloud AFTER the local write (fire-and-forget; opt-in via wsSync).
|
|
4927
4940
|
void pushWorkspaceSync(cwd, history);
|
|
4928
4941
|
}
|
|
4929
4942
|
|
|
4930
|
-
|
|
4943
|
+
/** How many of the in-memory history's messages are already in the archive log (#278). */
|
|
4944
|
+
let archivedUpTo = 0;
|
|
4945
|
+
|
|
4946
|
+
/**
|
|
4947
|
+
* /reset — a NEW conversation, the old one KEPT (#278). It used to unlink the whole session file
|
|
4948
|
+
* (the conversation AND this folder's model/key/RAG/test-auto choices) and delete the database
|
|
4949
|
+
* copy. Now: every message is in the archive log (a reset marker is added), the database gets an
|
|
4950
|
+
* "archived" record of its own, and only the conversation in the session file is emptied.
|
|
4951
|
+
* → where the archive is.
|
|
4952
|
+
*/
|
|
4953
|
+
async function startNewConversation(cwd, history) {
|
|
4954
|
+
const n = history.length;
|
|
4955
|
+
const at = new Date().toISOString();
|
|
4931
4956
|
try {
|
|
4932
|
-
await
|
|
4933
|
-
|
|
4934
|
-
|
|
4957
|
+
archivedUpTo = await appendNew(cwd, history, archivedUpTo);
|
|
4958
|
+
await markReset(cwd, n);
|
|
4959
|
+
} catch (err) {
|
|
4960
|
+
console.error(dim(`(conversation archive write failed: ${err.message})`));
|
|
4935
4961
|
}
|
|
4936
|
-
|
|
4962
|
+
if (n) {
|
|
4963
|
+
void pushWorkspaceSync(cwd, history, {
|
|
4964
|
+
key: archivedWorkspaceKey(workspaceKeyFor(cwd), at),
|
|
4965
|
+
name: `${wsSync.name || basename(cwd)} — archived ${at.slice(0, 16).replace("T", " ")}`,
|
|
4966
|
+
});
|
|
4967
|
+
}
|
|
4968
|
+
try {
|
|
4969
|
+
await updateSessionFile(cwd, (raw) => {
|
|
4970
|
+
raw.history = [];
|
|
4971
|
+
raw.unfinishedTask = "";
|
|
4972
|
+
raw.updatedAt = at;
|
|
4973
|
+
});
|
|
4974
|
+
} catch (err) {
|
|
4975
|
+
console.error(dim(`(session save failed: ${err.message})`));
|
|
4976
|
+
}
|
|
4977
|
+
sessionUnfinishedTask = "";
|
|
4978
|
+
void pushWorkspaceSync(cwd, []); // the live record: empty, not deleted
|
|
4979
|
+
archivedUpTo = 0;
|
|
4980
|
+
return archivePathFor(cwd);
|
|
4937
4981
|
}
|
|
4938
4982
|
|
|
4939
4983
|
/**
|
|
@@ -11115,12 +11159,14 @@ async function main() {
|
|
|
11115
11159
|
|
|
11116
11160
|
const cwd = resolve(process.cwd());
|
|
11117
11161
|
const history = await loadSession(cwd);
|
|
11162
|
+
// The folder's archive log (#278): seeded with what it already has, the first time.
|
|
11163
|
+
archivedUpTo = await seedArchive(cwd, history).catch(() => history.length);
|
|
11118
11164
|
// A new gu session (task #270): the agent runs onStart hooks on the first turn it sees this id.
|
|
11119
11165
|
await startHookSession(cwd);
|
|
11120
11166
|
if (history.length > 0) {
|
|
11121
11167
|
const turns = history.filter((m) => m.role === "user").length;
|
|
11122
11168
|
console.log(
|
|
11123
|
-
wrapGutter(`Resumed session: ${turns} prior turn(s) from last time in this folder — /
|
|
11169
|
+
wrapGutter(`Resumed session: ${turns} prior turn(s) from last time in this folder — /new starts a new conversation (this one is kept).`, dim) + "\n"
|
|
11124
11170
|
);
|
|
11125
11171
|
}
|
|
11126
11172
|
// Ctrl-C arrives on ONE of two paths depending on the readline mode: with
|
|
@@ -11474,11 +11520,18 @@ async function main() {
|
|
|
11474
11520
|
);
|
|
11475
11521
|
continue;
|
|
11476
11522
|
}
|
|
11477
|
-
if (line === "/reset" || line === "/new") {
|
|
11523
|
+
if (line === "/reset" || line === "/new" || line === "/clear") {
|
|
11524
|
+
const kept = history.length;
|
|
11525
|
+
const where = await startNewConversation(cwd, history);
|
|
11478
11526
|
history.length = 0;
|
|
11479
|
-
await clearSession(cwd);
|
|
11480
11527
|
await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
|
|
11481
|
-
console.log(
|
|
11528
|
+
console.log(
|
|
11529
|
+
GUTTER + dim("New conversation started — this folder's settings are unchanged.") + "\n" +
|
|
11530
|
+
GUTTER + dim(kept
|
|
11531
|
+
? `The previous one (${kept} message${kept === 1 ? "" : "s"}) is kept: ${where.replace(homedir(), "~")}` +
|
|
11532
|
+
(wsSync.enabled ? ", and as an archived copy in the database." : ".")
|
|
11533
|
+
: `Every earlier message of this folder is kept: ${where.replace(homedir(), "~")}.`) + "\n"
|
|
11534
|
+
);
|
|
11482
11535
|
continue;
|
|
11483
11536
|
}
|
|
11484
11537
|
if (line === "/tokens") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.747",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"mcp-command.mjs",
|
|
95
95
|
"hooks-config.mjs",
|
|
96
96
|
"hooks-command.mjs",
|
|
97
|
+
"conversation-archive.mjs",
|
|
97
98
|
"gonext_probe_agent.py",
|
|
98
99
|
"gonext_agent_chat.py",
|
|
99
100
|
"gu_mcp.py",
|
package/terminal-layout.mjs
CHANGED
|
@@ -2077,8 +2077,8 @@ export const HIDDEN_COMMANDS = [
|
|
|
2077
2077
|
"/rag-cloud", // they say — unlisted so /help offers one way into the setting
|
|
2078
2078
|
"/thought", // the reasoning display stays ON; only the toggle is unlisted
|
|
2079
2079
|
"/hover", // in-turn mouse capture — behaviour unchanged, just hidden
|
|
2080
|
-
"/reset",
|
|
2081
|
-
"/
|
|
2080
|
+
"/reset", // the old name of /new (#278) — still works, one name is offered
|
|
2081
|
+
"/clear", // Claude Code's name for it — still works, likewise unlisted
|
|
2082
2082
|
"/reset-output-token-global", // pending a rename to /reset-global-token that also resets
|
|
2083
2083
|
// the INPUT total — which does not exist yet as a counter
|
|
2084
2084
|
];
|