From ef283f6b796f509a2bc1396edf25443d28f3ddd2 Mon Sep 17 00:00:00 2001 From: davefmurray <1911980+davefmurray@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:36:30 -0400 Subject: [PATCH 1/2] feat: persist grep-friendly local thread history --- .changeset/local-thread-history.md | 5 + README.md | 18 +++ src/cli.js | 55 +++++++- src/history.js | 83 ++++++++++++ test/history.test.js | 198 +++++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 .changeset/local-thread-history.md create mode 100644 src/history.js create mode 100644 test/history.test.js diff --git a/.changeset/local-thread-history.md b/.changeset/local-thread-history.md new file mode 100644 index 0000000..af2e324 --- /dev/null +++ b/.changeset/local-thread-history.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Add opt-in local JSONL thread history (`GROK_BOT_HISTORY=on`) with offline `gbot history` search. diff --git a/README.md b/README.md index cf79c7c..6f688bc 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,24 @@ you need), then read the reply with `gbot_thread`. Do not block a turn waiting for it. ``` +## Local history + +Recording is **opt-in**. Set `GROK_BOT_HISTORY=on` (or `true`/`1`) to append successful +`send`/`thread`/`chat` observations as plaintext JSONL at +`~/.grok-bot-cli/history.jsonl`. Without that env, nothing is written. + +```bash +export GROK_BOT_HISTORY=on +gbot send Researcher "Investigate the startup timeout" +gbot thread Researcher +gbot history Researcher --search timeout +gbot history --path +``` + +`history` works offline. Use `--history-dir` / `GROK_BOT_HISTORY_DIR` to relocate, +`--no-history` to skip one command. New dirs are `0700`, files `0600`. Conversation +text is recorded as you typed it; gateway credentials and raw response metadata are not. + ## License MIT diff --git a/src/cli.js b/src/cli.js index 9cd7f18..f79bd0c 100755 --- a/src/cli.js +++ b/src/cli.js @@ -4,6 +4,7 @@ import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; import { entryText, transcriptEntries } from "./transcript.js"; +import { historyPath, readHistory, saveHistory } from "./history.js"; import { redactSecrets } from "./url-policy.js"; import { codexStatus, listCodexThreads, sendToCodexThread } from "./codex-bridge.js"; @@ -50,6 +51,8 @@ function usage() { " send ", " thread [--limit N] [--root MESSAGE_ID] [--full]", " chat alias for thread", + " history [bot-or-group] [--search TEXT] [--limit N] (offline)", + " history --path print the local JSONL file path", " codex status", " codex list-threads [--limit N]", " codex send ", @@ -62,6 +65,9 @@ function usage() { "Auth: GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN, or the Grok Bot app session, or CURSOR_ACCESS_TOKEN", "File fallback: GROK_BOT_AGENTS_DIR", "Codex: talks to the local app-server daemon socket under CODEX_HOME (default ~/.codex)", + "History: opt-in plaintext JSONL at ~/.grok-bot-cli/history.jsonl", + " GROK_BOT_HISTORY=on to record; --history-dir / GROK_BOT_HISTORY_DIR to relocate", + " --no-history to skip one command", ].join("\n"); } @@ -105,11 +111,20 @@ function takeRepeating(args, name) { return out; } +function hasFlag(args, name) { + const i = args.indexOf(name); + if (i === -1) return false; + args.splice(i, 1); + return true; +} + /** Peel global CLI options only from the leading argv (before the command). */ function takeLeadingGlobals(args) { let json = false; let gateway = false; let files = false; + let noHistory = false; + let historyDir; let dir; while (args.length) { const a = args[0]; @@ -132,6 +147,18 @@ function takeLeadingGlobals(args) { args.shift(); continue; } + if (a === "--no-history") { + noHistory = true; + args.shift(); + continue; + } + if (a === "--history-dir") { + args.shift(); + const value = args.shift(); + if (value == null || value.startsWith("-")) throw new StoreError("--history-dir needs a value"); + historyDir = value; + continue; + } if (a === "--dir") { args.shift(); const value = args.shift(); @@ -141,7 +168,7 @@ function takeLeadingGlobals(args) { } break; } - return { json, gateway, files, dir }; + return { json, gateway, files, dir, noHistory, historyDir }; } /** Strip CSI/OSC and other C0/C1 controls so thread fields cannot drive the terminal. */ @@ -310,7 +337,7 @@ async function main(argv) { return; } - const { json, gateway, files: filesMode, dir: rootFlag } = takeLeadingGlobals(args); + const { json, gateway, files: filesMode, dir: rootFlag, noHistory, historyDir } = takeLeadingGlobals(args); const cmd = args[0]; const sub = args[1]; const rest = args.slice(2); @@ -348,6 +375,28 @@ async function main(argv) { return; } + if (cmd === "history") { + const options = args.slice(1); + const showPath = hasFlag(options, "--path"); + const search = takeFlag(options, "--search"); + const limitRaw = takeFlag(options, "--limit"); + const limit = limitRaw === undefined ? 40 : Number(limitRaw); + if (!Number.isSafeInteger(limit) || limit < 1) throw new StoreError("--limit must be a positive integer"); + if (options.length > 1 || options[0]?.startsWith("-") || (showPath && (options.length || search !== undefined || limitRaw !== undefined))) { + throw new StoreError("gbot history [bot-or-group] [--search TEXT] [--limit N], or history --path"); + } + const path = historyPath(historyDir); + if (showPath) print(json ? { path } : path); + else { + const rows = await readHistory(path, { ref: options[0], search, limit }); + if (json) print(rows); + else print(rows.length ? rows.map((row) => + "[" + row.recordedAt + "] " + row.target.name + " (" + row.target.id + ") [" + row.role + "] " + row.text + ).join("\n") : "No local history."); + } + return; + } + if (cmd === "codex") { await runCodex(sub, rest, json); return; @@ -465,6 +514,7 @@ async function main(argv) { const message = rest.join(" ").trim(); if (!ref || !message) throw new StoreError("gbot send "); const out = await backend.send(ref, message); + saveHistory(out, { dir: historyDir, disabled: noHistory, event: "send", prompt: message }); const receipt = out.messageId ? " message " + out.messageId : ""; if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result, delivery: out.delivery || "accepted", ...(out.messageId ? { messageId: out.messageId } : {}) }); else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")" + receipt); @@ -481,6 +531,7 @@ async function main(argv) { const limit = limitRaw ? Number(limitRaw) : 40; if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new StoreError("--limit must be an integer 1-200"); const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit); + saveHistory(out, { dir: historyDir, disabled: noHistory, event: cmd, rootId }); if (json) print(out); else print(formatTranscript(out, { full })); return; diff --git a/src/history.js b/src/history.js new file mode 100644 index 0000000..50be0f3 --- /dev/null +++ b/src/history.js @@ -0,0 +1,83 @@ +import { appendFileSync, closeSync, constants, createReadStream, fstatSync, mkdirSync, openSync, readSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { entryText, transcriptEntries } from "./transcript.js"; + +export function historyPath(dir) { + return join(resolve(dir || process.env.GROK_BOT_HISTORY_DIR || join(homedir(), ".grok-bot-cli")), "history.jsonl"); +} + +// Keep only conversation fields, never gateway responses, session credentials or bot instructions. +export function saveHistory(out, { dir, disabled, event, prompt, rootId } = {}) { + // Opt-in only: plaintext local history stays off unless GROK_BOT_HISTORY=on (or true/1). + if (disabled || !/^(on|true|1)$/i.test(process.env.GROK_BOT_HISTORY || "")) return; + try { + const recordedAt = new Date().toISOString(); + const target = { id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot" }; + const payload = out.transcript || out.thread || out; + const entries = event === "send" ? [{ role: "user", text: prompt }] : transcriptEntries(payload); + const rows = entries.map((entry) => ({ + version: 1, + recordedAt, + event, + target, + ...(rootId ? { rootId } : {}), + role: String(entry.role || entry.kind || entry.sender || entry.type || "msg"), + ...(entry.id || entry.messageId ? { messageId: String(entry.id || entry.messageId) } : {}), + ...(entry.timestamp || entry.createdAt ? { timestamp: String(entry.timestamp || entry.createdAt) } : {}), + text: entryText(entry), + })); + if (!rows.length) return; + const path = historyPath(dir); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const fd = openSync(path, constants.O_CREAT | constants.O_APPEND | constants.O_RDWR | constants.O_NOFOLLOW, 0o600); + try { + // Separate a previous interrupted append from the next complete record. + const size = fstatSync(fd).size; + const last = Buffer.alloc(1); + if (size) readSync(fd, last, 0, 1, size - 1); + const prefix = size && last[0] !== 10 ? "\n" : ""; + appendFileSync(fd, prefix + rows.map((row) => JSON.stringify(row)).join("\n") + "\n"); + } finally { + closeSync(fd); + } + } catch { + // A successful remote send must not look failed and invite an accidental resend. + process.stderr.write("Warning: could not save local history. Check the history directory and permissions.\n"); + } +} + +export async function readHistory(path, { ref, search, limit = 40 } = {}) { + const rows = []; + let malformed = 0; + const input = createReadStream(path, { encoding: "utf8" }); + const lines = createInterface({ input, crlfDelay: Infinity }); + try { + for await (const line of lines) { + if (!line.trim()) continue; + let row; + try { + row = JSON.parse(line); + if (row?.version !== 1 || typeof row.text !== "string" || typeof row.role !== "string" || + typeof row.recordedAt !== "string" || typeof row.target?.id !== "string" || typeof row.target?.name !== "string") { + throw new Error("Invalid history record"); + } + } catch { + malformed++; + continue; + } + if (ref && row.target.id !== ref && row.target.name.toLowerCase() !== ref.toLowerCase()) continue; + if (search !== undefined && !row.text.toLowerCase().includes(search.toLowerCase())) continue; + rows.push(row); + if (rows.length > limit) rows.shift(); + } + } catch (err) { + if (err.code !== "ENOENT") throw err; + } finally { + lines.close(); + input.destroy(); + } + if (malformed) process.stderr.write("Warning: skipped " + malformed + " malformed local history record(s).\n"); + return rows; +} diff --git a/test/history.test.js b/test/history.test.js new file mode 100644 index 0000000..60d54cc --- /dev/null +++ b/test/history.test.js @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const target = { id: "bot-1", name: "Researcher", description: "PRIVATE_INSTRUCTIONS" }; +const group = { id: "group-1", name: "Launch", isGroup: true, memberIds: [target.id] }; +const reply = "A long reply: " + "x".repeat(500) + "\nneedle at the end 🔧"; + +async function fixture(t) { + const home = mkdtempSync(join(tmpdir(), "gbot-history-test-")); + t.after(() => rmSync(home, { recursive: true, force: true })); + const env = { ...process.env, HOME: home, USERPROFILE: home }; + for (const key of Object.keys(env)) { + if (/^(GROK_BOT_|CURSOR_|SAND_)/.test(key)) delete env[key]; + } + env.GROK_BOT_HISTORY = "on"; + env.GROK_BOT_ALLOW_LOCAL_GATEWAY = "1"; + const calls = []; + const state = { failSend: false, payload: { entries: [ + { id: "reply-1", role: "assistant", preview: "A long reply: ...", content: [{ type: "text", text: reply }], createdAt: "2026-09-05T10:00:00Z", token: "PRIVATE_ENTRY_METADATA" }, + ], gatewayToken: "PRIVATE_RESPONSE_METADATA" } }; + const server = createServer(async (req, res) => { + let text = ""; + for await (const chunk of req) text += chunk; + const body = JSON.parse(text); + calls.push({ method: req.url, body }); + res.setHeader("Content-Type", "application/json"); + if (req.url === "/api/listAgents") res.end(JSON.stringify({ agents: [target, group] })); + else if (req.url === "/api/sendPrompt") { + res.statusCode = state.failSend ? 500 : 200; + res.end(JSON.stringify(state.failSend ? { error: "rejected" } : { ok: true, gatewayToken: "PRIVATE_SEND_METADATA" })); + } else res.end(JSON.stringify(state.payload)); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + const onlineEnv = { ...env, GROK_BOT_GATEWAY_URL: "http://127.0.0.1:" + server.address().port, GROK_BOT_GATEWAY_TOKEN: "PRIVATE_AUTH" }; + const run = (args, extra = {}, online = true) => exec(process.execPath, [CLI, ...args], { env: { ...(online ? onlineEnv : env), ...extra } }); + const path = join(home, ".grok-bot-cli", "history.jsonl"); + const rows = () => readFileSync(path, "utf8").trim().split("\n").map(JSON.parse); + return { home, path, rows, run, calls, state }; +} + +test("send persists a full multiline prompt across processes, searchable offline and with grep", async (t) => { + const f = await fixture(t); + const prompt = 'Investigate timeout\nwith "quotes" and Unicode 🔧'; + const sent = await f.run(["send", "Researcher", prompt, "--json"]); + assert.equal(JSON.parse(sent.stdout).result.ok, true); + assert.equal(sent.stderr, ""); + const [row] = f.rows(); + assert.equal(row.text, prompt); + assert.equal(row.role, "user"); + assert.equal(row.event, "send"); + assert.deepEqual(row.target, { id: target.id, name: target.name, kind: "bot" }); + assert.ok(Number.isFinite(Date.parse(row.recordedAt))); + assert.equal(readFileSync(f.path, "utf8").trim().split("\n").length, 1); + assert.doesNotMatch(readFileSync(f.path, "utf8"), /PRIVATE_/); + const count = f.calls.length; + const history = await f.run(["history", "researcher", "--search", "TIMEOUT", "--json"], {}, false); + assert.deepEqual(JSON.parse(history.stdout), [row]); + assert.equal(f.calls.length, count); + const grep = await exec("grep", ["-n", "timeout", f.path]); + assert.match(grep.stdout, /^1:/); + if (process.platform !== "win32") { + assert.equal(statSync(f.path).mode & 0o777, 0o600); + assert.equal(statSync(join(f.home, ".grok-bot-cli")).mode & 0o777, 0o700); + } +}); + +test("thread and chat preserve full replies, group and root metadata, and repeated observations", async (t) => { + const f = await fixture(t); + await f.run(["thread", "Researcher", "--limit", "80"]); + assert.deepEqual(f.calls.at(-1), { method: "/api/getAgentTranscriptTail", body: { id: target.id, limit: 80 } }); + await f.run(["chat", "Launch", "--root", "root-1", "--json"]); + assert.deepEqual(f.calls.at(-1), { method: "/api/getAgentThread", body: { id: group.id, rootId: "root-1" } }); + const rows = f.rows(); + assert.equal(rows.length, 2); + assert.equal(rows[0].text, reply); + assert.equal(rows[0].messageId, "reply-1"); + assert.equal(rows[0].timestamp, "2026-09-05T10:00:00Z"); + assert.equal(rows[1].rootId, "root-1"); + assert.equal(rows[1].target.kind, "group"); + assert.doesNotMatch(readFileSync(f.path, "utf8"), /PRIVATE_/); + const found = await f.run(["history", "group-1", "--search", "needle", "--json"], {}, false); + assert.deepEqual(JSON.parse(found.stdout), [rows[1]]); + await f.run(["thread", "Researcher"]); + assert.equal(f.rows().length, 3); +}); + +test("supports all transcript envelopes and text fields already displayed by the CLI", async (t) => { + const f = await fixture(t); + for (const [key, entry] of [ + ["messages", { messageId: "m1", sender: "assistant", message: "message text" }], + ["items", { kind: "user", prompt: "prompt text" }], + [null, { type: "assistant", content: "content text" }], + ]) { + f.state.payload = key ? { [key]: [entry] } : [entry]; + await f.run(["thread", "Researcher"]); + } + assert.deepEqual(f.rows().map((r) => r.text), ["message text", "prompt text", "content text"]); +}); + +test("recording opt-outs do not create storage or disable access to existing history", async (t) => { + const f = await fixture(t); + await f.run(["--no-history", "send", "Researcher", "private prompt"]); + for (const value of ["off", "FALSE", "0"]) { + await f.run(["thread", "Researcher"], { GROK_BOT_HISTORY: value }); + } + assert.equal(existsSync(f.path), false); + await f.run(["send", "Researcher", "saved"]); + await f.run(["--no-history", "thread", "Researcher"]); + const found = await f.run(["history", "--json"], { GROK_BOT_HISTORY: "off" }, false); + assert.equal(JSON.parse(found.stdout).length, 1); + assert.equal(f.rows().length, 1); +}); + +test("history path is offline and side-effect free; flag directory overrides environment", async (t) => { + const f = await fixture(t); + const defaultPath = await f.run(["history", "--path"], {}, false); + assert.equal(defaultPath.stdout.trim(), f.path); + assert.equal(existsSync(f.path), false); + const dir = join(f.home, "custom history"); + const extra = { GROK_BOT_HISTORY_DIR: join(f.home, "env-history") }; + await f.run(["send", "Researcher", "env"], extra); + await f.run(["--history-dir", dir, "send", "Researcher", "flag"], extra); + const found = await f.run(["--history-dir", dir, "history", "--json"], extra, false); + assert.equal(JSON.parse(found.stdout)[0].text, "flag"); + const envHistory = await f.run(["history", "--json"], extra, false); + assert.equal(JSON.parse(envHistory.stdout)[0].text, "env"); + const path = await f.run(["--history-dir", dir, "history", "--path", "--json"], extra, false); + assert.deepEqual(JSON.parse(path.stdout), { path: join(dir, "history.jsonl") }); +}); + +test("offline history handles missing files, filters before limiting, and validates options", async (t) => { + const f = await fixture(t); + assert.equal((await f.run(["history"], {}, false)).stdout.trim(), "No local history."); + assert.equal(existsSync(f.path), false); + for (const text of ["match one", "match two", "unrelated"]) await f.run(["send", "Researcher", text]); + const result = await f.run(["history", "--search", "match", "--limit", "1", "--json"], {}, false); + assert.equal(JSON.parse(result.stdout)[0].text, "match two"); + assert.deepEqual(JSON.parse((await f.run(["history", "unknown", "--json"], {}, false)).stdout), []); + for (const args of [["--limit", "0"], ["--limit", "1.5"], ["--limit", "NaN"], ["--path", "Researcher"], ["--unknown"]]) { + await assert.rejects(f.run(["history", ...args], {}, false), (err) => err.code === 1); + } +}); + +test("failed sends and empty threads leave no history; disk failure does not fail a successful send", async (t) => { + const f = await fixture(t); + f.state.failSend = true; + await assert.rejects(f.run(["send", "Researcher", "rejected"])); + assert.equal(existsSync(f.path), false); + f.state.payload = { entries: [] }; + await f.run(["thread", "Researcher"]); + assert.equal(existsSync(f.path), false); + f.state.failSend = false; + const blocked = join(f.home, "not-a-directory"); + writeFileSync(blocked, "occupied"); + const result = await f.run(["--history-dir", blocked, "send", "Researcher", "sent once", "--json"]); + assert.equal(JSON.parse(result.stdout).result.ok, true); + assert.match(result.stderr, /Warning: could not save local history/); + assert.equal(f.calls.filter((c) => c.method === "/api/sendPrompt" && c.body.prompt === "sent once").length, 1); +}); + +test("history skips malformed records and separates interrupted writes on the next append", async (t) => { + const f = await fixture(t); + await f.run(["send", "Researcher", "first"]); + appendFileSync(f.path, 'null\n{"version":1}\n{"text":"interrupted'); + await f.run(["send", "Researcher", "second"]); + const found = await f.run(["history", "--json"], {}, false); + assert.deepEqual(JSON.parse(found.stdout).map((r) => r.text), ["first", "second"]); + assert.match(found.stderr, /skipped 3 malformed/); +}); + +test("concurrent CLI processes append complete records", async (t) => { + const f = await fixture(t); + await Promise.all(Array.from({ length: 8 }, (_, i) => f.run(["send", "Researcher", "parallel " + i]))); + assert.equal(f.rows().length, 8); + assert.equal(new Set(f.rows().map((r) => r.text)).size, 8); +}); + +test("refuses to append through a history-file symlink", { skip: process.platform === "win32" }, async (t) => { + const f = await fixture(t); + const destination = join(f.home, "unrelated-file"); + writeFileSync(destination, "keep me"); + symlinkSync(destination, join(f.home, "history.jsonl")); + const result = await f.run(["--history-dir", f.home, "send", "Researcher", "hello"]); + assert.match(result.stderr, /could not save local history/); + assert.equal(readFileSync(destination, "utf8"), "keep me"); +}); From f5a01543f9bbb55a4a215a7ce8f2d14ad0e5a05a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 03:12:01 +0000 Subject: [PATCH 2/2] fix: rebase history onto main with opt-in and full entry text Peel trailing --json for history/send/thread, prefer content over preview for searchable history, and keep GROK_BOT_HISTORY opt-in after P1 bridge. --- src/cli.js | 7 ++++++- src/transcript.js | 5 +++-- test/history.test.js | 2 +- test/transcript.test.js | 2 ++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli.js b/src/cli.js index f79bd0c..ea23b14 100755 --- a/src/cli.js +++ b/src/cli.js @@ -337,7 +337,8 @@ async function main(argv) { return; } - const { json, gateway, files: filesMode, dir: rootFlag, noHistory, historyDir } = takeLeadingGlobals(args); + // `json` may also be peeled later from command-local argv (trailing `--json`). + let { json, gateway, files: filesMode, dir: rootFlag, noHistory, historyDir } = takeLeadingGlobals(args); const cmd = args[0]; const sub = args[1]; const rest = args.slice(2); @@ -377,6 +378,8 @@ async function main(argv) { if (cmd === "history") { const options = args.slice(1); + // Command-local flags (globals only peel from argv before the command). + if (hasFlag(options, "--json")) json = true; const showPath = hasFlag(options, "--path"); const search = takeFlag(options, "--search"); const limitRaw = takeFlag(options, "--limit"); @@ -511,6 +514,7 @@ async function main(argv) { if (cmd === "send") { const ref = sub; + if (hasFlag(rest, "--json")) json = true; const message = rest.join(" ").trim(); if (!ref || !message) throw new StoreError("gbot send "); const out = await backend.send(ref, message); @@ -524,6 +528,7 @@ async function main(argv) { if (cmd === "thread" || cmd === "chat") { const ref = sub; if (!ref) throw new StoreError("gbot thread [--limit N] [--root MESSAGE_ID] [--full]"); + if (hasFlag(rest, "--json")) json = true; const full = rest.includes("--full"); if (full) rest.splice(rest.indexOf("--full"), 1); const limitRaw = takeFlag(rest, "--limit"); diff --git a/src/transcript.js b/src/transcript.js index 060b4c3..2eb5ab1 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -24,7 +24,8 @@ export function entryText(e) { function entryTextRaw(e) { if (!e || typeof e !== "object") return ""; - const direct = e.text || e.prompt || e.message || e.preview; + // Prefer full body fields over `preview` (often truncated for list UIs). + const direct = e.text || e.prompt || e.message; if (typeof direct === "string" && direct) return direct; const content = e.content; if (typeof content === "string") return content; @@ -38,7 +39,7 @@ function entryTextRaw(e) { if (content && typeof content === "object") return content.text || toSafeText(content); // Bot replies arrive as `{ kind: "send-message", message: { type, content } }`. if (e.message && typeof e.message === "object" && typeof e.message.content === "string") return e.message.content; - return ""; + return typeof e.preview === "string" ? e.preview : ""; } export function transcriptEntries(payload) { diff --git a/test/history.test.js b/test/history.test.js index 60d54cc..616272e 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -55,7 +55,7 @@ test("send persists a full multiline prompt across processes, searchable offline const prompt = 'Investigate timeout\nwith "quotes" and Unicode 🔧'; const sent = await f.run(["send", "Researcher", prompt, "--json"]); assert.equal(JSON.parse(sent.stdout).result.ok, true); - assert.equal(sent.stderr, ""); + assert.match(sent.stderr, /^(warning: GROK_BOT_ALLOW_LOCAL_GATEWAY is set; credentials may be sent to a loopback gateway\.\n)?$/); const [row] = f.rows(); assert.equal(row.text, prompt); assert.equal(row.role, "user"); diff --git a/test/transcript.test.js b/test/transcript.test.js index 57fef6a..e8a0958 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -14,6 +14,8 @@ test("direct string keys win over content, then content parts join", () => { assert.equal(entryText({ content: ["a", { text: "b" }, { content: "c" }, 4] }), "a\nb\nc"); assert.equal(entryText({ content: { text: "obj" } }), "obj"); assert.equal(entryText({ content: { other: 1 } }), '{"other":1}'); + assert.equal(entryText({ preview: "short…", content: [{ type: "text", text: "full body" }] }), "full body"); + assert.equal(entryText({ preview: "preview only" }), "preview only"); }); test("transcript containers unwrap to an entry list", () => {