@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.
@@ -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
+ }
@@ -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
- "Run ONE command per call, and use the cwd argument to choose the directory.")
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
- " run_command LIMITS: there is NO shell — pipes `|`, redirects `>`, `&&` and "
7947
- "backticks do NOT work (one command per call, or python3 for anything that composes); "
7948
- "`sudo`/`su` are blocked; an interactive input() CLI fails with EOFError. A server or "
7949
- "GUI app is left RUNNING — stop_server closes it.\n"
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). Make ONE CHANGE per block (an edit, a file "
8053
- "write or a command): if a line fails, the lines after it never run. Take a separate "
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. Most commands run; no shell (so no pipes/redirects/&&), and sudo/su are blocked.
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
- async function clearSession(cwd) {
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 unlink(sessionFilePath(cwd));
4933
- } catch {
4934
- // Nothing on disk to clear — fine.
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
- void pushWorkspaceSync(cwd, [], { del: true }); // /reset removes the cloud copy too
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(GUTTER + dim("Session cleared — starting fresh.\n"));
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.745",
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",