diff --git a/.changeset/thread-after-filter.md b/.changeset/thread-after-filter.md new file mode 100644 index 0000000..bedf9fc --- /dev/null +++ b/.changeset/thread-after-filter.md @@ -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. diff --git a/README.md b/README.md index 6f688bc..cabe478 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ 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 --json gbot groups delete Launch gbot bots delete Researcher gbot bots delete Writer @@ -35,6 +36,11 @@ 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 @@ -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 diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index ac4be59..2041913 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -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. @@ -47,20 +47,11 @@ export const entrySchema = z.object({ }); type Entry = z.infer; -/** 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(''), @@ -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 }; } @@ -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); diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index a242ce2..cb074ff 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -6,8 +6,8 @@ import { connectGateway, entrySchema, getTranscriptTail, - summarizeTarget, - targetSchema, + RECEIPT_CURSOR_MAX, + transcriptDelta, transcriptEntries, withRedactedErrors, } from '../../../gbot.js'; @@ -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' }, @@ -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(), 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 ( + + {summary} + + ); + } + const value = { ...receipt, entries }; return ( - {`${value.target.kind} ${value.target.name}: ${value.entries.length} entries.`} - {value.entries.map((entry, index) => ( - {`[${entry.role ?? entry.kind}] ${entry.text}`} - ))} + {summary} ); }, diff --git a/plugin/src/skills/talk-to-grok-bot/SKILL.md b/plugin/src/skills/talk-to-grok-bot/SKILL.md index f966dc4..1ff6440 100644 --- a/plugin/src/skills/talk-to-grok-bot/SKILL.md +++ b/plugin/src/skills/talk-to-grok-bot/SKILL.md @@ -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. diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index fbba0c5..cbeefb6 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -26,10 +26,18 @@ const roster = { { id: 'bot-5', isGroup: false, name: 'Noreceipt' }, { id: 'bot-6', isGroup: false, name: 'Big' }, { id: 'bot-7', isGroup: false, name: 'Meta' }, + { id: 'bot-8', isGroup: false, name: 'Noisy' }, ], }; const transcripts: Record = { 'bot-1': { entries: [], nextBeforeSeq: 0 }, + 'bot-8': { + entries: Array.from({ length: 60 }, (_, index) => ({ + id: `n${index + 1}`, + kind: 'message', + text: `update ${index + 1} ${'y'.repeat(390)}`, + })), + }, 'bot-2': { messages: [ { content: 'ignored when text is set', id: 'l1', text: 'direct text' }, @@ -57,7 +65,10 @@ const transcripts: Record = { entries: Array.from({ length: 11 }, (_, index) => ({ id: `b${index}`, kind: 'note', text: 'q'.repeat(20000) })), }, 'bot-7': { - entries: [{ id: 'i'.repeat(300), kind: 'k'.repeat(300), role: 'R'.repeat(5000), text: 'hi' }], + entries: [ + { id: 'i'.repeat(300), kind: 'k'.repeat(300), role: 'R'.repeat(5000), text: 'hi' }, + { id: 'last-valid-id', kind: 7 }, + ], }, }; const responses: Record) => [number, unknown]> = { @@ -143,41 +154,66 @@ describe('grok-bot MCP server', () => { expect(calls[1]?.body.clientNonce).toMatch(/^[0-9a-f-]{36}$/u); }); - it('gbot_thread normalizes user and bot entries and forwards the limit', async () => { + it('gbot_thread returns a short summary plus cursor by default and keeps entry text for full:true', async () => { const result = await invokeMcpTool('gbot_thread', { input: { limit: 3, target: 'Launch' }, server: 'grok-bot', }); expect(result.isError).toBe(false); expect(result.structuredContent).toEqual({ + cursor: 't3', + entryCount: 3, + gapReset: false, + summary: '3 entries', + }); + expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); + const summary = contentText(result.content); + expect(summary).toBe('3 entries'); + expect(summary).not.toContain('hello from the test'); + expect(summary).not.toContain('reply'); + + const full = await invokeMcpTool('gbot_thread', { + input: { full: true, limit: 3, target: 'Launch' }, + server: 'grok-bot', + }); + expect(full.isError).toBe(false); + expect(full.structuredContent).toEqual({ + cursor: 't3', + entryCount: 3, entries: [ { id: 't1', kind: 'message', role: 'user', text: 'hello from the test', truncated: false, fullLength: 19, timestampMs: 1 }, { id: 't2', kind: 'send-message', text: 'reply', truncated: false, fullLength: 5 }, { id: 't3', kind: 'tool-call', text: '', truncated: false, fullLength: 0 }, ], - target: { id: 'grp-1', kind: 'group', name: 'Launch' }, + gapReset: false, + summary: '3 entries', }); - expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); - expect(contentText(result.content)).toContain('[user] hello from the test'); - expect(contentText(result.content)).toContain('[send-message] reply'); + expect(contentText(full.content)).toBe('3 entries'); }); it('gbot_thread defaults the limit to 40 like the CLI and reads the other transcript shapes', async () => { const empty = await invokeMcpTool('gbot_thread', { input: { target: 'General' }, server: 'grok-bot' }); expect(calls[1]?.body).toEqual({ id: 'bot-1', limit: 40 }); - expect(empty.structuredContent).toEqual({ entries: [], target: { id: 'bot-1', kind: 'bot', name: 'General' } }); + expect(empty.structuredContent).toEqual({ + cursor: '', + entryCount: 0, + gapReset: false, + summary: '0 entries', + }); + expect(contentText(empty.content)).toBe('0 entries'); const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); - expect(legacy.structuredContent).toMatchObject({ - entries: [ - { id: 'l1', kind: 'message', text: 'direct text', truncated: false, fullLength: 11 }, - { id: 'l2', kind: 'note', text: 'part one\npart two\npart three', truncated: false, fullLength: 28 }, - { id: 'l3', kind: 'message', text: 'plain message', truncated: false, fullLength: 13 }, - { id: 'l4', kind: 'message', text: `${'x'.repeat(399)}…`, truncated: true, fullLength: 450 }, - ], + expect(legacy.structuredContent).toEqual({ + cursor: 'l4', + entryCount: 4, + gapReset: false, + summary: '4 entries', }); - expect(contentText(legacy.content)).toContain('…'); - expect(contentText(legacy.content)).not.toContain('x'.repeat(450)); + const legacySummary = contentText(legacy.content); + expect(legacySummary).toBe('4 entries'); + expect(legacySummary).not.toContain('direct text'); + expect(legacySummary).not.toContain('…'); + expect(legacySummary).not.toContain('x'.repeat(450)); }); it('gbot_thread recovers a complete long reply with full:true and normalizes malformed entries', async () => { @@ -188,18 +224,91 @@ describe('grok-bot MCP server', () => { expect(full.isError).toBe(false); const fullContent = full.structuredContent as { entries: { id: string; text: string; truncated: boolean; fullLength: number }[] }; expect(fullContent.entries[3]).toEqual({ id: 'l4', kind: 'message', text: 'x'.repeat(450), truncated: false, fullLength: 450 }); - expect(contentText(full.content)).toContain('x'.repeat(450)); + expect(contentText(full.content)).not.toContain('x'.repeat(450)); - const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Odd' }, server: 'grok-bot' }); + const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Odd' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ + cursor: 'o3', + entryCount: 3, entries: [ { id: 'o1', kind: 'note', text: '5', truncated: false, fullLength: 1 }, { id: 'o2', kind: 'mystery', text: '', truncated: false, fullLength: 0 }, { id: 'o3', kind: 'note', text: 'a�b', truncated: false, fullLength: 3 }, ], - target: { id: 'bot-4', kind: 'bot', name: 'Odd' }, + gapReset: false, + summary: '3 entries', + }); + expect(contentText(odd.content)).toBe('3 entries'); + }); + + it('gbot_thread returns tiny no-op receipts and exclusive deltas without sending after upstream', async () => { + const summary = await invokeMcpTool('gbot_thread', { input: { limit: 60, target: 'Noisy' }, server: 'grok-bot' }); + expect(summary.isError).toBe(false); + const structured = summary.structuredContent as { cursor: string; entries?: unknown[]; entryCount: number }; + expect(structured.entries).toBeUndefined(); + expect(structured.cursor).toBe('n60'); + expect(structured.entryCount).toBe(60); + expect(JSON.stringify(structured)).not.toContain('update 1'); + expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThan(4096); + const summaryText = contentText(summary.content); + expect(summaryText).toBe('60 entries'); + expect(summaryText).not.toContain('update 1'); + + const newer = await invokeMcpTool('gbot_thread', { + input: { after: 'n58', full: true, limit: 60, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(newer.isError).toBe(false); + expect(newer.structuredContent).toMatchObject({ + cursor: 'n60', + entryCount: 2, + gapReset: false, + summary: '2 new', + }); + expect((newer.structuredContent as { entries: { id: string }[] }).entries.map((entry) => entry.id)).toEqual(['n59', 'n60']); + + const unchanged = await invokeMcpTool('gbot_thread', { + input: { after: 'n60', limit: 60, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(unchanged.isError).toBe(false); + expect(unchanged.structuredContent).toMatchObject({ + cursor: 'n60', + entryCount: 0, + gapReset: false, + summary: '0 new', + }); + expect((unchanged.structuredContent as { entries?: unknown[] }).entries).toBeUndefined(); + + const full = await invokeMcpTool('gbot_thread', { + input: { full: true, limit: 60, target: 'Noisy' }, + server: 'grok-bot', }); + expect(full.isError).toBe(false); + const fullText = JSON.stringify(full.structuredContent); + expect(fullText).toContain('update 1'); + expect(contentText(full.content)).not.toContain('update 1'); + expect(Buffer.byteLength(JSON.stringify(unchanged.structuredContent))).toBeLessThan(Buffer.byteLength(fullText) / 10); + expect(calls.filter((call) => call.method === 'getAgentTranscriptTail').every((call) => !('after' in call.body))).toBe(true); + }); + + it('gbot_thread resets unknown cursors with one bounded snapshot', async () => { + const reset = await invokeMcpTool('gbot_thread', { + input: { after: 'bogus', full: true, limit: 40, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(reset.isError).toBe(false); + expect(reset.structuredContent).toMatchObject({ + cursor: 'n60', + entryCount: 40, + gapReset: true, + summary: '40 entries; gap reset', + }); + const entries = (reset.structuredContent as { entries: { id: string }[] }).entries; + expect(entries).toHaveLength(40); + expect(entries[0]?.id).toBe('n21'); + expect(entries[39]?.id).toBe('n60'); }); it('gbot_send stays unknown when the gateway confirms no receipt', async () => { @@ -232,18 +341,33 @@ describe('grok-bot MCP server', () => { it('gbot_thread enforces the requested count even when the gateway ignores the limit', async () => { const capped = await invokeMcpTool('gbot_thread', { - input: { limit: 3, target: 'Big' }, + input: { full: true, limit: 3, target: 'Big' }, server: 'grok-bot', }); expect(capped.isError).toBe(false); - const entries = (capped.structuredContent as { entries: unknown[] }).entries; - expect(entries.length).toBe(3); + const cappedContent = capped.structuredContent as { cursor: string; entries: { id: string }[] }; + expect(cappedContent.entries.map((entry) => entry.id)).toEqual(['b8', 'b9', 'b10']); + expect(cappedContent.cursor).toBe('b10'); }); it('gbot_thread bounds id/kind/role metadata that would bypass the text budget', async () => { - const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); + const summary = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); + expect(summary.isError).toBe(false); + expect(summary.structuredContent).toEqual({ + cursor: 'last-valid-id', + entryCount: 2, + gapReset: false, + summary: '2 entries', + }); + const summaryText = contentText(summary.content); + expect(summaryText).not.toContain('i'.repeat(300)); + expect(summaryText.length).toBeLessThan(300); + + const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Meta' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ + cursor: 'last-valid-id', + entryCount: 2, entries: [ { id: `${'i'.repeat(200)}…`, @@ -253,8 +377,10 @@ describe('grok-bot MCP server', () => { truncated: false, fullLength: 2, }, + { id: '', kind: 'unknown', text: '', truncated: false, fullLength: 0 }, ], - target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, + gapReset: false, + summary: '2 entries', }); }); diff --git a/src/cli.js b/src/cli.js index d2eb07d..a610822 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,7 @@ 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 { entryText, transcriptDelta, 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"; @@ -58,7 +58,7 @@ function usage() { " groups set --member ID [--member ...]", " groups delete ", " send ", - " thread [--limit N] [--root MESSAGE_ID] [--full]", + " thread [--limit N] [--after ENTRY_ID] [--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", @@ -561,16 +561,27 @@ 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 (!ref) throw new StoreError("gbot thread [--limit N] [--after ENTRY_ID] [--root MESSAGE_ID] [--full]"); const full = hasFlag(rest, "--full"); const limitRaw = takeFlag(rest, "--limit"); const rootId = takeFlag(rest, "--root"); + const after = takeFlag(rest, "--after"); const limit = limitRaw ? Number(limitRaw) : 40; if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new StoreError("--limit must be an integer 1-200"); + if (after !== undefined && (after.length === 0 || after.length > 1024)) throw new StoreError("--after must be 1-1024 characters"); + if (after !== undefined && rootId !== undefined) throw new StoreError("--after cannot be combined with --root"); 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 })); + let selected = out; + if (after !== undefined) { + const delta = transcriptDelta(out.transcript, { after, limit }); + selected = { ...out, transcript: { entries: delta.entries }, cursor: delta.cursor, entryCount: delta.entryCount, gapReset: delta.gapReset }; + } + saveHistory(selected, { dir: historyDir, disabled: noHistory, event: cmd, rootId }); + if (json) print(selected); + else { + const text = formatTranscript(selected, { full }); + print(after === undefined ? text : text + "\n\ncursor: " + selected.cursor + (selected.gapReset ? " (gap reset)" : "")); + } return; } diff --git a/src/transcript.js b/src/transcript.js index 2eb5ab1..c771f9d 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -48,3 +48,35 @@ export function transcriptEntries(payload) { const entries = payload.entries || payload.messages || payload.items; return Array.isArray(entries) ? entries : []; } + +export function sourceEntryId(entry) { + if (!entry || typeof entry !== "object") return ""; + if (typeof entry.id === "string" && entry.id) return entry.id; + return typeof entry.messageId === "string" ? entry.messageId : ""; +} + +function lastSourceId(entries, fallback = "") { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const id = sourceEntryId(entries[index]); + if (id) return id; + } + return fallback; +} + +export function transcriptDelta(payload, { after, limit = 40 } = /** @type {{ after?: string, limit?: number }} */ ({})) { + const bounded = Math.min(Math.max(Math.trunc(limit) || 40, 1), 200); + const page = transcriptEntries(payload).slice(-bounded); + if (after === undefined) { + const gapReset = page.length > 0 && !sourceEntryId(page[page.length - 1]); + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset }; + } + const afterIndex = page.findIndex((entry) => sourceEntryId(entry) === after); + if (afterIndex === -1) { + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: true }; + } + const entries = page.slice(afterIndex + 1); + if (entries.length > 0 && !sourceEntryId(entries[entries.length - 1])) { + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: true }; + } + return { cursor: lastSourceId(entries, after), entries, entryCount: entries.length, gapReset: false }; +} diff --git a/test/history.test.js b/test/history.test.js index 616272e..b1c5154 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -109,6 +109,37 @@ test("supports all transcript envelopes and text fields already displayed by the assert.deepEqual(f.rows().map((r) => r.text), ["message text", "prompt text", "content text"]); }); +test("thread --after returns exclusive deltas, no-op receipts, and bounded gap resets", async (t) => { + const f = await fixture(t); + const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); + f.state.payload = { entries }; + + const newer = JSON.parse((await f.run(["thread", "Researcher", "--limit", "45", "--after", "m43", "--json"])).stdout); + assert.deepEqual(f.calls.at(-1), { method: "/api/getAgentTranscriptTail", body: { id: target.id, limit: 45 } }); + assert.deepEqual(newer.transcript.entries, entries.slice(43)); + assert.deepEqual({ cursor: newer.cursor, entryCount: newer.entryCount, gapReset: newer.gapReset }, { + cursor: "m45", + entryCount: 2, + gapReset: false, + }); + + const unchanged = JSON.parse((await f.run(["thread", "Researcher", "--limit", "45", "--after", "m45", "--json"])).stdout); + assert.deepEqual(unchanged.transcript.entries, []); + assert.deepEqual({ cursor: unchanged.cursor, entryCount: unchanged.entryCount, gapReset: unchanged.gapReset }, { + cursor: "m45", + entryCount: 0, + gapReset: false, + }); + + const reset = JSON.parse((await f.run(["thread", "Researcher", "--limit", "40", "--after", "bogus", "--json"])).stdout); + assert.deepEqual(reset.transcript.entries, entries.slice(-40)); + assert.deepEqual({ cursor: reset.cursor, entryCount: reset.entryCount, gapReset: reset.gapReset }, { + cursor: "m45", + entryCount: 40, + gapReset: true, + }); +}); + 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"]); diff --git a/test/transcript.test.js b/test/transcript.test.js index e8a0958..ea8220a 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { entryText, transcriptEntries } from "../src/transcript.js"; +import { entryText, sourceEntryId, transcriptDelta, transcriptEntries } from "../src/transcript.js"; test("user messages read content, bot replies read message.content", () => { assert.equal(entryText({ kind: "message", role: "user", content: "hello" }), "hello"); @@ -34,3 +34,54 @@ test("entryText never throws and normalizes malformed content to safe strings", assert.equal(entryText({ text: "a\ud800b" }), "a\ufffdb"); assert.equal(entryText(null), ""); }); + +test("transcript delta uses opaque source ids and the true bounded tail", () => { + const longId = "i".repeat(300); + const entries = [ + { id: "old", text: "old" }, + { id: longId, kind: 7, text: "middle" }, + { id: "latest", text: "latest" }, + ]; + assert.equal(sourceEntryId(entries[1]), longId); + assert.deepEqual(transcriptDelta({ entries }, { limit: 2 }), { + cursor: "latest", + entries: entries.slice(1), + entryCount: 2, + gapReset: false, + }); +}); + +test("transcript delta filters exclusively after a known cursor and keeps no-op cursor", () => { + const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); + assert.deepEqual(transcriptDelta({ entries }, { after: "m43", limit: 45 }), { + cursor: "m45", + entries: entries.slice(43), + entryCount: 2, + gapReset: false, + }); + assert.deepEqual(transcriptDelta({ entries }, { after: "m45", limit: 45 }), { + cursor: "m45", + entries: [], + entryCount: 0, + gapReset: false, + }); +}); + +test("transcript delta resets when trailing idless entries cannot advance the cursor", () => { + const entries = [{ id: "seen", text: "seen" }, { text: "new but idless" }]; + assert.deepEqual(transcriptDelta({ entries }, { after: "seen", limit: 40 }), { + cursor: "seen", + entries, + entryCount: 2, + gapReset: true, + }); +}); + +test("transcript delta resets an unknown cursor with one bounded snapshot", () => { + const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); + const delta = transcriptDelta({ entries }, { after: "bogus", limit: 40 }); + assert.equal(delta.gapReset, true); + assert.equal(delta.entryCount, 40); + assert.deepEqual(delta.entries, entries.slice(-40)); + assert.equal(delta.cursor, "m45"); +});