Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-thread-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Add opt-in local JSONL thread history (`GROK_BOT_HISTORY=on`) with offline `gbot history` search.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 58 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -50,6 +51,8 @@ function usage() {
" send <bot-or-group> <message...>",
" thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]",
" chat <bot-or-group> 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 <threadId> <message...>",
Expand All @@ -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");
}

Expand Down Expand Up @@ -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];
Expand All @@ -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();
Expand All @@ -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. */
Expand Down Expand Up @@ -310,7 +337,8 @@ async function main(argv) {
return;
}

const { json, gateway, files: filesMode, dir: rootFlag } = 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);
Expand Down Expand Up @@ -348,6 +376,30 @@ async function main(argv) {
return;
}

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");
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;
Expand Down Expand Up @@ -462,9 +514,11 @@ 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 <bot-or-group> <message...>");
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);
Expand All @@ -474,13 +528,15 @@ async function main(argv) {
if (cmd === "thread" || cmd === "chat") {
const ref = sub;
if (!ref) throw new StoreError("gbot thread <bot-or-group> [--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");
const rootId = takeFlag(rest, "--root");
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;
Expand Down
83 changes: 83 additions & 0 deletions src/history.js
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 3 additions & 2 deletions src/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading