refactor: move transcripts into core

Move meeting notes into core transcripts, remove the bundled meeting-notes plugin/API, and require explicit transcripts.enabled before exposing the recording-capable tool.
This commit is contained in:
Peter Steinberger
2026-05-26 14:51:11 +01:00
committed by GitHub
parent 45feb37b13
commit cac0b2db18
94 changed files with 1008 additions and 1286 deletions

70
src/transcripts/config.ts Normal file
View File

@@ -0,0 +1,70 @@
import { normalizeOptionalString as readString } from "../shared/string-coerce.js";
export type TranscriptsAutoStartConfig = {
providerId: string;
sessionId?: string;
title?: string;
accountId?: string;
guildId?: string;
channelId?: string;
meetingUrl?: string;
};
export type ResolvedTranscriptsAutoStartConfig = {
providerId: string;
sessionId?: string;
title?: string;
accountId?: string;
guildId?: string;
channelId?: string;
meetingUrl?: string;
};
export type TranscriptsConfig = {
enabled?: boolean;
maxUtterances?: number;
autoStart?: TranscriptsAutoStartConfig[];
};
export type ResolvedTranscriptsConfig = {
enabled: boolean;
maxUtterances: number;
autoStart: ResolvedTranscriptsAutoStartConfig[];
};
function resolveAutoStart(raw: unknown): ResolvedTranscriptsAutoStartConfig[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry): ResolvedTranscriptsAutoStartConfig | undefined => {
const config = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
const providerId = readString(config.providerId);
if (!providerId) {
return undefined;
}
return {
providerId,
sessionId: readString(config.sessionId),
title: readString(config.title),
accountId: readString(config.accountId),
guildId: readString(config.guildId),
channelId: readString(config.channelId),
meetingUrl: readString(config.meetingUrl),
};
})
.filter((entry): entry is ResolvedTranscriptsAutoStartConfig => entry !== undefined);
}
export function resolveTranscriptsConfig(raw: unknown): ResolvedTranscriptsConfig {
const config = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const maxUtterances =
typeof config.maxUtterances === "number" && Number.isFinite(config.maxUtterances)
? Math.max(1, Math.min(10_000, Math.floor(config.maxUtterances)))
: 2_000;
return {
enabled: config.enabled === true,
maxUtterances,
autoStart: resolveAutoStart(config.autoStart),
};
}

View File

@@ -0,0 +1,33 @@
import type { TranscriptSourceProvider } from "./provider-types.js";
function parseSpeakerLine(line: string): { speakerLabel?: string; text: string } {
const match = /^([^:\n]{1,80}):\s+(.+)$/.exec(line.trim());
if (!match) {
return { text: line.trim() };
}
return { speakerLabel: match[1]?.trim(), text: match[2]?.trim() ?? "" };
}
export const manualTranscriptSourceProvider: TranscriptSourceProvider = {
id: "manual-transcript",
aliases: ["import", "transcript"],
name: "Manual Transcript Import",
sourceKinds: ["posthoc-transcript"],
async importTranscript(request) {
const now = new Date().toISOString();
return request.text
.split(/\r?\n/)
.map((line) => parseSpeakerLine(line))
.filter((entry) => entry.text)
.map((entry, index) => ({
id: `${request.session.sessionId}-${index + 1}`,
sessionId: request.session.sessionId,
startedAt: now,
final: true,
speaker: {
label: entry.speakerLabel ?? request.speakerLabel ?? "Speaker",
},
text: entry.text,
}));
},
};

View File

@@ -0,0 +1,53 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolvePluginCapabilityProvider,
resolvePluginCapabilityProviders,
} from "../plugins/capability-provider-runtime.js";
import {
buildCapabilityProviderMaps,
normalizeCapabilityProviderId,
} from "../plugins/provider-registry-shared.js";
import type { TranscriptSourceProvider } from "./provider-types.js";
export function normalizeTranscriptSourceProviderId(
providerId: string | undefined,
): string | undefined {
return normalizeCapabilityProviderId(providerId);
}
function resolveTranscriptsSourceProviderEntries(cfg?: OpenClawConfig): TranscriptSourceProvider[] {
return resolvePluginCapabilityProviders({
key: "transcriptSourceProviders",
cfg,
});
}
function buildProviderMaps(cfg?: OpenClawConfig): {
canonical: Map<string, TranscriptSourceProvider>;
aliases: Map<string, TranscriptSourceProvider>;
} {
return buildCapabilityProviderMaps(resolveTranscriptsSourceProviderEntries(cfg));
}
export function listTranscriptSourceProviders(cfg?: OpenClawConfig): TranscriptSourceProvider[] {
return [...buildProviderMaps(cfg).canonical.values()];
}
export function getTranscriptSourceProvider(
providerId: string | undefined,
cfg?: OpenClawConfig,
): TranscriptSourceProvider | undefined {
const normalized = normalizeTranscriptSourceProviderId(providerId);
if (!normalized) {
return undefined;
}
const directProvider = resolvePluginCapabilityProvider({
key: "transcriptSourceProviders",
providerId: normalized,
cfg,
});
if (directProvider) {
return directProvider;
}
return buildProviderMaps(cfg).aliases.get(normalized);
}

