@tiens.nguyen/gu-cli 1.0.745 → 1.0.746
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 +60 -11
- package/package.json +2 -1
|
@@ -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
|
@@ -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";
|
|
@@ -4372,15 +4373,16 @@ async function resetGlobalOutputTokens() {
|
|
|
4372
4373
|
}
|
|
4373
4374
|
}
|
|
4374
4375
|
|
|
4375
|
-
async function pushWorkspaceSync(cwd, history, { del = false } = {}) {
|
|
4376
|
+
async function pushWorkspaceSync(cwd, history, { del = false, key = "", name = "" } = {}) {
|
|
4376
4377
|
if (!wsSync.enabled) return;
|
|
4378
|
+
// `key` = a record of its own (an archived conversation, #278): sent whole, not cut to 40.
|
|
4377
4379
|
const body = del
|
|
4378
4380
|
? { workspaceKey: workspaceKeyFor(cwd), delete: true }
|
|
4379
4381
|
: {
|
|
4380
|
-
workspaceKey: workspaceKeyFor(cwd),
|
|
4381
|
-
name: wsSync.name || basename(cwd),
|
|
4382
|
+
workspaceKey: key || workspaceKeyFor(cwd),
|
|
4383
|
+
name: name || wsSync.name || basename(cwd),
|
|
4382
4384
|
path: cwd,
|
|
4383
|
-
history: history.slice(-MAX_PERSISTED_MESSAGES),
|
|
4385
|
+
history: key ? history : history.slice(-MAX_PERSISTED_MESSAGES),
|
|
4384
4386
|
};
|
|
4385
4387
|
try {
|
|
4386
4388
|
await fetch(`${apiBase}/api/worker/workspace-sync`, {
|
|
@@ -4923,17 +4925,55 @@ async function saveSession(cwd, history, { unfinishedTask = sessionUnfinishedTas
|
|
|
4923
4925
|
// Non-fatal — losing session persistence should never crash or block the REPL.
|
|
4924
4926
|
console.error(dim(`(session save failed: ${err.message})`));
|
|
4925
4927
|
}
|
|
4928
|
+
// EVERY message is kept in the folder's archive log (#278) — the session file keeps only
|
|
4929
|
+
// the last MAX_PERSISTED_MESSAGES, and those used to be the only copy.
|
|
4930
|
+
try {
|
|
4931
|
+
archivedUpTo = await appendNew(cwd, history, archivedUpTo);
|
|
4932
|
+
} catch (err) {
|
|
4933
|
+
console.error(dim(`(conversation archive write failed: ${err.message})`));
|
|
4934
|
+
}
|
|
4926
4935
|
// Mirror to the cloud AFTER the local write (fire-and-forget; opt-in via wsSync).
|
|
4927
4936
|
void pushWorkspaceSync(cwd, history);
|
|
4928
4937
|
}
|
|
4929
4938
|
|
|
4930
|
-
|
|
4939
|
+
/** How many of the in-memory history's messages are already in the archive log (#278). */
|
|
4940
|
+
let archivedUpTo = 0;
|
|
4941
|
+
|
|
4942
|
+
/**
|
|
4943
|
+
* /reset — a NEW conversation, the old one KEPT (#278). It used to unlink the whole session file
|
|
4944
|
+
* (the conversation AND this folder's model/key/RAG/test-auto choices) and delete the database
|
|
4945
|
+
* copy. Now: every message is in the archive log (a reset marker is added), the database gets an
|
|
4946
|
+
* "archived" record of its own, and only the conversation in the session file is emptied.
|
|
4947
|
+
* → where the archive is.
|
|
4948
|
+
*/
|
|
4949
|
+
async function startNewConversation(cwd, history) {
|
|
4950
|
+
const n = history.length;
|
|
4951
|
+
const at = new Date().toISOString();
|
|
4931
4952
|
try {
|
|
4932
|
-
await
|
|
4933
|
-
|
|
4934
|
-
|
|
4953
|
+
archivedUpTo = await appendNew(cwd, history, archivedUpTo);
|
|
4954
|
+
await markReset(cwd, n);
|
|
4955
|
+
} catch (err) {
|
|
4956
|
+
console.error(dim(`(conversation archive write failed: ${err.message})`));
|
|
4935
4957
|
}
|
|
4936
|
-
|
|
4958
|
+
if (n) {
|
|
4959
|
+
void pushWorkspaceSync(cwd, history, {
|
|
4960
|
+
key: archivedWorkspaceKey(workspaceKeyFor(cwd), at),
|
|
4961
|
+
name: `${wsSync.name || basename(cwd)} — archived ${at.slice(0, 16).replace("T", " ")}`,
|
|
4962
|
+
});
|
|
4963
|
+
}
|
|
4964
|
+
try {
|
|
4965
|
+
await updateSessionFile(cwd, (raw) => {
|
|
4966
|
+
raw.history = [];
|
|
4967
|
+
raw.unfinishedTask = "";
|
|
4968
|
+
raw.updatedAt = at;
|
|
4969
|
+
});
|
|
4970
|
+
} catch (err) {
|
|
4971
|
+
console.error(dim(`(session save failed: ${err.message})`));
|
|
4972
|
+
}
|
|
4973
|
+
sessionUnfinishedTask = "";
|
|
4974
|
+
void pushWorkspaceSync(cwd, []); // the live record: empty, not deleted
|
|
4975
|
+
archivedUpTo = 0;
|
|
4976
|
+
return archivePathFor(cwd);
|
|
4937
4977
|
}
|
|
4938
4978
|
|
|
4939
4979
|
/**
|
|
@@ -11115,6 +11155,8 @@ async function main() {
|
|
|
11115
11155
|
|
|
11116
11156
|
const cwd = resolve(process.cwd());
|
|
11117
11157
|
const history = await loadSession(cwd);
|
|
11158
|
+
// The folder's archive log (#278): seeded with what it already has, the first time.
|
|
11159
|
+
archivedUpTo = await seedArchive(cwd, history).catch(() => history.length);
|
|
11118
11160
|
// A new gu session (task #270): the agent runs onStart hooks on the first turn it sees this id.
|
|
11119
11161
|
await startHookSession(cwd);
|
|
11120
11162
|
if (history.length > 0) {
|
|
@@ -11475,10 +11517,17 @@ async function main() {
|
|
|
11475
11517
|
continue;
|
|
11476
11518
|
}
|
|
11477
11519
|
if (line === "/reset" || line === "/new") {
|
|
11520
|
+
const kept = history.length;
|
|
11521
|
+
const where = await startNewConversation(cwd, history);
|
|
11478
11522
|
history.length = 0;
|
|
11479
|
-
await clearSession(cwd);
|
|
11480
11523
|
await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
|
|
11481
|
-
console.log(
|
|
11524
|
+
console.log(
|
|
11525
|
+
GUTTER + dim("New conversation started — this folder's settings are unchanged.") + "\n" +
|
|
11526
|
+
GUTTER + dim(kept
|
|
11527
|
+
? `The previous one (${kept} message${kept === 1 ? "" : "s"}) is kept: ${where.replace(homedir(), "~")}` +
|
|
11528
|
+
(wsSync.enabled ? ", and as an archived copy in the database." : ".")
|
|
11529
|
+
: `Every earlier message of this folder is kept: ${where.replace(homedir(), "~")}.`) + "\n"
|
|
11530
|
+
);
|
|
11482
11531
|
continue;
|
|
11483
11532
|
}
|
|
11484
11533
|
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.746",
|
|
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",
|