diff --git a/README.md b/README.md index 8481f8c..6db9ae7 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ By default, data commands return markdown. Use `--json` to print raw JSON. - `conversations` - Access your recorded conversations. - `conversations list` - List conversations. Options: `--limit N`, `--cursor `, `--json`. - `conversations get ` - Get a specific conversation with full transcript. Options: `--json`. - - `conversations transcript ` - Get just the transcript utterances. Options: `--json`. + - `conversations transcript ` - Get just the transcript utterances. Use `--since ` to return only utterances spoken at or after a given time (epoch milliseconds) — handy for polling a live transcript for just the new utterances. Options: `--since `, `--json`. - `conversations related ` - Find conversations related to one. Options: `--limit N`, `--json`. - `daily` - Access daily summaries of your activity. diff --git a/sources/mcp/toolSnapshot.ts b/sources/mcp/toolSnapshot.ts index c927d6f..a755567 100644 --- a/sources/mcp/toolSnapshot.ts +++ b/sources/mcp/toolSnapshot.ts @@ -280,6 +280,10 @@ export const TOOL_SNAPSHOT: ToolDefinition[] = [ "id": { "type": "number", "description": "Bee conversation ID." + }, + "since": { + "type": "number", + "description": "Only return utterances spoken at or after this time (epoch milliseconds). Lets a live transcript watcher poll for just new utterances without tracking which it has already seen. Utterances with no timestamp are excluded when set." } }, "required": [ diff --git a/sources/resources/conversations/index.test.ts b/sources/resources/conversations/index.test.ts index 19f4228..1401468 100644 --- a/sources/resources/conversations/index.test.ts +++ b/sources/resources/conversations/index.test.ts @@ -296,6 +296,77 @@ describe("conversations command (registry-derived)", () => { expect(out).toContain("# Conversation Transcript"); }); + it("filters transcript utterances by --since, dropping older and null-timestamp utterances", async () => { + const ctx = proxyContext(() => + Response.json({ + conversation: { + id: 4, + transcriptions: [ + { + id: 1, + realtime: false, + utterances: [ + { id: 1, text: "older", speaker: "SPEAKER_1", spoken_at: 1_000, start: null }, + { id: 2, text: "boundary", speaker: "SPEAKER_1", spoken_at: 2_000, start: null }, + { id: 3, text: "newer", speaker: "SPEAKER_1", spoken_at: 3_000, start: null }, + // No spoken_at: falls back to start, which is at/after the bound. + { id: 4, text: "start-only", speaker: "SPEAKER_1", spoken_at: null, start: 2_500 }, + // Neither timestamp: cannot be ordered, excluded when --since is set. + { id: 5, text: "no-timestamp", speaker: "SPEAKER_1", spoken_at: null, start: null }, + ], + }, + ], + }, + }) + ); + const logs: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...a) => { logs.push(a.join(" ")); }); + try { + await conversationsCommand.run(["transcript", "4", "--since", "2000", "--json"], ctx); + } finally { + spy.mockRestore(); + } + const parsed = JSON.parse(logs.join("\n")) as { + since: number; + transcript: Array<{ id: number }>; + }; + expect(parsed.since).toBe(2_000); + expect(parsed.transcript.map((utterance) => utterance.id)).toEqual([2, 3, 4]); + }); + + it("leaves the transcript unfiltered and omits since when --since is absent", async () => { + const ctx = proxyContext(() => + Response.json({ + conversation: { + id: 4, + transcriptions: [ + { + id: 1, + realtime: false, + utterances: [ + { id: 1, text: "older", speaker: "SPEAKER_1", spoken_at: 1_000, start: null }, + { id: 2, text: "no-timestamp", speaker: "SPEAKER_1", spoken_at: null, start: null }, + ], + }, + ], + }, + }) + ); + const logs: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...a) => { logs.push(a.join(" ")); }); + try { + await conversationsCommand.run(["transcript", "4", "--json"], ctx); + } finally { + spy.mockRestore(); + } + const parsed = JSON.parse(logs.join("\n")) as { + since?: number; + transcript: Array<{ id: number }>; + }; + expect(parsed.since).toBeUndefined(); + expect(parsed.transcript.map((utterance) => utterance.id)).toEqual([1, 2]); + }); + // ---- related success path ------------------------------------------------- it("renders related conversations from /v1/conversations/:id/related", async () => { diff --git a/sources/resources/conversations/index.ts b/sources/resources/conversations/index.ts index aa80d3f..96ee6e7 100644 --- a/sources/resources/conversations/index.ts +++ b/sources/resources/conversations/index.ts @@ -22,7 +22,7 @@ import { const USAGE = [ "bee conversations list [--limit N] [--cursor ] [--json]", "bee conversations get [--json]", - "bee conversations transcript [--json]", + "bee conversations transcript [--since ] [--json]", "bee conversations related [--limit N] [--json]", ].join("\n"); @@ -203,7 +203,10 @@ const getConversation: ActionDefinition = { // ---- transcript (= bee_get_conversation_transcript) ------------------------- -type ConversationTranscriptInput = { id: number }; +// `since` (epoch ms) is an optional lower bound on utterance timestamps. It is +// carried as an optional so the unfiltered path (no --since) stays byte-for-byte +// unchanged; only when set do we drop older utterances and surface the bound. +type ConversationTranscriptInput = { id: number; since: number | undefined }; const getConversationTranscript: ActionDefinition = { mcp: { @@ -211,14 +214,22 @@ const getConversationTranscript: ActionDefinition = description: "Get ASR transcript utterances for one captured Bee conversation. Use only when transcript detail is needed; avoid direct quotes unless surrounding context gives high confidence.", inputSchema: objectSchema({ - properties: { id: idNumber("Bee conversation ID.") }, + properties: { + id: idNumber("Bee conversation ID."), + since: { + type: "number", + description: + "Only return utterances spoken at or after this time (epoch milliseconds). Lets a live transcript watcher poll for just new utterances without tracking which it has already seen. Utterances with no timestamp are excluded when set.", + }, + }, required: ["id"], }), }, cli: { subcommand: "transcript", positionals: [{ name: "id", required: false }], - flags: [], + // --since is an int (epoch milliseconds), matching `bee search --since`. + flags: [{ name: "--since", kind: "int" }], render: (result, format) => { if (result.kind !== "json") { return; @@ -226,17 +237,44 @@ const getConversationTranscript: ActionDefinition = printToolData("Conversation Transcript", result.data, format); }, }, - coerceInput: (raw, surface) => ({ id: coerceConversationId(raw["id"], surface) }), + coerceInput: (raw, surface) => ({ + id: coerceConversationId(raw["id"], surface), + // CLI: the argv parser already produced a number (or omitted). MCP: accept a + // native finite number, otherwise treat as absent. Either way an absent value + // is undefined, which leaves the transcript unfiltered. + since: surface === "cli" + ? (typeof raw["since"] === "number" ? raw["since"] : undefined) + : (typeof raw["since"] === "number" && Number.isFinite(raw["since"]) ? raw["since"] : undefined), + }), run: async (ctx, input) => { const data = asRecord(parseJson(await apiGet(ctx, `/v1/conversations/${input.id}`))); const conversation = asRecord(data.conversation); - const transcript = arrayProp(conversation, "transcriptions").flatMap((transcription) => { + let transcript = arrayProp(conversation, "transcriptions").flatMap((transcription) => { return arrayProp(asRecord(transcription), "utterances"); }); + // When --since is set, keep only utterances at or after it, ordered by the + // SAME timestamp key the detail document uses to sort utterances + // (spoken_at ?? start ?? 0). Utterances with neither spoken_at nor start are + // excluded: with no timestamp they cannot be ordered against the bound, so a + // watcher would re-emit them on every poll. Omitting --since leaves the + // transcript untouched. + if (input.since !== undefined) { + const since = input.since; + transcript = transcript.filter((utterance) => { + const record = asRecord(utterance); + const spokenAt = typeof record["spoken_at"] === "number" ? record["spoken_at"] : null; + const start = typeof record["start"] === "number" ? record["start"] : null; + const timestamp = spokenAt ?? start; + return timestamp !== null && timestamp >= since; + }); + } return { kind: "json", data: { conversationId: input.id, + // Echo back the applied lower bound so callers can confirm the filter ran; + // omitted entirely when no --since was supplied. + ...(input.since !== undefined ? { since: input.since } : {}), transcript, note: "Transcript text is ASR output and may contain recognition errors. Avoid direct quotes unless surrounding Bee context gives high confidence.", },