View File

@@ -0,0 +1,109 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
export type TranscriptSourceKind =
| "live-audio"
| "live-caption"
| "posthoc-transcript"
| "recording-stt";
export type TranscriptSourceLocator = {
providerId: string;
kind?: TranscriptSourceKind;
accountId?: string;
guildId?: string;
channelId?: string;
meetingUrl?: string;
threadTs?: string;
fileId?: string;
[key: string]: string | undefined;
};
export type TranscriptParticipant = {
id?: string;
label: string;
};
export type TranscriptUtterance = {
id?: string;
sessionId?: string;
startedAt?: string;
endedAt?: string;
speaker?: TranscriptParticipant;
text: string;
final?: boolean;
metadata?: Record<string, unknown>;
};
export type TranscriptSessionDescriptor = {
sessionId: string;
title?: string;
source: TranscriptSourceLocator;
startedAt: string;
stoppedAt?: string;
metadata?: Record<string, unknown>;
};
export type TranscriptStartRequest = {
cfg?: OpenClawConfig;
session: TranscriptSessionDescriptor;
abortSignal?: AbortSignal;
startupWaitMs?: number;
onUtterance: (utterance: TranscriptUtterance) => void | Promise<void>;
onStatus?: (status: TranscriptSourceStatus) => void | Promise<void>;
};
export type TranscriptsStartResult =
| {
ok: true;
session: TranscriptSessionDescriptor;
}
| {
ok: false;
error: string;
};
export type TranscriptStopRequest = {
cfg?: OpenClawConfig;
sessionId: string;
source: TranscriptSourceLocator;
reason?: string;
};
export type TranscriptsStopResult =
| {
ok: true;
sessionId: string;
stoppedAt?: string;
}
| {
ok: false;
error: string;
};
export type TranscriptSourceStatus = {
sessionId?: string;
active: boolean;
message?: string;
source?: TranscriptSourceLocator;
};
export type TranscriptImportRequest = {
cfg?: OpenClawConfig;
session: TranscriptSessionDescriptor;
text: string;
speakerLabel?: string;
};
export type TranscriptSourceProvider = {
id: string;
aliases?: readonly string[];
name: string;
sourceKinds: readonly TranscriptSourceKind[];
start?: (request: TranscriptStartRequest) => Promise<TranscriptsStartResult>;
stop?: (request: TranscriptStopRequest) => Promise<TranscriptsStopResult>;
status?: (
source: TranscriptSourceLocator,
cfg?: OpenClawConfig,
) => Promise<TranscriptSourceStatus[]>;
importTranscript?: (request: TranscriptImportRequest) => Promise<TranscriptUtterance[]>;
};

272
src/transcripts/store.ts Normal file
View File

