Skip to content
Open
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": minor
---

Persist sent prompts and fetched thread messages in local JSONL history, with offline search, a configurable directory, and recording opt-outs.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,53 @@ gbot bots delete Writer

Run `gbot --help` for every command.

## Local history

Successful `send` commands save the prompt locally. `thread` and `chat` save the
messages returned by the gateway, including replies, without the terminal display's
400-character truncation. History is plain-text JSONL at
`~/.grok-bot-cli/history.jsonl`: one message observation per line, with the target
ID/name, role, text, command, and recording time. Message IDs, message timestamps,
and the requested thread root are included when available.

```bash
gbot send Researcher "Investigate the startup timeout"
gbot thread Researcher
gbot history Researcher --search timeout
gbot history --search timeout --limit 100 --json
grep -in 'timeout' "$(gbot history --path)"
```

`history` works offline, without app credentials. It shows the last 40 matching
records in recording order; `--limit` changes that count. The optional target
matches an exact ID or a case-insensitive full name. `--search` is a
case-insensitive literal text search. `--json` returns an array with full text.
JSONL escapes embedded newlines, so multiline prompts remain one searchable line.

Use `--history-dir DIR` or `GROK_BOT_HISTORY_DIR` to choose another directory (the
flag takes precedence). New directories are created with mode `0700` and new
files with mode `0600`. Existing directory permissions are left unchanged.
History contains the conversation text, including any sensitive information you
put in messages. Gateway credentials and raw response metadata are not recorded.
Disable recording for a command with `--no-history`, or for all commands with
`GROK_BOT_HISTORY=off` (`false` and `0` also work):

```bash
gbot --no-history send Researcher "Do not record this prompt locally"
export GROK_BOT_HISTORY=off
```

The opt-out affects local recording only; the gateway still receives the command,
and existing local history remains readable. To remove local history, delete the
file reported by `gbot history --path`. There is no automatic expiry.

This is a record of CLI observations, not a background sync or a conversation
resume mechanism. Replies are captured when you run `thread`/`chat`; the default
fetch is the latest 40 messages (`thread --limit N` requests more). Repeated fetches
append repeated observations, and a sent prompt can appear again when fetched.
Messages never fetched by this CLI are not backed up. Local write failures warn
on stderr without turning a successful gateway operation into a failed command.

## License

MIT
53 changes: 34 additions & 19 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan
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";

function print(value) {
if (typeof value === "string") process.stdout.write(value + "\n");
Expand Down Expand Up @@ -47,6 +49,8 @@ function usage() {
" send <bot-or-group> <message...>",
" thread <bot-or-group> [--limit N] [--root MESSAGE_ID]",
" chat <bot-or-group> alias for thread",
" history [bot-or-group] [--search TEXT] [--limit N] (offline)",
" history --path print the local JSONL file path",
"",
"Max group members: " + MAX_GROUP_MEMBERS,
"--description / --instructions is the UI Instructions field (same key).",
Expand All @@ -55,6 +59,9 @@ function usage() {
"Flags: --gateway --files --dir DIR --json",
"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",
"History: ~/.grok-bot-cli/history.jsonl (plain text; saved after send/thread/chat)",
" --history-dir DIR / GROK_BOT_HISTORY_DIR to relocate",
" --no-history / GROK_BOT_HISTORY=off to disable recording",
].join("\n");
}

Expand Down Expand Up @@ -184,27 +191,9 @@ function formatRecord(rec, all) {
return kind + " " + rec.name + title + "\n " + rec.id + desc + avatar + settingsLine + extra;
}

function entryText(e) {
if (!e || typeof e !== "object") return "";
const direct = e.text || e.prompt || e.message || e.preview;
if (typeof direct === "string" && direct) return direct;
const content = e.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object") return part.text || part.content || "";
return "";
}).filter(Boolean).join("\n");
}
if (content && typeof content === "object") return content.text || JSON.stringify(content);
return "";
}

function formatTranscript(out) {
const rec = out.target;
const payload = out.transcript || out.thread || {};
const entries = payload.entries || payload.messages || payload.items || (Array.isArray(payload) ? payload : []);
const entries = transcriptEntries(out);
const header = (rec.isGroup ? "group" : "bot") + " " + rec.name + "\n " + rec.id;
if (!Array.isArray(entries) || entries.length === 0) {
return header + "\n (no messages)";
Expand All @@ -226,6 +215,8 @@ async function main(argv) {
return;
}

const noHistory = hasFlag(args, "--no-history");
const historyDir = takeFlag(args, "--history-dir");
const json = hasFlag(args, "--json");
const gateway = hasFlag(args, "--gateway");
const filesMode = hasFlag(args, "--files");
Expand All @@ -238,6 +229,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 === "doctor") {
const candidates = defaultCandidateRoots();
const found = candidates.filter(looksLikeAgentsRoot);
Expand Down Expand Up @@ -379,6 +392,7 @@ async function main(argv) {
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 });
if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result });
else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")");
return;
Expand All @@ -391,6 +405,7 @@ async function main(argv) {
const rootId = takeFlag(rest, "--root");
const limit = limitRaw ? Number(limitRaw) : 40;
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));
return;
Expand Down
81 changes: 81 additions & 0 deletions src/history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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 } = {}) {
if (disabled || /^(off|false|0)$/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 entries = event === "send" ? [{ role: "user", text: prompt }] : transcriptEntries(out);
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;
}
22 changes: 22 additions & 0 deletions src/transcript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function entryText(e) {
if (!e || typeof e !== "object") return "";
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;
if (Array.isArray(content)) {
return content.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object") return part.text || part.content || "";
return "";
}).filter(Boolean).join("\n");
}
if (content && typeof content === "object") return content.text || JSON.stringify(content);
return typeof e.preview === "string" ? e.preview : "";
}

export function transcriptEntries(out) {
const payload = out.transcript || out.thread || {};
const entries = Array.isArray(payload) ? payload : payload.entries || payload.messages || payload.items || [];
return Array.isArray(entries) ? entries.filter((entry) => entry && typeof entry === "object") : [];
}
Loading