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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cursor>`, `--json`.
- `conversations get <id>` - Get a specific conversation with full transcript. Options: `--json`.
- `conversations transcript <id>` - Get just the transcript utterances. Options: `--json`.
- `conversations transcript <id>` - Get just the transcript utterances. Use `--since <epochMs>` 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 <epochMs>`, `--json`.
- `conversations related <id>` - Find conversations related to one. Options: `--limit N`, `--json`.

- `daily` - Access daily summaries of your activity.
Expand Down
4 changes: 4 additions & 0 deletions sources/mcp/toolSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
71 changes: 71 additions & 0 deletions sources/resources/conversations/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
50 changes: 44 additions & 6 deletions sources/resources/conversations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
const USAGE = [
"bee conversations list [--limit N] [--cursor <cursor>] [--json]",
"bee conversations get <id> [--json]",
"bee conversations transcript <id> [--json]",
"bee conversations transcript <id> [--since <epochMs>] [--json]",
"bee conversations related <id> [--limit N] [--json]",
].join("\n");

Expand Down Expand Up @@ -203,40 +203,78 @@ const getConversation: ActionDefinition<ConversationGetInput> = {

// ---- 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<ConversationTranscriptInput> = {
mcp: {
name: "bee_get_conversation_transcript",
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;
}
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.",
},
Expand Down