Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/thread-after-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Add `gbot thread --after ID` for exclusive client-side filtering of the bounded gateway tail, including no-op cursors and explicit gap-reset snapshots.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ gbot groups update Launch --title "Launch room" --hidden off
gbot send Researcher "Summarize the launch status."
gbot send Launch "Share your updates."
gbot thread Researcher
gbot thread Researcher --after <last-entry-id> --json
gbot groups delete Launch
gbot bots delete Researcher
gbot bots delete Writer
```

`update` fields: `--name` `--description`/`--instructions` `--title` `--avatar-shape` `--avatar-color` `--notify` `--hidden`. `--description` is the UI Instructions field.

`gbot thread --after ID` filters the bounded tail locally and returns entries strictly
after that opaque entry ID. Its JSON includes `cursor`, `entryCount`, and `gapReset`.
An unchanged poll has `entryCount: 0`; an unknown or expired ID returns one bounded
snapshot with `gapReset: true`. The gateway request remains limit-only.

Run `gbot --help` for every command.

## Gateway URL policy
Expand Down Expand Up @@ -99,6 +105,13 @@ Add `--replace` to an install command to overwrite an earlier copy. `npm run che
runs the plugin gates: source validation, build, artifact validation, typecheck, and
the route-unit tests, which drive both tools against a loopback fake gateway.

`gbot_thread` returns a small receipt by default: deterministic `summary`, opaque
`cursor`, `entryCount`, and `gapReset`.
Pass the cursor back as `after` for an exclusive client-side delta. Pass `full:true`
only when bounded entry bodies are needed in structured content; `Agent.Text` remains
the short summary. Unknown cursors set `gapReset: true`; repeat that call with
`full:true` to inspect the bounded reset snapshot.

Auth resolves exactly as for `gbot`: `GROK_BOT_GATEWAY_URL` + `GROK_BOT_GATEWAY_TOKEN`,
then the Grok Bot app session, then `CURSOR_ACCESS_TOKEN`. The MCP server therefore
needs outbound HTTPS to the gateway host and read access to the app-session file
Expand Down
33 changes: 10 additions & 23 deletions plugin/src/gbot.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { z } from 'zod';

import { connectGateway, getTranscriptTail, sendPrompt } from 'grok-bot-cli/src/gateway.js';
import { entryText, transcriptEntries as unwrapEntries } from 'grok-bot-cli/src/transcript.js';
import { entryText, transcriptDelta, transcriptEntries as unwrapEntries } from 'grok-bot-cli/src/transcript.js';
import { redactSecrets } from 'grok-bot-cli/src/url-policy.js';

export { connectGateway, getTranscriptTail, sendPrompt };
export { connectGateway, getTranscriptTail, sendPrompt, transcriptDelta };

// The same pass `fail()` in src/cli.js applies before printing: MCP hosts show
// the error text, and a fetch or proxy failure can echo a credential.
Expand Down Expand Up @@ -47,20 +47,11 @@ export const entrySchema = z.object({
});
type Entry = z.infer<typeof entrySchema>;

/** Match `gbot thread` CLI preview width so MCP hosts are not flooded. */
export const ENTRY_TEXT_MAX = 400;
// ponytail: fixed preview/full budgets; upgrade path is a paged thread resource instead of wider caps.
// When an entry is cut by these budgets it still reports truncated/fullLength, and the
// remainder is retrievable with `gbot thread --full` / `--json` on the machine.
export const ENTRY_FULL_MAX = 20000;
export const TRANSCRIPT_TOTAL_MAX = 200000;
// Metadata fields are capped too: an uncapped id/kind/role would bypass the total budget.
export const ENTRY_META_MAX = 200;