@@ -0,0 +1,272 @@
import { createReadStream } from "node:fs";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { createInterface } from "node:readline";
import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js";
import type { TranscriptsSummary } from "./summary.js";
import { renderTranscriptsMarkdown } from "./summary.js";
export type TranscriptsSessionEntry = {
session: TranscriptSessionDescriptor;
sessionDir: string;
};
function safeSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "session";
}
function dateSegment(value: string | undefined): string {
const isoDate = value?.match(/^(\d{4}-\d{2}-\d{2})T/)?.[1];
return isoDate ?? new Date().toISOString().slice(0, 10);
}
async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
try {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return undefined;
}
throw err;
}
}
function normalizeMaxUtterances(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return Math.max(1, Math.floor(value));
}
function sameSessionIdentity(
left: TranscriptSessionDescriptor,
right: TranscriptSessionDescriptor,
): boolean {
return left.sessionId === right.sessionId && left.startedAt === right.startedAt;
}
export class TranscriptsStore {
constructor(private readonly rootDir: string) {}
sessionDir(session: TranscriptSessionDescriptor): string {
return path.join(this.rootDir, dateSegment(session.startedAt), safeSegment(session.sessionId));
}
private async hasSessionMetadata(dir: string): Promise<boolean> {
return (await readJsonFile<unknown>(path.join(dir, "metadata.json"))) !== undefined;
}
private async findSessionDirForSession(session: TranscriptSessionDescriptor): Promise<string> {
const datedDir = this.sessionDir(session);
const datedSession = await readJsonFile<TranscriptSessionDescriptor>(
path.join(datedDir, "metadata.json"),
);
if (datedSession && sameSessionIdentity(datedSession, session)) {
return datedDir;
}
return datedDir;
}
private async findSessionDir(selector: string): Promise<string | undefined> {
const qualified = selector.match(/^(\d{4}-\d{2}-\d{2})\/(.+)$/);
if (qualified?.[1] && qualified[2]) {
const directDir = path.join(this.rootDir, qualified[1], safeSegment(qualified[2]));
return (await this.hasSessionMetadata(directDir)) ? directDir : undefined;
}
const safeSessionId = safeSegment(selector);
const idDate = selector
.match(/^meeting-(\d{4})-(\d{2})-(\d{2})T/)
?.slice(1, 4)
.join("-");
if (idDate) {
const directDir = path.join(this.rootDir, idDate, safeSessionId);
return (await this.hasSessionMetadata(directDir)) ? directDir : undefined;
}
let entries: Dirent[];
try {
entries = await fs.readdir(this.rootDir, { withFileTypes: true });
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return undefined;
}
throw err;
}
const datedEntries = entries
.filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry.name))
.toSorted((left, right) => right.name.localeCompare(left.name));
const matches: string[] = [];
for (const entry of datedEntries) {
const candidate = path.join(this.rootDir, entry.name, safeSessionId);
const session = await readJsonFile<TranscriptSessionDescriptor>(
path.join(candidate, "metadata.json"),
);
if (session?.sessionId === selector) {
matches.push(candidate);
}
}
if (matches.length > 1) {
throw new Error(
`multiple transcripts sessions match ${selector}; use a YYYY-MM-DD/${selector} selector`,
);
}
return matches[0];
}
async writeSession(session: TranscriptSessionDescriptor): Promise<void> {
const dir = this.sessionDir(session);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "metadata.json"), `${JSON.stringify(session, null, 2)}\n`);
}
async readSession(sessionId: string): Promise<TranscriptSessionDescriptor | undefined> {
return (await this.readSessionEntry(sessionId))?.session;
}
async readSessionEntry(sessionId: string): Promise<TranscriptsSessionEntry | undefined> {
const dir = await this.findSessionDir(sessionId);
if (!dir) {
return undefined;
}
const session = await readJsonFile<TranscriptSessionDescriptor>(
path.join(dir, "metadata.json"),
);
return session ? { session, sessionDir: dir } : undefined;
}
async appendUtterance(sessionId: string, utterance: TranscriptUtterance): Promise<void> {
const dir =
(await this.findSessionDir(sessionId)) ??
path.join(this.rootDir, dateSegment(sessionId), safeSegment(sessionId));
await this.appendUtteranceToDir(dir, sessionId, utterance);
}
async appendUtteranceForSession(
session: TranscriptSessionDescriptor,
utterance: TranscriptUtterance,
): Promise<void> {
const dir = await this.findSessionDirForSession(session);
await this.appendUtteranceToDir(dir, session.sessionId, utterance);
}
private async appendUtteranceToDir(
dir: string,
sessionId: string,
utterance: TranscriptUtterance,
): Promise<void> {
await fs.mkdir(dir, { recursive: true });
await fs.appendFile(
path.join(dir, "transcript.jsonl"),
`${JSON.stringify({ ...utterance, sessionId })}\n`,
);
}
async readUtterancesForSession(
session: TranscriptSessionDescriptor,
options: { maxUtterances?: number } = {},
): Promise<TranscriptUtterance[]> {
return await this.readUtterancesFromDir(await this.findSessionDirForSession(session), options);
}
async readUtterancesFromSessionDir(
sessionDir: string,
options: { maxUtterances?: number } = {},
): Promise<TranscriptUtterance[]> {
return await this.readUtterancesFromDir(sessionDir, options);
}
async readUtterances(
sessionId: string,
options: { maxUtterances?: number } = {},
): Promise<TranscriptUtterance[]> {
const dir = await this.findSessionDir(sessionId);
if (!dir) {
return [];
}
return await this.readUtterancesFromDir(dir, options);
}
private async readUtterancesFromDir(
dir: string,
options: { maxUtterances?: number } = {},
): Promise<TranscriptUtterance[]> {
const transcriptPath = path.join(dir, "transcript.jsonl");
const maxUtterances = normalizeMaxUtterances(options.maxUtterances);
if (maxUtterances !== undefined) {
const utterances: TranscriptUtterance[] = [];
try {
const lines = createInterface({
input: createReadStream(transcriptPath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
for await (const line of lines) {
if (!line) {
continue;
}
utterances.push(JSON.parse(line) as TranscriptUtterance);
if (utterances.length > maxUtterances) {
utterances.shift();
}
}
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return [];
}
throw err;
}
return utterances;
}
let raw: string;
try {
raw = await fs.readFile(transcriptPath, "utf8");
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return [];
}
throw err;
}
return raw
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as TranscriptUtterance);
}
async updateStopped(sessionId: string, stoppedAt: string): Promise<void> {
const dir = await this.findSessionDir(sessionId);
if (!dir) {
return;
}
const session = await readJsonFile<TranscriptSessionDescriptor>(
path.join(dir, "metadata.json"),
);
if (!session) {
return;
}
await fs.writeFile(
path.join(dir, "metadata.json"),
`${JSON.stringify({ ...session, stoppedAt }, null, 2)}\n`,
);
}
async writeSummary(
summary: TranscriptsSummary,
session?: TranscriptSessionDescriptor,
): Promise<string> {
const dir =
session !== undefined
? await this.findSessionDirForSession(session)
: ((await this.findSessionDir(summary.sessionId)) ??
path.join(this.rootDir, dateSegment(summary.sessionId), safeSegment(summary.sessionId)));
return await this.writeSummaryToDir(summary, dir);
}
async writeSummaryToDir(summary: TranscriptsSummary, dir: string): Promise<string> {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
const markdown = renderTranscriptsMarkdown(summary);
const markdownPath = path.join(dir, "summary.md");
await fs.writeFile(markdownPath, `${markdown}\n`);
return markdownPath;
}
}

View File

@@ -0,0 +1,96 @@
import { normalizeStringEntries } from "../shared/string-normalization.js";
import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js";
export type TranscriptsSummary = {
sessionId: string;
title: string;
generatedAt: string;
overview: string;
transcript: string[];
decisions: string[];
actionItems: string[];
risks: string[];
utteranceCount: number;
};
const ACTION_PATTERNS =
/\b(todo|action|follow up|follow-up|assign|owner|next step|ship|fix|send|schedule)\b/i;
const DECISION_PATTERNS = /\b(decided|decision|we will|we'll|agreed|approved|go with|ship it)\b/i;
const RISK_PATTERNS =
/\b(risk|blocked|blocker|concern|issue|problem|unknown|deadline|privacy|security)\b/i;
function firstSentences(utterances: TranscriptUtterance[], limit: number): string {
const text = normalizeStringEntries(utterances.map((utterance) => utterance.text)).join(" ");
const sentences = text.match(/[^.!?]+[.!?]?/g) ?? [];
return normalizeStringEntries(sentences.slice(0, limit)).join(" ");
}
function collectMatches(utterances: TranscriptUtterance[], pattern: RegExp): string[] {
return utterances
.filter((utterance) => pattern.test(utterance.text))
.map(formatSpeakerLine)
.filter(Boolean)
.slice(0, 12);
}
function formatSpeakerLine(utterance: TranscriptUtterance): string {
const text = utterance.text.trim();
if (!text) {
return "";
}
const speaker = utterance.speaker?.label?.trim();
return speaker ? `${speaker}: ${text}` : text;
}
function formatTranscript(utterances: TranscriptUtterance[]): string[] {
return utterances.map(formatSpeakerLine).filter(Boolean);
}
export function summarizeTranscripts(params: {
session: TranscriptSessionDescriptor;
utterances: TranscriptUtterance[];
}): TranscriptsSummary {
const title = params.session.title?.trim() || "Transcripts";
const overview = firstSentences(params.utterances, 4) || "No transcript captured yet.";
return {
sessionId: params.session.sessionId,
title,
generatedAt: new Date().toISOString(),
overview,
transcript: formatTranscript(params.utterances),
decisions: collectMatches(params.utterances, DECISION_PATTERNS),
actionItems: collectMatches(params.utterances, ACTION_PATTERNS),
risks: collectMatches(params.utterances, RISK_PATTERNS),
utteranceCount: params.utterances.length,
};
}
function renderList(items: string[]): string {
return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- None captured";
}
export function renderTranscriptsMarkdown(summary: TranscriptsSummary): string {
return [
`# ${summary.title}`,
"",
`Generated: ${summary.generatedAt}`,
`Session: ${summary.sessionId}`,
"",
"## Overview",
summary.overview,
"",
"## Transcript",
renderList(summary.transcript),
"",
"## Decisions",
renderList(summary.decisions),
"",
"## Action Items",
renderList(summary.actionItems),
"",
"## Risks",
renderList(summary.risks),
"",
`Transcript utterances: ${summary.utteranceCount}`,
].join("\n");
}