export const truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => {
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1))}…`;
};
export const RECEIPT_CURSOR_MAX = 1024;

const entryFields = z.object({
id: z.string().default(''),
Expand All @@ -71,11 +62,11 @@ const entryFields = z.object({

const capMeta = (value: string): string => (value.length > ENTRY_META_MAX ? `${value.slice(0, ENTRY_META_MAX)}…` : value);

const threadEntry = (raw: unknown, max: number, ellipsis: boolean): Entry => {
const threadEntry = (raw: unknown): Entry => {
const fields = entryFields.safeParse(raw);
const full = entryText(raw);
const truncated = full.length > max;
const text = !truncated ? full : ellipsis ? truncateEntryText(full, max) : full.slice(0, max);
const truncated = full.length > ENTRY_FULL_MAX;
const text = truncated ? full.slice(0, ENTRY_FULL_MAX) : full;
if (!fields.success) {
return { id: '', kind: 'unknown', text, truncated, fullLength: full.length };
}
Expand All @@ -93,18 +84,14 @@ const threadEntry = (raw: unknown, max: number, ellipsis: boolean): Entry => {

const metaLength = (entry: Entry): number => entry.id.length + entry.kind.length + (entry.role?.length ?? 0);

export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; limit?: number } = {}): Entry[] => {
export const transcriptEntries = (transcript: unknown): Entry[] => {
const rows = unwrapEntries(transcript);
// Enforce the requested count locally: a gateway ignoring `limit` cannot inflate output.
const wanted =
typeof opts.limit === 'number' && Number.isInteger(opts.limit) && opts.limit > 0 ? Math.min(opts.limit, 200) : rows.length;
const perEntry = opts.full ? ENTRY_FULL_MAX : ENTRY_TEXT_MAX;
let remaining = TRANSCRIPT_TOTAL_MAX;
return rows.slice(0, wanted).map((raw) => {
const entry = threadEntry(raw, perEntry, !opts.full);
return rows.map((raw) => {
const entry = threadEntry(raw);
const allowText = Math.max(0, Math.min(entry.text.length, remaining - metaLength(entry)));
if (allowText < entry.text.length) {
entry.text = allowText <= 0 ? '' : !opts.full ? truncateEntryText(entry.text, allowText) : entry.text.slice(0, allowText);
entry.text = allowText <= 0 ? '' : entry.text.slice(0, allowText);
entry.truncated = entry.fullLength > entry.text.length;
}
remaining = Math.max(0, remaining - metaLength(entry) - entry.text.length);
Expand Down
52 changes: 40 additions & 12 deletions plugin/src/mcp/grok-bot/tools/gbot_thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
connectGateway,
entrySchema,
getTranscriptTail,
summarizeTarget,
targetSchema,
RECEIPT_CURSOR_MAX,
transcriptDelta,
transcriptEntries,
withRedactedErrors,
} from '../../../gbot.js';
Expand All @@ -16,19 +16,23 @@ export default defineTool(
{
annotations: { readOnlyHint: true },
description:
'Read the most recent messages in a Grok Bot bot or group thread, like `gbot thread`. Use it to collect the reply to a gbot_send.',
'Read a bounded Grok Bot thread tail. Returns a small receipt by default; pass the last cursor as after for an exclusive client-side delta, or full:true to include bounded entry text.',
inputJsonSchema: {
additionalProperties: false,
properties: {
after: {
description: 'Opaque cursor from the previous call. Returns entries strictly after it; an unknown cursor resets with a bounded snapshot.',
type: 'string',
},
limit: {
default: 40,
description: 'How many trailing entries to return (1-200). Each entry text is capped at 400 characters.',
description: 'How many trailing entries to inspect (1-200). Entries are returned only with full:true.',
type: 'number',
},
full: {
default: false,
description:
'Return complete entry text up to bounded budgets (20k chars per entry, 200k total) instead of the 400-character preview. Every entry still reports truncated/fullLength; read the remainder with `gbot thread --full` / `--json` on the machine.',
'Return entries with text up to bounded budgets (20k chars per entry, 200k total). Every entry reports truncated/fullLength; read any remainder with `gbot thread --full` / `--json` on the machine.',
type: 'boolean',
},
target: { description: 'Bot or group name or id, for example "General".', type: 'string' },
Expand All @@ -39,22 +43,46 @@ export default defineTool(
inputSchema: z.object({
// ponytail: the route inputJsonSchema type cannot express minimum/maximum, so the
// 1-200 bound lives here in zod (and in the CLI/gateway); widen the route type to align them.
after: z.string().min(1).max(RECEIPT_CURSOR_MAX).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the empty-thread cursor reusable

When the first poll targets an empty thread, transcriptDelta returns cursor: "", but this schema rejects that cursor on the next call because of .min(1). This breaks the advertised workflow of passing the previous cursor as after precisely when polling starts before the first message; return a non-empty sentinel cursor or accept the emitted empty cursor. The CLI has the same incompatibility in its --after length validation.

Useful? React with 👍 / 👎.

limit: z.number().int().min(1).max(200).default(40),
full: z.boolean().default(false),
target: z.string().min(1),
}),
resultSchema: z.object({ entries: z.array(entrySchema), target: targetSchema }),
resultSchema: z.object({
cursor: z.string().max(RECEIPT_CURSOR_MAX),
entries: z.array(entrySchema).optional(),
entryCount: z.number().int().min(0).max(200),
gapReset: z.boolean(),
summary: z.string().max(256),
}),
title: 'Read a Grok Bot thread',
},
async ({ limit, target, full }) => {
async ({ after, limit, target, full }) => {
const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit));
const value = { entries: transcriptEntries(tail.transcript, { full, limit }), target: summarizeTarget(tail.target) };
const delta = transcriptDelta(tail.transcript, { after, limit });
const entries = transcriptEntries(delta.entries);
const summary = delta.gapReset
? `${delta.entryCount} entries; gap reset`
: after === undefined
? `${delta.entryCount} entries`
: `${delta.entryCount} new`;
const receipt = {
cursor: delta.cursor,
entryCount: delta.entryCount,
gapReset: delta.gapReset,
summary,
};
if (!full) {
return (
<Agent.Result value={receipt}>
<Agent.Text>{summary}</Agent.Text>
</Agent.Result>
);
}
const value = { ...receipt, entries };
return (
<Agent.Result value={value}>
<Agent.Text>{`${value.target.kind} ${value.target.name}: ${value.entries.length} entries.`}</Agent.Text>
{value.entries.map((entry, index) => (
<Agent.Text key={entry.id || index}>{`[${entry.role ?? entry.kind}] ${entry.text}`}</Agent.Text>
))}
<Agent.Text>{summary}</Agent.Text>
</Agent.Result>
);
},
Expand Down
6 changes: 5 additions & 1 deletion plugin/src/skills/talk-to-grok-bot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ Do not ping a bot for work you can finish yourself. Replies are asynchronous —
## How

1. `gbot_send` with `target` (name or id) and `message` (first line: who you are + what you need).
2. Later, `gbot_thread` with the same `target` (`limit` defaults to 40). Bot replies are `send-message` entries; yours are `message` with `role: user`.
2. Later, call `gbot_thread` with the same `target` (`limit` defaults to 40). The default receipt has only `summary`, `cursor`, `entryCount`, and `gapReset`; it never includes entries.
3. Poll with the previous `cursor` as `after`. This is exclusive and client-side: `entryCount: 0` means no change.
4. Pass `full: true` only when entry bodies are needed inline; it adds bounded `entries` to structured content, not to `Agent.Text`. If `gapReset` is true, repeat the same call with `full: true` to inspect the bounded reset snapshot.

Bot replies are `send-message` entries; yours are `message` with `role: user`.

List targets with `gbot bots list` / `gbot groups list` when the name is ambiguous.

Expand Down
Loading