From cac0b2db18ea962fca43da13140c418ef4225863 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 14:51:11 +0100 Subject: [PATCH] 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. --- docs/cli/index.md | 6 +- docs/cli/{meeting-notes.md => transcripts.md} | 95 +++-- docs/docs.json | 1 - docs/plugins/architecture.md | 2 +- docs/plugins/manifest.md | 2 +- docs/plugins/meeting-notes.md | 359 ------------------ docs/plugins/plugin-inventory.md | 13 +- docs/plugins/reference.md | 3 +- docs/plugins/reference/discord.md | 2 +- docs/plugins/reference/meeting-notes.md | 23 -- docs/plugins/sdk-subpaths.md | 4 +- extensions/discord/index.ts | 4 +- .../discord/meeting-notes-source-api.ts | 1 - extensions/discord/openclaw.plugin.json | 2 +- .../discord/src/monitor/provider.lifecycle.ts | 5 +- extensions/discord/src/monitor/provider.ts | 5 +- .../discord/src/voice/manager.e2e.test.ts | 58 +-- extensions/discord/src/voice/manager.ts | 28 +- extensions/discord/src/voice/realtime.ts | 16 +- extensions/discord/src/voice/segment.ts | 8 +- extensions/discord/src/voice/session.ts | 6 +- ...rce.test.ts => transcripts-source.test.ts} | 34 +- ...-notes-source.ts => transcripts-source.ts} | 24 +- extensions/discord/transcripts-source-api.ts | 1 + extensions/meeting-notes/index.ts | 29 -- extensions/meeting-notes/openclaw.plugin.json | 80 ---- extensions/meeting-notes/package.json | 21 - package.json | 7 +- pnpm-lock.yaml | 10 - scripts/lib/plugin-sdk-entrypoints.json | 2 +- src/agents/openclaw-tools.ts | 4 + src/agents/openclaw-tools.update-plan.test.ts | 12 + ...ded-subscribe.handlers.tools.media.test.ts | 4 +- .../pi-embedded-subscribe.tools.media.test.ts | 12 +- .../agents/tools/transcripts-tool.test.ts | 133 +++---- .../agents/tools/transcripts-tool.ts | 238 ++++++------ .../reply/commands-diagnostics.test.ts | 2 +- src/cli/program/command-registry-core.ts | 5 + src/cli/program/core-command-descriptors.ts | 5 + .../cli/program/register.transcripts.test.ts | 38 +- .../cli/program/register.transcripts.ts | 93 +++-- src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 22 ++ src/config/schema.labels.ts | 11 + src/config/types.openclaw.ts | 2 + src/config/zod-schema.ts | 22 ++ src/gateway/server-close.test.ts | 41 ++ src/gateway/server-close.ts | 7 + src/gateway/server-plugins.test.ts | 2 +- src/gateway/server-reload-handlers.ts | 4 +- .../server-startup-post-attach.test.ts | 73 +++- src/gateway/server-startup-post-attach.ts | 48 ++- src/gateway/server.impl.ts | 16 +- src/gateway/test-helpers.plugin-registry.ts | 2 +- src/meeting-notes/provider-types.ts | 109 ------ src/plugin-sdk/entrypoints.ts | 1 - src/plugin-sdk/meeting-notes.ts | 19 - src/plugin-sdk/plugin-entry.ts | 4 +- src/plugin-sdk/plugin-test-api.ts | 2 +- .../plugin-registration-contract.ts | 10 +- src/plugin-sdk/transcripts.ts | 19 + .../plugin-state-store.runtime.test.ts | 2 +- src/plugins/api-builder.ts | 8 +- .../bundled-capability-metadata.test.ts | 2 +- src/plugins/bundled-capability-runtime.ts | 10 +- src/plugins/capability-provider-runtime.ts | 6 +- src/plugins/captured-registration.ts | 12 +- src/plugins/commands.test.ts | 2 +- .../inventory/bundled-capability-metadata.ts | 8 +- .../contracts/registry.contract.test.ts | 12 +- src/plugins/contracts/registry.ts | 33 +- .../contracts/speech-vitest-registry.ts | 18 +- src/plugins/hooks.test-helpers.ts | 2 +- src/plugins/inspect-shape.ts | 4 +- src/plugins/loader-records.ts | 2 +- src/plugins/loader.runtime-registry.test.ts | 2 +- src/plugins/loader.ts | 6 +- src/plugins/manifest-registry.ts | 4 +- src/plugins/manifest.ts | 6 +- src/plugins/registry-empty.ts | 2 +- src/plugins/registry-types.ts | 10 +- src/plugins/registry.ts | 18 +- src/plugins/status.test-helpers.ts | 4 +- src/plugins/status.ts | 2 +- src/plugins/types.ts | 12 +- src/test-utils/channel-plugins.ts | 2 +- src/trajectory/metadata.test.ts | 2 +- .../src => src/transcripts}/config.ts | 40 +- .../src => src/transcripts}/manual-source.ts | 4 +- .../provider-registry.ts | 28 +- src/transcripts/provider-types.ts | 109 ++++++ .../src => src/transcripts}/store.ts | 71 ++-- .../src => src/transcripts}/summary.ts | 29 +- .../bundled-plugin-build-entries.test.ts | 10 - 94 files changed, 1008 insertions(+), 1286 deletions(-) rename docs/cli/{meeting-notes.md => transcripts.md} (53%) delete mode 100644 docs/plugins/meeting-notes.md delete mode 100644 docs/plugins/reference/meeting-notes.md delete mode 100644 extensions/discord/meeting-notes-source-api.ts rename extensions/discord/src/voice/{meeting-notes-source.test.ts => transcripts-source.test.ts} (74%) rename extensions/discord/src/voice/{meeting-notes-source.ts => transcripts-source.ts} (84%) create mode 100644 extensions/discord/transcripts-source-api.ts delete mode 100644 extensions/meeting-notes/index.ts delete mode 100644 extensions/meeting-notes/openclaw.plugin.json delete mode 100644 extensions/meeting-notes/package.json rename extensions/meeting-notes/index.test.ts => src/agents/tools/transcripts-tool.test.ts (70%) rename extensions/meeting-notes/src/tool.ts => src/agents/tools/transcripts-tool.ts (67%) rename extensions/meeting-notes/src/cli.test.ts => src/cli/program/register.transcripts.test.ts (73%) rename extensions/meeting-notes/src/cli.ts => src/cli/program/register.transcripts.ts (72%) delete mode 100644 src/meeting-notes/provider-types.ts delete mode 100644 src/plugin-sdk/meeting-notes.ts create mode 100644 src/plugin-sdk/transcripts.ts rename {extensions/meeting-notes/src => src/transcripts}/config.ts (59%) rename {extensions/meeting-notes/src => src/transcripts}/manual-source.ts (84%) rename src/{meeting-notes => transcripts}/provider-registry.ts (53%) create mode 100644 src/transcripts/provider-types.ts rename {extensions/meeting-notes/src => src/transcripts}/store.ts (77%) rename {extensions/meeting-notes/src => src/transcripts}/summary.ts (72%) diff --git a/docs/cli/index.md b/docs/cli/index.md index 3204c30d3c9..8b37b1a3af5 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -30,12 +30,12 @@ Use the setup commands by intent: | Models and inference | [`models`](/cli/models) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`commitments`](/cli/commitments) · [`wiki`](/cli/wiki) | | Network and nodes | [`directory`](/cli/directory) · [`nodes`](/cli/nodes) · [`devices`](/cli/devices) · [`node`](/cli/node) | | Runtime and sandbox | [`approvals`](/cli/approvals) · `exec-policy` (see [`approvals`](/cli/approvals)) · [`sandbox`](/cli/sandbox) · [`tui`](/cli/tui) · `chat`/`terminal` (aliases for [`tui --local`](/cli/tui)) · [`browser`](/cli/browser) | -| Automation | [`cron`](/cli/cron) · [`tasks`](/cli/tasks) · [`hooks`](/cli/hooks) · [`webhooks`](/cli/webhooks) | +| Automation | [`cron`](/cli/cron) · [`tasks`](/cli/tasks) · [`hooks`](/cli/hooks) · [`webhooks`](/cli/webhooks) · [`transcripts`](/cli/transcripts) | | Discovery and docs | [`dns`](/cli/dns) · [`docs`](/cli/docs) | | Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) | | Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) | | Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) | -| Plugins (optional) | [`meeting-notes`](/cli/meeting-notes) · [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) (if installed) | +| Plugins (optional) | [`path`](/cli/path) · [`policy`](/cli/policy) · [`voicecall`](/cli/voicecall) (if installed) | ## Global flags @@ -128,7 +128,7 @@ openclaw [--dev] [--profile ] status index search - meeting-notes + transcripts list show path diff --git a/docs/cli/meeting-notes.md b/docs/cli/transcripts.md similarity index 53% rename from docs/cli/meeting-notes.md rename to docs/cli/transcripts.md index 6f383a29a2c..4b5145b4a05 100644 --- a/docs/cli/meeting-notes.md +++ b/docs/cli/transcripts.md @@ -1,18 +1,17 @@ --- -summary: "CLI reference for `openclaw meeting-notes` (list, show, and locate stored meeting notes)" +summary: "CLI reference for `openclaw transcripts` (list, show, and locate stored transcripts)" read_when: - - You want to read stored meeting note summaries from the terminal - - You need the path to a meeting notes markdown summary - - You are debugging the meeting-notes plugin storage layout -title: "Meeting Notes CLI" + - You want to read stored transcript summaries from the terminal + - You need the path to a transcripts markdown summary + - You are debugging the core transcripts storage layout +title: "Transcripts CLI" --- -# `openclaw meeting-notes` +# `openclaw transcripts` -Inspect meeting notes written by the external `meeting-notes` plugin. This CLI -is read-only and is available when that plugin is installed or loaded from -source. Capture, import, and summarization are owned by the `meeting_notes` -agent tool and by configured auto-start sources. +Inspect transcripts written by OpenClaw's core `transcripts` tool. This CLI is +read-only; capture, import, and summarization are owned by the agent tool and +configured auto-start sources. Use the CLI when you want to find yesterday's notes, open the Markdown file in an editor, feed a transcript to another tool, or debug where a session landed on @@ -21,7 +20,7 @@ disk. It does not start or stop capture. Artifacts live under the OpenClaw state directory: ```text -$OPENCLAW_STATE_DIR/meeting-notes/YYYY-MM-DD// +$OPENCLAW_STATE_DIR/transcripts/YYYY-MM-DD// metadata.json transcript.jsonl summary.json @@ -35,17 +34,17 @@ session directory is a safe filesystem segment derived from the session id. ## Commands ```bash -openclaw meeting-notes list -openclaw meeting-notes show -openclaw meeting-notes show YYYY-MM-DD/ -openclaw meeting-notes path -openclaw meeting-notes path YYYY-MM-DD/ -openclaw meeting-notes path --dir -openclaw meeting-notes path --metadata -openclaw meeting-notes path --transcript -openclaw meeting-notes list --json -openclaw meeting-notes show --json -openclaw meeting-notes path --json +openclaw transcripts list +openclaw transcripts show +openclaw transcripts show YYYY-MM-DD/ +openclaw transcripts path +openclaw transcripts path YYYY-MM-DD/ +openclaw transcripts path --dir +openclaw transcripts path --metadata +openclaw transcripts path --transcript +openclaw transcripts list --json +openclaw transcripts show --json +openclaw transcripts path --json ``` - `list`: list stored sessions, date-qualified selector, start time, title, and `summary.md` path. @@ -57,7 +56,7 @@ openclaw meeting-notes path --json - `--json`: print machine-readable output. When a human session id repeats across days, use the date-qualified selector -from `list`, for example `openclaw meeting-notes show 2026-05-22/standup`. +from `list`, for example `openclaw transcripts show 2026-05-22/standup`. Default session ids include a timestamp and random suffix; configure fixed session ids only when they are unique within the day. @@ -66,7 +65,7 @@ session ids only when they are unique within the day. `list` prints one session per line: ```text -2026-05-22/standup 2026-05-22T09:00:00.000Z Weekly standup /Users/alex/.openclaw/meeting-notes/2026-05-22/standup/summary.md +2026-05-22/standup 2026-05-22T09:00:00.000Z Weekly standup /Users/alex/.openclaw/transcripts/2026-05-22/standup/summary.md ``` The output is tab-separated. The columns are selector, start time, title, and @@ -91,13 +90,13 @@ and whether that file exists. ## Many meetings per day -Meeting Notes groups sessions by date, then by session id. Ten meetings on one +Transcripts groups sessions by date, then by session id. Ten meetings on one day become ten sibling folders: ```text -~/.openclaw/meeting-notes/2026-05-22/ - meeting-2026-05-22T09-00-00-000Z-a1b2c3d4/ - meeting-2026-05-22T10-30-00-000Z-b2c3d4e5/ +~/.openclaw/transcripts/2026-05-22/ + transcript-2026-05-22T09-00-00-000Z-a1b2c3d4/ + transcript-2026-05-22T10-30-00-000Z-b2c3d4e5/ standup/ ``` @@ -112,7 +111,41 @@ write `summary.md` immediately after import. A session can still appear in or metadata was written before any utterances arrived. Use `path --transcript` to inspect the append-only transcript, and use -the `meeting_notes` tool action `summarize` to regenerate the Markdown summary. +the `transcripts` tool action `summarize` to regenerate the Markdown summary. -See [Meeting Notes](/plugins/meeting-notes) for configuration, auto-start, and -source-provider details. +## Configuration + +Transcript capture is opt-in because live sources can join and record meeting +audio. Enable the tool with top-level `transcripts.enabled`: + +```json +{ + "transcripts": { + "enabled": true, + "maxUtterances": 2000 + } +} +``` + +Configure auto-start sources with `transcripts.autoStart` in `openclaw.json`. +Each entry is enabled by being present; omit an entry to disable that source. + +```json +{ + "transcripts": { + "enabled": true, + "autoStart": [ + { + "providerId": "discord-voice", + "guildId": "1234567890", + "channelId": "2345678901" + }, + { + "providerId": "slack-huddle", + "accountId": "workspace", + "channelId": "C123" + } + ] + } +} +``` diff --git a/docs/docs.json b/docs/docs.json index 41f3c4d79ba..e2512c96818 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1212,7 +1212,6 @@ "plugins/codex-native-plugins", "plugins/codex-computer-use", "plugins/google-meet", - "plugins/meeting-notes", "plugins/webhooks", "plugins/admin-http-rpc", "plugins/voice-call", diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 184e3cc29b1..b4e4cd33c38 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -42,7 +42,7 @@ Capabilities are the public **native plugin** model inside OpenClaw. Every nativ | Realtime transcription | `api.registerRealtimeTranscriptionProvider(...)` | `openai` | | Realtime voice | `api.registerRealtimeVoiceProvider(...)` | `openai` | | Media understanding | `api.registerMediaUnderstandingProvider(...)` | `openai`, `google` | -| Meeting notes source | `api.registerMeetingNotesSourceProvider(...)` | `discord`, `meeting-notes` | +| Transcripts source | `api.registerTranscriptSourceProvider(...)` | `discord` | | Image generation | `api.registerImageGenerationProvider(...)` | `openai`, `google`, `fal`, `minimax` | | Music generation | `api.registerMusicGenerationProvider(...)` | `google`, `minimax` | | Video generation | `api.registerVideoGenerationProvider(...)` | `qwen` | diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index eb808738240..23238a1307b 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -649,7 +649,7 @@ Each list is optional: | `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. | | `memoryEmbeddingProviders` | `string[]` | Memory embedding provider ids this plugin owns. | | `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. | -| `meetingNotesSourceProviders` | `string[]` | Meeting-notes source provider ids this plugin owns. | +| `transcriptSourceProviders` | `string[]` | Transcript source provider ids this plugin owns. | | `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. | | `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. | | `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. | diff --git a/docs/plugins/meeting-notes.md b/docs/plugins/meeting-notes.md deleted file mode 100644 index 95d035c906a..00000000000 --- a/docs/plugins/meeting-notes.md +++ /dev/null @@ -1,359 +0,0 @@ ---- -summary: "Meeting Notes plugin: capture transcripts from Discord voice and imported meeting sources, then write summaries" -read_when: - - You want OpenClaw to take meeting notes - - You are wiring Discord voice, Google Meet, Slack huddles, or another meeting source into notes - - You need the meeting_notes tool contract -title: "Meeting Notes plugin" ---- - -The Meeting Notes plugin is the generic notes layer for live calls and imported -meeting transcripts. It owns transcript storage, summary rendering, and the -`meeting_notes` tool. Channel plugins own capture, authentication, and -platform-specific meeting joins. - -Use this page when you want OpenClaw to capture Discord voice notes today, when -you want to import a transcript from another meeting system, or when you are -building a Google Meet, Slack huddle, Zoom, or calendar-owned source provider. - -## Source model - -Meeting sources register `meetingNotesSourceProviders` through the plugin SDK. -The first live provider is `discord-voice`; the built-in `manual-transcript` -provider imports post-meeting transcripts. - -- `live-audio`: source joins or listens to a call and streams final utterances. -- `live-caption`: source reads captions from a browser or meeting surface. -- `posthoc-transcript`: source imports a transcript or notes artifact after the meeting. -- `recording-stt`: source transcribes a recording before importing utterances. - -This keeps Discord, Google Meet, Slack huddles, and future meeting surfaces out -of the notes engine. Each source supplies speaker-labeled utterances; Meeting -Notes writes the artifacts and summary. - -## Install and enable - -Meeting Notes is an external source plugin in this repository. It is not part of -the core OpenClaw npm package and becomes available only when the plugin is -installed as a plugin or loaded from a source checkout that contains -`extensions/meeting-notes`. - -Once the plugin is loaded, it is enabled by default unless one of these settings -blocks it: - -- `plugins.enabled: false` disables all plugins. -- `plugins.deny` contains `meeting-notes`. -- `plugins.allow` is set and does not contain `meeting-notes`. -- `plugins.entries.meeting-notes.enabled: false` disables this plugin entry. -- `plugins.entries.meeting-notes.config.enabled: false` keeps the plugin loaded - but disables the `meeting_notes` tool and auto-start service. - -The normal user config file is `~/.openclaw/openclaw.json`. The `plugins` -section controls plugin loading, and the nested `entries..config` -object is passed to that plugin as plugin-specific config. A separate -`config: { ... }` block under `meeting-notes` is expected; it is how plugins -receive their own options without adding core config keys. - -Use this shape when your config has a plugin allowlist: - -```json5 -{ - plugins: { - allow: ["discord", "meeting-notes"], - entries: { - "meeting-notes": { - enabled: true, - config: { - enabled: true, - maxUtterances: 2000, - autoStart: [], - }, - }, - }, - }, -} -``` - -Run a config check after editing: - -```bash -openclaw config validate -``` - -Gateway config hot reload applies plugin allowlist and plugin-entry changes. -Restart the Gateway if you are also changing the source plugin itself, installing -new plugin files, or changing Discord voice credentials. - -## Configuration - -Meeting Notes has three plugin config fields: - -- `enabled`: `true` by default. Set `false` to leave the plugin installed but - disable the tool and auto-start service. -- `maxUtterances`: `2000` by default. Summary generation reads only the newest - N utterances from `transcript.jsonl`; valid values are clamped to `1` through - `10000`. -- `autoStart`: empty by default. Each entry starts a live notes source when the - Gateway starts or reloads the plugin. - -An `autoStart` entry accepts: - -- `providerId`: required. Use `discord-voice` for Discord voice. -- `enabled`: optional, default `true`. Set `false` to keep an entry without - starting it. -- `sessionId`: optional. If omitted, OpenClaw generates a timestamped id. -- `title`: optional human-readable title for summaries and CLI output. -- `accountId`: optional source account id when more than one account exists. -- `guildId`: provider-specific Discord guild id. -- `channelId`: provider-specific Discord voice channel id. -- `meetingUrl`: provider-specific meeting URL for browser or calendar sources. - -Use `autoStart` when OpenClaw should begin notes capture automatically on -gateway startup: - -```json5 -{ - plugins: { - entries: { - "meeting-notes": { - config: { - autoStart: [ - { - providerId: "discord-voice", - guildId: "123", - channelId: "456", - title: "Weekly planning", - }, - ], - }, - }, - }, - }, -} -``` - -Auto-start retries startup failures up to 12 times with a five-second delay. -This lets the notes service wait for channel plugins such as Discord to finish -initializing. Sessions that were started by auto-start are stopped and summarized -when the plugin service stops cleanly. - -Discord voice capture still needs normal Discord voice setup and permissions. -See [Discord voice](/channels/discord#voice-mode). - -## Discord voice - -Discord is the first live source. The Discord plugin owns the voice connection, -speaker detection, audio decoding, and transcription. Meeting Notes receives -final speaker-labeled utterances and persists them. - -For Discord live capture: - -- Enable and configure the Discord plugin first. -- Configure Discord voice mode so OpenClaw can join the target voice channel. -- Use `providerId: "discord-voice"`. -- Provide `guildId` and `channelId`. -- Add `accountId` only when you run more than one Discord account. - -The transcription model is not chosen by Meeting Notes. In Discord `stt-tts` -voice mode, STT uses `tools.media.audio`; `voice.model` controls the agent reply -model, not transcription. In realtime voice modes, transcription follows the -configured realtime provider and model. See [Discord voice](/channels/discord#voice-mode) -for the current Discord voice model and provider knobs. - -## Google Meet, Slack huddles, and other sources - -Meeting Notes is intentionally source-neutral. Google Meet, Slack huddles, Zoom, -calendar recordings, or browser caption capture should be separate source -providers that register with the plugin SDK. - -Recommended source choices: - -- Google Meet live browser/caption support: implement a `live-caption` provider - that accepts `meetingUrl` and emits final caption utterances. -- Google Meet recordings or downloaded transcripts: implement - `posthoc-transcript` or use `manual-transcript` until a provider exists. -- Slack huddles today: import post-meeting huddle notes or transcript artifacts. - Slack does not expose a general bot-join live huddle audio API. -- Slack huddles later: keep the Slack-owned source provider responsible for - Slack auth, artifact lookup, and transcript normalization. - -The notes engine should not contain platform joins, browser automation, Slack -API polling, or Discord voice logic. Those belong to the owning source plugin. - -## Tool - -Use `meeting_notes` with an `action`: - -- `status`: list registered providers and active sessions. -- `start`: start a live notes session. -- `stop`: stop a live session and write `summary.md`. -- `import`: import a transcript and write `summary.md`. -- `summarize`: regenerate a summary for an existing session. - -Discord live notes require `providerId: "discord-voice"`, plus `guildId` and -`channelId`. `accountId` is optional when only one Discord account is active. - -```json -{ - "action": "start", - "providerId": "discord-voice", - "guildId": "123", - "channelId": "456", - "title": "Weekly planning" -} -``` - -Stop by session id: - -```json -{ - "action": "stop", - "sessionId": "meeting-2026-05-22T10-00-00-000Z-a1b2c3d4" -} -``` - -Import a transcript: - -```json -{ - "action": "import", - "providerId": "manual-transcript", - "title": "Design review", - "transcript": "Alex: We decided to ship the Discord source first.\nSam: Action item: add Slack huddle import later." -} -``` - -`manual-transcript` splits plain transcript text into utterances. Use it for -copied Google Meet notes, Slack huddle summaries, calendar transcripts, or any -source that already produced text. - -## Storage layout - -Artifacts are stored under the OpenClaw state directory: - -```text -$OPENCLAW_STATE_DIR/meeting-notes/YYYY-MM-DD// - metadata.json - transcript.jsonl - summary.json - summary.md -``` - -If `OPENCLAW_STATE_DIR` is unset, the default state directory is -`~/.openclaw`. A normal local install therefore writes notes under -`~/.openclaw/meeting-notes/...`. - -Each file has one job: - -- `metadata.json`: session id, source provider, title, start time, stop time, - and provider metadata. -- `transcript.jsonl`: append-only speaker utterances. Each line is one JSON - object with the utterance text and the session id. -- `summary.json`: structured summary data used by tooling, including the - speaker-labeled transcript window used for the generated summary. -- `summary.md`: human-readable notes for terminals, editors, and document - workflows, including a speaker-labeled transcript section. - -The date directory comes from the session start time, so multiple meetings per -day stay grouped. If a human session id repeats across days, use the -date-qualified selector from `openclaw meeting-notes list`, such as -`2026-05-22/standup`. - -By default, OpenClaw generates timestamped session ids: - -```text -meeting-2026-05-22T10-00-00-000Z-a1b2c3d4 -``` - -That means ten meetings on the same day become ten sibling directories: - -```text -~/.openclaw/meeting-notes/2026-05-22/ - meeting-2026-05-22T09-00-00-000Z-a1b2c3d4/ - meeting-2026-05-22T10-30-00-000Z-b2c3d4e5/ - meeting-2026-05-22T13-00-00-000Z-c3d4e5f6/ -``` - -Configure `sessionId` only when that id is unique for the day. Human ids such as -`standup` are fine for one recurring meeting per day. If the same id appears on -multiple days, use the date-qualified selector in the CLI. - -## CLI access - -Use the read-only CLI to find or print stored summaries: - -```bash -openclaw meeting-notes list -openclaw meeting-notes show -openclaw meeting-notes path -openclaw meeting-notes path --transcript -``` - -See [Meeting Notes CLI](/cli/meeting-notes) for the full command reference. - -## Long meetings - -For long meetings, utterances are appended to `transcript.jsonl` as they arrive. -Summary generation reads a bounded window controlled by -`plugins.entries.meeting-notes.config.maxUtterances` (default: `2000`) so a -multi-hour call does not require unbounded summary memory. - -This means the transcript can keep growing on disk, while summarization stays -bounded. Increase `maxUtterances` when you need more of a multi-hour meeting in -the generated summary and speaker-labeled transcript section. Decrease it when -summaries are too slow or too large. - -Current summaries are generated when a session stops, after an import, or when -the `summarize` action runs. They are not continuously rewritten for every -utterance. - -## Troubleshooting - -### `meeting_notes` is missing - -Check that the plugin is installed or loaded from source, and that plugin -loading does not exclude it: - -```bash -openclaw config validate -openclaw meeting-notes list -``` - -If `plugins.allow` is set, it must include `meeting-notes`. If `plugins.deny` -contains `meeting-notes`, remove it. - -### Auto-start does not join Discord - -Confirm the `autoStart` entry uses `providerId: "discord-voice"` and includes -both `guildId` and `channelId`. If you run multiple Discord accounts, include -`accountId`. Also verify Discord voice works outside Meeting Notes by joining -the same voice channel through the Discord voice commands. - -### Summary is missing - -Live sessions write `summary.md` when stopped. Stop the session with -`meeting_notes` action `stop`, then inspect it: - -```bash -openclaw meeting-notes list -openclaw meeting-notes path -``` - -Use `meeting_notes` action `summarize` to regenerate `summary.md` for an -existing stored session. - -### Selector is ambiguous - -If you reused a human session id such as `standup`, use the date-qualified -selector shown by `openclaw meeting-notes list`: - -```bash -openclaw meeting-notes show 2026-05-22/standup -``` - -## Related - -- [Meeting Notes CLI](/cli/meeting-notes) -- [Discord voice](/channels/discord#voice-mode) -- [Plugin management](/tools/plugin) -- [Plugin architecture](/plugins/architecture) diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 1d92e243266..6ceb03c2d1d 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -151,7 +151,7 @@ commands. | [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`
npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin | | [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`
npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin | | [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`
npm; ClawHub | contracts: tools; skills | -| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`
npm; ClawHub | channels: discord; contracts: meetingNotesSourceProviders | +| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`
npm; ClawHub | channels: discord; contracts: transcriptSourceProviders | | [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`
npm; ClawHub | channels: feishu; contracts: tools; skills | | [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`
npm; ClawHub | contracts: tools | | [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`
npm; ClawHub | channels: googlechat | @@ -175,9 +175,8 @@ commands. ## Source checkout only -| Plugin | Description | Distribution | Surface | -| ------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------- | -| [meeting-notes](/plugins/reference/meeting-notes) | Capture meeting transcripts from channel-owned sources and write summaries. | `@openclaw/meeting-notes`
source checkout only | contracts: meetingNotesSourceProviders, tools | -| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`
source checkout only | channels: qa-channel | -| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`
source checkout only | plugin | -| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`
source checkout only | plugin | +| Plugin | Description | Distribution | Surface | +| ------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------ | -------------------- | +| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`
source checkout only | channels: qa-channel | +| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`
source checkout only | plugin | +| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`
source checkout only | plugin | diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index c0d1ab96d5a..31f8a46d049 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -44,7 +44,7 @@ pnpm plugins:inventory:gen | [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`
npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin | | [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`
npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin | | [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`
npm; ClawHub | contracts: tools; skills | -| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`
npm; ClawHub | channels: discord; contracts: meetingNotesSourceProviders | +| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`
npm; ClawHub | channels: discord; contracts: transcriptSourceProviders | | [document-extract](/plugins/reference/document-extract) | Extract text and fallback page images from local document attachments. | `@openclaw/document-extract-plugin`
included in OpenClaw | contracts: documentExtractors | | [duckduckgo](/plugins/reference/duckduckgo) | Adds web search provider support. | `@openclaw/duckduckgo-plugin`
included in OpenClaw | contracts: webSearchProviders | | [elevenlabs](/plugins/reference/elevenlabs) | Adds media understanding provider support. Adds realtime transcription provider support. Adds text-to-speech provider support. | `@openclaw/elevenlabs-speech`
included in OpenClaw | contracts: mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders | @@ -73,7 +73,6 @@ pnpm plugins:inventory:gen | [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`
npm; ClawHub | contracts: tools | | [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`
ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix | | [mattermost](/plugins/reference/mattermost) | Adds the Mattermost channel surface for sending and receiving OpenClaw messages. | `@openclaw/mattermost`
included in OpenClaw | channels: mattermost | -| [meeting-notes](/plugins/reference/meeting-notes) | Capture meeting transcripts from channel-owned sources and write summaries. | `@openclaw/meeting-notes`
source checkout only | contracts: meetingNotesSourceProviders, tools | | [memory-core](/plugins/reference/memory-core) | Adds memory embedding provider support. Adds agent-callable tools. | `@openclaw/memory-core`
included in OpenClaw | contracts: memoryEmbeddingProviders, tools | | [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`
npm; ClawHub | contracts: tools | | [memory-wiki](/plugins/reference/memory-wiki) | Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw. | `@openclaw/memory-wiki`
included in OpenClaw | contracts: tools; skills | diff --git a/docs/plugins/reference/discord.md b/docs/plugins/reference/discord.md index 6313816c3f6..a25c3601d8d 100644 --- a/docs/plugins/reference/discord.md +++ b/docs/plugins/reference/discord.md @@ -16,7 +16,7 @@ Adds the Discord channel surface for sending and receiving OpenClaw messages. ## Surface -channels: discord; contracts: meetingNotesSourceProviders +channels: discord; contracts: transcriptSourceProviders ## Related docs diff --git a/docs/plugins/reference/meeting-notes.md b/docs/plugins/reference/meeting-notes.md deleted file mode 100644 index 20fdf157604..00000000000 --- a/docs/plugins/reference/meeting-notes.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -summary: "Capture meeting transcripts from channel-owned sources and write summaries." -read_when: - - You are installing, configuring, or auditing the meeting-notes plugin -title: "Meeting Notes plugin" ---- - -# Meeting Notes plugin - -Capture meeting transcripts from channel-owned sources and write summaries. - -## Distribution - -- Package: `@openclaw/meeting-notes` -- Install route: source checkout only - -## Surface - -contracts: meetingNotesSourceProviders, tools - -## Related docs - -- [meeting-notes](/plugins/meeting-notes) diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 6542fea9e91..effdb0c8289 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -322,9 +322,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`, | `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers | | `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` and `saveMediaStream` | | `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging | - | `plugin-sdk/meeting-notes` | Meeting notes source provider types, registry lookup, and provider id normalization helpers | | `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports | - | `plugin-sdk/meeting-notes` | Meeting notes source provider types, registry helpers, and provider id normalization | | `plugin-sdk/text-chunking` | Text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities | | `plugin-sdk/text-chunking` | Outbound text chunking helper | | `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports | @@ -338,7 +336,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`, | `plugin-sdk/music-generation-core` | Shared music-generation types, failover helpers, provider lookup, and model-ref parsing | | `plugin-sdk/video-generation` | Video generation provider/request/result types | | `plugin-sdk/video-generation-core` | Shared video-generation types, failover helpers, provider lookup, and model-ref parsing | - | `plugin-sdk/meeting-notes` | Shared meeting-notes source provider types, registry helpers, session descriptors, and utterance metadata | + | `plugin-sdk/transcripts` | Shared transcripts source provider types, registry helpers, session descriptors, and utterance metadata | | `plugin-sdk/webhook-targets` | Webhook target registry and route-install helpers | | `plugin-sdk/webhook-path` | Deprecated compatibility alias; use `plugin-sdk/webhook-ingress` | | `plugin-sdk/web-media` | Shared remote/local media loading helpers | diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index cba9c3ec6e5..fa8f9b305d3 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,6 +1,6 @@ import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; -import { discordVoiceMeetingNotesSourceProvider } from "./meeting-notes-source-api.js"; import { registerDiscordSubagentHooks } from "./subagent-hooks-api.js"; +import { discordVoiceTranscriptsSourceProvider } from "./transcripts-source-api.js"; export default defineBundledChannelEntry({ id: "discord", @@ -21,6 +21,6 @@ export default defineBundledChannelEntry({ }, registerFull(api) { registerDiscordSubagentHooks(api); - api.registerMeetingNotesSourceProvider(discordVoiceMeetingNotesSourceProvider); + api.registerTranscriptSourceProvider(discordVoiceTranscriptsSourceProvider); }, }); diff --git a/extensions/discord/meeting-notes-source-api.ts b/extensions/discord/meeting-notes-source-api.ts deleted file mode 100644 index 9e9dfdf01c1..00000000000 --- a/extensions/discord/meeting-notes-source-api.ts +++ /dev/null @@ -1 +0,0 @@ -export { discordVoiceMeetingNotesSourceProvider } from "./src/voice/meeting-notes-source.js"; diff --git a/extensions/discord/openclaw.plugin.json b/extensions/discord/openclaw.plugin.json index 1d3e90e6dfb..0f51a29ef87 100644 --- a/extensions/discord/openclaw.plugin.json +++ b/extensions/discord/openclaw.plugin.json @@ -5,7 +5,7 @@ }, "channels": ["discord"], "contracts": { - "meetingNotesSourceProviders": ["discord-voice"] + "transcriptSourceProviders": ["discord-voice"] }, "channelEnvVars": { "discord": ["DISCORD_BOT_TOKEN"] diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index a12c9e48820..2a3d0b90ecc 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -555,9 +555,8 @@ export async function runDiscordGatewayLifecycle(params: { ); if (params.voiceManager) { await params.voiceManager.destroy(); - const { setDiscordMeetingNotesVoiceManager } = - await import("../voice/meeting-notes-source.js"); - setDiscordMeetingNotesVoiceManager({ + const { setDiscordTranscriptsVoiceManager } = await import("../voice/transcripts-source.js"); + setDiscordTranscriptsVoiceManager({ accountId: params.accountId, manager: null, }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 33d88053bd8..39d68832ede 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -511,9 +511,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime, botUserId, }); - const { setDiscordMeetingNotesVoiceManager } = - await import("../voice/meeting-notes-source.js"); - setDiscordMeetingNotesVoiceManager({ + const { setDiscordTranscriptsVoiceManager } = await import("../voice/transcripts-source.js"); + setDiscordTranscriptsVoiceManager({ accountId: account.accountId, manager: voiceManager, }); diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 0818c220597..6eb9b6aa5a4 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -633,7 +633,7 @@ describe("DiscordVoiceManager", () => { expectConnectedStatus(manager, "1002"); }); - it("attaches meeting notes capture to an existing voice session", async () => { + it("attaches transcripts capture to an existing voice session", async () => { const manager = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); @@ -641,7 +641,7 @@ describe("DiscordVoiceManager", () => { const result = await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, @@ -649,17 +649,17 @@ describe("DiscordVoiceManager", () => { ); const entry = getSessionEntry(manager) as { - meetingNotes?: { sessionId: string; onUtterance: typeof onUtterance }; + transcripts?: { sessionId: string; onUtterance: typeof onUtterance }; }; expect(result.ok).toBe(true); expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1); - expect(entry.meetingNotes).toEqual({ + expect(entry.transcripts).toEqual({ sessionId: "notes-1", onUtterance, }); }); - it("does not leave a newer meeting-notes-only session for a stale stop", async () => { + it("does not leave a newer transcripts-only session for a stale stop", async () => { const manager = createManager({ groupPolicy: "open", voice: { @@ -674,7 +674,7 @@ describe("DiscordVoiceManager", () => { await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance: firstUtterance, }, @@ -683,7 +683,7 @@ describe("DiscordVoiceManager", () => { await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-2", onUtterance: secondUtterance, }, @@ -692,21 +692,21 @@ describe("DiscordVoiceManager", () => { const result = await manager.leave( { guildId: "g1", channelId: "1001" }, - { meetingNotesSessionId: "notes-1" }, + { transcriptsSessionId: "notes-1" }, ); const entry = getSessionEntry(manager) as { - meetingNotes?: { sessionId: string; onUtterance: typeof secondUtterance }; + transcripts?: { sessionId: string; onUtterance: typeof secondUtterance }; }; expect(result.ok).toBe(false); - expect(entry.meetingNotes).toEqual({ + expect(entry.transcripts).toEqual({ sessionId: "notes-2", onUtterance: secondUtterance, }); expectConnectedStatus(manager, "1001"); }); - it("upgrades a meeting-notes-only session to realtime on a normal join", async () => { + it("upgrades a transcripts-only session to realtime on a normal join", async () => { const manager = createManager({ groupPolicy: "open", voice: { @@ -720,7 +720,7 @@ describe("DiscordVoiceManager", () => { await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, @@ -729,7 +729,7 @@ describe("DiscordVoiceManager", () => { expect(createRealtimeVoiceBridgeSessionMock).not.toHaveBeenCalled(); const entry = getSessionEntry(manager) as { - meetingNotes?: { sessionId: string; onUtterance: typeof onUtterance }; + transcripts?: { sessionId: string; onUtterance: typeof onUtterance }; realtime?: unknown; }; let resolveRealtimeReady!: () => void; @@ -750,7 +750,7 @@ describe("DiscordVoiceManager", () => { expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1); expect(createRealtimeVoiceBridgeSessionMock).toHaveBeenCalledTimes(1); expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1); - expect(entry.meetingNotes).toEqual({ + expect(entry.transcripts).toEqual({ sessionId: "notes-1", onUtterance, }); @@ -758,11 +758,11 @@ describe("DiscordVoiceManager", () => { const stopNotesResult = await manager.leave( { guildId: "g1", channelId: "1001" }, - { meetingNotesSessionId: "notes-1" }, + { transcriptsSessionId: "notes-1" }, ); expect(stopNotesResult.ok).toBe(true); - expect(entry.meetingNotes).toBeUndefined(); + expect(entry.transcripts).toBeUndefined(); expect(entry.realtime).toBeTruthy(); expect(realtimeSessionMock.close).not.toHaveBeenCalled(); expectConnectedStatus(manager, "1001"); @@ -782,7 +782,7 @@ describe("DiscordVoiceManager", () => { await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, @@ -818,7 +818,7 @@ describe("DiscordVoiceManager", () => { expect(entry.realtime).toBeUndefined(); }); - it("detaches meeting notes without leaving voice during pending realtime upgrade", async () => { + it("detaches transcripts without leaving voice during pending realtime upgrade", async () => { const manager = createManager({ groupPolicy: "open", voice: { @@ -832,14 +832,14 @@ describe("DiscordVoiceManager", () => { await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, }, ); const entry = getSessionEntry(manager) as { - meetingNotes?: { sessionId: string; onUtterance: typeof onUtterance }; + transcripts?: { sessionId: string; onUtterance: typeof onUtterance }; pendingRealtime?: unknown; realtime?: unknown; }; @@ -854,11 +854,11 @@ describe("DiscordVoiceManager", () => { await vi.waitFor(() => expect(createRealtimeVoiceBridgeSessionMock).toHaveBeenCalledTimes(1)); const stopNotesResult = await manager.leave( { guildId: "g1", channelId: "1001" }, - { meetingNotesSessionId: "notes-1" }, + { transcriptsSessionId: "notes-1" }, ); expect(stopNotesResult.ok).toBe(true); - expect(entry.meetingNotes).toBeUndefined(); + expect(entry.transcripts).toBeUndefined(); expect(entry.pendingRealtime).toBeTruthy(); expect(entry.realtime).toBeUndefined(); @@ -885,7 +885,7 @@ describe("DiscordVoiceManager", () => { await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, @@ -912,7 +912,7 @@ describe("DiscordVoiceManager", () => { expect(createRealtimeVoiceBridgeSessionMock).not.toHaveBeenCalled(); }); - it("keeps realtime playback alive when meeting notes attaches to an existing voice session", async () => { + it("keeps realtime playback alive when transcripts attaches to an existing voice session", async () => { const manager = createManager({ groupPolicy: "open", voice: { @@ -925,7 +925,7 @@ describe("DiscordVoiceManager", () => { await manager.join({ guildId: "g1", channelId: "1001" }); const player = getLastAudioPlayer(); const entry = getSessionEntry(manager) as { - meetingNotes?: { sessionId: string; onUtterance: (event: unknown) => Promise }; + transcripts?: { sessionId: string; onUtterance: (event: unknown) => Promise }; realtime?: { beginSpeakerTurn: ( context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, @@ -941,13 +941,13 @@ describe("DiscordVoiceManager", () => { | undefined; bridgeParams?.audioSink?.sendAudio(Buffer.alloc(24_000)); - const stopCallsBeforeMeetingNotes = player.stop.mock.calls.length; + const stopCallsBeforeTranscripts = player.stop.mock.calls.length; const onUtterance = vi.fn(async () => undefined); const result = await manager.join( { guildId: "g1", channelId: "1001" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, @@ -955,9 +955,9 @@ describe("DiscordVoiceManager", () => { ); expect(result.ok).toBe(true); - expect(entry.meetingNotes?.sessionId).toBe("notes-1"); + expect(entry.transcripts?.sessionId).toBe("notes-1"); expect(realtimeSessionMock.close).not.toHaveBeenCalled(); - expect(player.stop).toHaveBeenCalledTimes(stopCallsBeforeMeetingNotes); + expect(player.stop).toHaveBeenCalledTimes(stopCallsBeforeTranscripts); const turn = entry.realtime?.beginSpeakerTurn( { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index bb668ef998c..50b269cfb10 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -419,7 +419,7 @@ export class DiscordVoiceManager { params: { guildId: string; channelId: string }, options?: { preserveFollowState?: boolean; - meetingNotes?: VoiceSessionEntry["meetingNotes"]; + transcripts?: VoiceSessionEntry["transcripts"]; }, ): Promise { if (this.destroyed) { @@ -484,7 +484,7 @@ export class DiscordVoiceManager { params: { guildId: string; channelId: string }, options?: { preserveFollowState?: boolean; - meetingNotes?: VoiceSessionEntry["meetingNotes"]; + transcripts?: VoiceSessionEntry["transcripts"]; }, ): Promise { const { guildId, channelId } = params; @@ -493,10 +493,10 @@ export class DiscordVoiceManager { const existing = this.sessions.get(guildId); if (existing && existing.channelId === channelId) { - if (options?.meetingNotes) { - existing.meetingNotes = options.meetingNotes; + if (options?.transcripts) { + existing.transcripts = options.transcripts; } - if (!options?.meetingNotes && isDiscordRealtimeVoiceMode(voiceMode) && !existing.realtime) { + if (!options?.transcripts && isDiscordRealtimeVoiceMode(voiceMode) && !existing.realtime) { const realtimeResult = await this.attachRealtimeSession(existing, voiceMode, { requireLiveEntry: true, }); @@ -739,7 +739,7 @@ export class DiscordVoiceManager { playbackQueue: Promise.resolve(), processingQueue: Promise.resolve(), capture: createVoiceCaptureState(), - meetingNotes: options?.meetingNotes, + transcripts: options?.transcripts, receiveRecovery: createVoiceReceiveRecoveryState(), isStopped: () => stopped, stop: () => { @@ -750,7 +750,7 @@ export class DiscordVoiceManager { }, }; - if (!options?.meetingNotes && isDiscordRealtimeVoiceMode(voiceMode)) { + if (!options?.transcripts && isDiscordRealtimeVoiceMode(voiceMode)) { const realtimeResult = await this.attachRealtimeSession(entry, voiceMode); if (!realtimeResult.ok) { destroyVoiceConnectionSafely({ @@ -911,7 +911,7 @@ export class DiscordVoiceManager { async leave( params: { guildId: string; channelId?: string }, - options?: { preserveFollowState?: boolean; meetingNotesSessionId?: string }, + options?: { preserveFollowState?: boolean; transcriptsSessionId?: string }, ): Promise { const guildId = params.guildId.trim(); logVoiceVerbose(`leave requested: guild ${guildId} channel ${params.channelId ?? "current"}`); @@ -922,20 +922,20 @@ export class DiscordVoiceManager { if (params.channelId && params.channelId !== entry.channelId) { return { ok: false, message: "Not connected to that voice channel." }; } - if (options?.meetingNotesSessionId) { - if (!entry.meetingNotes || entry.meetingNotes.sessionId !== options.meetingNotesSessionId) { + if (options?.transcriptsSessionId) { + if (!entry.transcripts || entry.transcripts.sessionId !== options.transcriptsSessionId) { return { ok: false, - message: "Meeting notes session is not active in this voice channel.", + message: "Transcripts session is not active in this voice channel.", guildId, channelId: entry.channelId, }; } if (entry.realtime || entry.pendingRealtime) { - entry.meetingNotes = undefined; + entry.transcripts = undefined; return { ok: true, - message: `Stopped meeting notes for ${formatMention({ channelId: entry.channelId })}.`, + message: `Stopped transcripts for ${formatMention({ channelId: entry.channelId })}.`, guildId, channelId: entry.channelId, }; @@ -1742,7 +1742,7 @@ export class DiscordVoiceManager { ownerAllowFrom: this.ownerAllowFrom, runtime: this.params.runtime, speakerContext: this.speakerContext, - meetingNotes: params.entry.meetingNotes, + transcripts: params.entry.transcripts, fetchGuildName: async (guildId) => { const guild = await this.params.client.fetchGuild(guildId).catch(() => null); return guild && typeof guild.name === "string" && guild.name.trim() diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts index 8e1839f17ba..8cd09f0e8e9 100644 --- a/extensions/discord/src/voice/realtime.ts +++ b/extensions/discord/src/voice/realtime.ts @@ -1219,8 +1219,8 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { return; } this.partialUserTranscript = ""; - const meetingNotesTurn = this.peekPendingSpeakerTurn(); - this.recordMeetingNotesUtterance(trimmed, meetingNotesTurn); + const transcriptsTurn = this.peekPendingSpeakerTurn(); + this.recordTranscriptUtterance(trimmed, transcriptsTurn); const wakeNameResult = this.resolveWakeNameTranscript(trimmed); if (!wakeNameResult.allowed) { this.rememberIgnoredWakeNameSpeakerContext(this.consumePendingSpeakerContext()); @@ -1309,14 +1309,14 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { return { allowed: false, text }; } - private recordMeetingNotesUtterance(text: string, turn: PendingSpeakerTurn | undefined): void { - const meetingNotes = this.params.entry.meetingNotes; - if (!meetingNotes || !turn) { + private recordTranscriptUtterance(text: string, turn: PendingSpeakerTurn | undefined): void { + const transcripts = this.params.entry.transcripts; + if (!transcripts || !turn) { return; } const context = turn.context; const utterance = { - sessionId: meetingNotes.sessionId, + sessionId: transcripts.sessionId, startedAt: new Date(turn.startedAt).toISOString(), final: true, speaker: { @@ -1332,10 +1332,10 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { }, }; void Promise.resolve() - .then(() => meetingNotes.onUtterance(utterance)) + .then(() => transcripts.onUtterance(utterance)) .catch((error: unknown) => { logger.warn( - `discord voice: realtime meeting notes utterance failed: ${formatErrorMessage(error)}`, + `discord voice: realtime transcripts utterance failed: ${formatErrorMessage(error)}`, ); }); } diff --git a/extensions/discord/src/voice/segment.ts b/extensions/discord/src/voice/segment.ts index fb5feaa771d..195a42c5805 100644 --- a/extensions/discord/src/voice/segment.ts +++ b/extensions/discord/src/voice/segment.ts @@ -40,7 +40,7 @@ export async function processDiscordVoiceSegment(params: { ownerAllowFrom?: string[]; fetchGuildName: (guildId: string) => Promise; speakerContext: DiscordVoiceSpeakerContextResolver; - meetingNotes?: VoiceSessionEntry["meetingNotes"]; + transcripts?: VoiceSessionEntry["transcripts"]; enqueuePlayback: (entry: VoiceSessionEntry, task: () => Promise) => void; }) { const { entry, wavPath, userId, durationSeconds } = params; @@ -79,9 +79,9 @@ export async function processDiscordVoiceSegment(params: { logVoiceVerbose( `transcript from ${ingress.speakerLabel} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceTranscriptLogPreview(transcript)}`, ); - if (params.meetingNotes) { - await params.meetingNotes.onUtterance({ - sessionId: params.meetingNotes.sessionId, + if (params.transcripts) { + await params.transcripts.onUtterance({ + sessionId: params.transcripts.sessionId, startedAt: new Date().toISOString(), final: true, speaker: { diff --git a/extensions/discord/src/voice/session.ts b/extensions/discord/src/voice/session.ts index 9e43fb7a7a3..117e466f0ad 100644 --- a/extensions/discord/src/voice/session.ts +++ b/extensions/discord/src/voice/session.ts @@ -1,6 +1,6 @@ -import type { MeetingNotesUtterance } from "openclaw/plugin-sdk/meeting-notes"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { TranscriptUtterance } from "openclaw/plugin-sdk/transcripts"; import { ChannelType } from "../internal/discord.js"; import type { VoiceCaptureState } from "./capture-state.js"; import type { VoiceReceiveRecoveryState } from "./receive-recovery.js"; @@ -70,9 +70,9 @@ export type VoiceSessionEntry = { capture: VoiceCaptureState; pendingRealtime?: VoiceRealtimeSession; realtime?: VoiceRealtimeSession; - meetingNotes?: { + transcripts?: { sessionId: string; - onUtterance: (utterance: MeetingNotesUtterance) => void | Promise; + onUtterance: (utterance: TranscriptUtterance) => void | Promise; }; receiveRecovery: VoiceReceiveRecoveryState; isStopped: () => boolean; diff --git a/extensions/discord/src/voice/meeting-notes-source.test.ts b/extensions/discord/src/voice/transcripts-source.test.ts similarity index 74% rename from extensions/discord/src/voice/meeting-notes-source.test.ts rename to extensions/discord/src/voice/transcripts-source.test.ts index 03cc130cb55..43420da9b53 100644 --- a/extensions/discord/src/voice/meeting-notes-source.test.ts +++ b/extensions/discord/src/voice/transcripts-source.test.ts @@ -1,26 +1,26 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { DiscordVoiceManager } from "./manager.js"; import { - discordVoiceMeetingNotesSourceProvider, - setDiscordMeetingNotesVoiceManager, -} from "./meeting-notes-source.js"; + discordVoiceTranscriptsSourceProvider, + setDiscordTranscriptsVoiceManager, +} from "./transcripts-source.js"; -describe("discordVoiceMeetingNotesSourceProvider", () => { +describe("discordVoiceTranscriptsSourceProvider", () => { afterEach(() => { - setDiscordMeetingNotesVoiceManager({ accountId: "primary", manager: null }); - setDiscordMeetingNotesVoiceManager({ accountId: "delayed", manager: null }); + setDiscordTranscriptsVoiceManager({ accountId: "primary", manager: null }); + setDiscordTranscriptsVoiceManager({ accountId: "delayed", manager: null }); vi.useRealTimers(); }); - it("starts Discord voice in meeting-notes mode", async () => { + it("starts Discord voice in transcripts mode", async () => { const join = vi.fn(async () => ({ ok: true, message: "joined" })); - setDiscordMeetingNotesVoiceManager({ + setDiscordTranscriptsVoiceManager({ accountId: "primary", manager: { join } as unknown as DiscordVoiceManager, }); const onUtterance = vi.fn(); - const result = await discordVoiceMeetingNotesSourceProvider.start?.({ + const result = await discordVoiceTranscriptsSourceProvider.start?.({ session: { sessionId: "notes-1", startedAt: new Date().toISOString(), @@ -38,7 +38,7 @@ describe("discordVoiceMeetingNotesSourceProvider", () => { expect(join).toHaveBeenCalledWith( { guildId: "g1", channelId: "c1" }, { - meetingNotes: { + transcripts: { sessionId: "notes-1", onUtterance, }, @@ -50,7 +50,7 @@ describe("discordVoiceMeetingNotesSourceProvider", () => { vi.useFakeTimers(); const join = vi.fn(async () => ({ ok: true, message: "joined" })); const onUtterance = vi.fn(); - const resultPromise = discordVoiceMeetingNotesSourceProvider.start?.({ + const resultPromise = discordVoiceTranscriptsSourceProvider.start?.({ session: { sessionId: "notes-2", startedAt: new Date().toISOString(), @@ -68,7 +68,7 @@ describe("discordVoiceMeetingNotesSourceProvider", () => { await vi.advanceTimersByTimeAsync(1_000); expect(join).not.toHaveBeenCalled(); - setDiscordMeetingNotesVoiceManager({ + setDiscordTranscriptsVoiceManager({ accountId: "delayed", manager: { join } as unknown as DiscordVoiceManager, }); @@ -78,7 +78,7 @@ describe("discordVoiceMeetingNotesSourceProvider", () => { }); it("fails promptly without an explicit startup wait", async () => { - const result = await discordVoiceMeetingNotesSourceProvider.start?.({ + const result = await discordVoiceTranscriptsSourceProvider.start?.({ session: { sessionId: "notes-3", startedAt: new Date().toISOString(), @@ -98,14 +98,14 @@ describe("discordVoiceMeetingNotesSourceProvider", () => { }); }); - it("stops Discord meeting notes without owning promoted voice sessions", async () => { + it("stops Discord transcripts without owning promoted voice sessions", async () => { const leave = vi.fn(async () => ({ ok: true, message: "stopped notes" })); - setDiscordMeetingNotesVoiceManager({ + setDiscordTranscriptsVoiceManager({ accountId: "primary", manager: { leave } as unknown as DiscordVoiceManager, }); - const result = await discordVoiceMeetingNotesSourceProvider.stop?.({ + const result = await discordVoiceTranscriptsSourceProvider.stop?.({ sessionId: "notes-1", source: { providerId: "discord-voice", @@ -122,7 +122,7 @@ describe("discordVoiceMeetingNotesSourceProvider", () => { channelId: "c1", }, { - meetingNotesSessionId: "notes-1", + transcriptsSessionId: "notes-1", }, ); }); diff --git a/extensions/discord/src/voice/meeting-notes-source.ts b/extensions/discord/src/voice/transcripts-source.ts similarity index 84% rename from extensions/discord/src/voice/meeting-notes-source.ts rename to extensions/discord/src/voice/transcripts-source.ts index 25480f1180b..47ff46acef8 100644 --- a/extensions/discord/src/voice/meeting-notes-source.ts +++ b/extensions/discord/src/voice/transcripts-source.ts @@ -1,7 +1,7 @@ import type { - MeetingNotesSourceProviderPlugin, - MeetingNotesStartRequest, -} from "openclaw/plugin-sdk/meeting-notes"; + TranscriptSourceProvider, + TranscriptStartRequest, +} from "openclaw/plugin-sdk/transcripts"; import type { DiscordVoiceManager } from "./manager.js"; const managersByAccountId = new Map(); @@ -10,7 +10,7 @@ const managerWaiters = new Set<{ resolve: () => void; }>(); -export function setDiscordMeetingNotesVoiceManager(params: { +export function setDiscordTranscriptsVoiceManager(params: { accountId: string; manager: DiscordVoiceManager | null; }): void { @@ -26,7 +26,7 @@ export function setDiscordMeetingNotesVoiceManager(params: { } } -function resolveManager(request: MeetingNotesStartRequest): DiscordVoiceManager | undefined { +function resolveManager(request: TranscriptStartRequest): DiscordVoiceManager | undefined { const accountId = request.session.source.accountId?.trim(); if (accountId) { return managersByAccountId.get(accountId); @@ -35,7 +35,7 @@ function resolveManager(request: MeetingNotesStartRequest): DiscordVoiceManager } async function waitForManager( - request: MeetingNotesStartRequest, + request: TranscriptStartRequest, ): Promise { const existing = resolveManager(request); if (existing) { @@ -70,7 +70,7 @@ async function waitForManager( return resolveManager(request); } -export const discordVoiceMeetingNotesSourceProvider: MeetingNotesSourceProviderPlugin = { +export const discordVoiceTranscriptsSourceProvider: TranscriptSourceProvider = { id: "discord-voice", aliases: ["discord"], name: "Discord Voice", @@ -81,17 +81,17 @@ export const discordVoiceMeetingNotesSourceProvider: MeetingNotesSourceProviderP return { ok: false, error: "Discord voice manager is not available." }; } if (request.abortSignal?.aborted) { - return { ok: false, error: "Discord meeting notes start aborted." }; + return { ok: false, error: "Discord transcripts start aborted." }; } const guildId = request.session.source.guildId?.trim(); const channelId = request.session.source.channelId?.trim(); if (!guildId || !channelId) { - return { ok: false, error: "Discord meeting notes require guildId and channelId." }; + return { ok: false, error: "Discord transcripts require guildId and channelId." }; } const joined = await manager.join( { guildId, channelId }, { - meetingNotes: { + transcripts: { sessionId: request.session.sessionId, onUtterance: request.onUtterance, }, @@ -112,7 +112,7 @@ export const discordVoiceMeetingNotesSourceProvider: MeetingNotesSourceProviderP } const guildId = request.source.guildId?.trim(); if (!guildId) { - return { ok: false, error: "Discord meeting notes require guildId." }; + return { ok: false, error: "Discord transcripts require guildId." }; } const result = await manager.leave( { @@ -120,7 +120,7 @@ export const discordVoiceMeetingNotesSourceProvider: MeetingNotesSourceProviderP channelId: request.source.channelId, }, { - meetingNotesSessionId: request.sessionId, + transcriptsSessionId: request.sessionId, }, ); if (!result.ok) { diff --git a/extensions/discord/transcripts-source-api.ts b/extensions/discord/transcripts-source-api.ts new file mode 100644 index 00000000000..663711212e9 --- /dev/null +++ b/extensions/discord/transcripts-source-api.ts @@ -0,0 +1 @@ +export { discordVoiceTranscriptsSourceProvider } from "./src/voice/transcripts-source.js"; diff --git a/extensions/meeting-notes/index.ts b/extensions/meeting-notes/index.ts deleted file mode 100644 index 363acf10da7..00000000000 --- a/extensions/meeting-notes/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { manualTranscriptSourceProvider } from "./src/manual-source.js"; -import { createMeetingNotesAutoStartService, createMeetingNotesTool } from "./src/tool.js"; - -export default definePluginEntry({ - id: "meeting-notes", - name: "Meeting Notes", - description: "Capture and summarize meeting transcripts from generic source providers.", - register(api) { - api.registerMeetingNotesSourceProvider(manualTranscriptSourceProvider); - api.registerTool(createMeetingNotesTool(api), { name: "meeting_notes" }); - api.registerCli( - async ({ program }) => { - const { registerMeetingNotesCli } = await import("./src/cli.js"); - registerMeetingNotesCli(program); - }, - { - descriptors: [ - { - name: "meeting-notes", - description: "Inspect stored meeting notes", - hasSubcommands: true, - }, - ], - }, - ); - api.registerService(createMeetingNotesAutoStartService(api)); - }, -}); diff --git a/extensions/meeting-notes/openclaw.plugin.json b/extensions/meeting-notes/openclaw.plugin.json deleted file mode 100644 index ffd415c8cca..00000000000 --- a/extensions/meeting-notes/openclaw.plugin.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "id": "meeting-notes", - "enabledByDefault": true, - "activation": { - "onStartup": false, - "onConfigPaths": ["plugins.entries.meeting-notes.config.autoStart"], - "onCommands": ["meeting-notes"] - }, - "name": "Meeting Notes", - "description": "Capture meeting transcripts from channel-owned sources and write summaries.", - "contracts": { - "meetingNotesSourceProviders": ["manual-transcript"], - "tools": ["meeting_notes"] - }, - "commandAliases": [ - { - "name": "meeting-notes", - "kind": "cli" - } - ], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": true - }, - "maxUtterances": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "default": 2000 - }, - "autoStart": { - "type": "array", - "default": [], - "items": { - "type": "object", - "additionalProperties": false, - "required": ["providerId"], - "properties": { - "enabled": { - "type": "boolean", - "default": true - }, - "providerId": { - "type": "string", - "minLength": 1 - }, - "sessionId": { - "type": "string", - "minLength": 1 - }, - "title": { - "type": "string", - "minLength": 1 - }, - "accountId": { - "type": "string", - "minLength": 1 - }, - "guildId": { - "type": "string", - "minLength": 1 - }, - "channelId": { - "type": "string", - "minLength": 1 - }, - "meetingUrl": { - "type": "string", - "minLength": 1 - } - } - } - } - } - } -} diff --git a/extensions/meeting-notes/package.json b/extensions/meeting-notes/package.json deleted file mode 100644 index 6704a3a2ba4..00000000000 --- a/extensions/meeting-notes/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@openclaw/meeting-notes", - "version": "2026.5.26", - "private": true, - "description": "OpenClaw meeting notes plugin", - "type": "module", - "dependencies": { - "typebox": "1.1.38" - }, - "devDependencies": { - "@openclaw/plugin-sdk": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "build": { - "bundledDist": false - } - } -} diff --git a/package.json b/package.json index 36a86e58b8f..68f46bbb49e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "!dist/extensions/line/**", "!dist/extensions/lobster/**", "!dist/extensions/memory-lancedb/**", - "!dist/extensions/meeting-notes/**", "!dist/extensions/matrix/**", "!dist/extensions/msteams/**", "!dist/extensions/nextcloud-talk/**", @@ -1011,9 +1010,9 @@ "types": "./dist/plugin-sdk/realtime-voice.d.ts", "default": "./dist/plugin-sdk/realtime-voice.js" }, - "./plugin-sdk/meeting-notes": { - "types": "./dist/plugin-sdk/meeting-notes.d.ts", - "default": "./dist/plugin-sdk/meeting-notes.js" + "./plugin-sdk/transcripts": { + "types": "./dist/plugin-sdk/transcripts.d.ts", + "default": "./dist/plugin-sdk/transcripts.js" }, "./plugin-sdk/media-understanding": { "types": "./dist/plugin-sdk/media-understanding.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 181701e8f7a..de5a80017c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -964,16 +964,6 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk - extensions/meeting-notes: - dependencies: - typebox: - specifier: 1.1.38 - version: 1.1.38 - devDependencies: - '@openclaw/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk - extensions/memory-core: dependencies: chokidar: diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 37526ea4494..f36ba10a81c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -228,7 +228,7 @@ "realtime-transcription", "realtime-bootstrap-context", "realtime-voice", - "meeting-notes", + "transcripts", "media-understanding", "media-understanding-runtime", "messaging-targets", diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 133113f4c5d..71b8bc8db82 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -7,6 +7,7 @@ import { isEmbeddedMode } from "../infra/embedded-mode.js"; import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime-state.js"; import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime-web-tools-state.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; +import { resolveTranscriptsConfig } from "../transcripts/config.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js"; @@ -51,6 +52,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js"; import { createSubagentsTool } from "./tools/subagents-tool.js"; +import { createTranscriptsTool } from "./tools/transcripts-tool.js"; import { createTtsTool } from "./tools/tts-tool.js"; import { createUpdatePlanTool } from "./tools/update-plan-tool.js"; import { createVideoGenerateTool } from "./tools/video-generate-tool.js"; @@ -375,6 +377,7 @@ export function createOpenClawTools( pluginToolAllowlist: options?.pluginToolAllowlist, pluginToolDenylist: options?.pluginToolDenylist, }); + const includeTranscriptsTool = resolveTranscriptsConfig(resolvedConfig?.transcripts).enabled; const tools: AnyAgentTool[] = [ ...(embedded ? [] @@ -401,6 +404,7 @@ export function createOpenClawTools( agentId: sessionAgentId, agentAccountId: options?.agentAccountId, }), + ...(includeTranscriptsTool ? [createTranscriptsTool({ config: resolvedConfig })] : []), ...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]), ...(embedded ? [] diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index eb96843699a..29cb1a19540 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -114,6 +114,18 @@ describe("openclaw-tools update_plan gating", () => { expect(toolNames(tools)).toContain("message"); }); + it("requires explicit transcripts enablement before registering the transcripts tool", () => { + const defaultTools = createFastToolNames({ + config: {} as OpenClawConfig, + }); + const enabledTools = createFastToolNames({ + config: { transcripts: { enabled: true } } as OpenClawConfig, + }); + + expect(defaultTools).not.toContain("transcripts"); + expect(enabledTools).toContain("transcripts"); + }); + it("keeps explicitly allowed message tool in embedded completions", () => { setEmbeddedMode(true); const fromRuntimeAllowlist = createOpenClawTools({ diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index bb64ab237f8..e78ec14b2e4 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -472,12 +472,12 @@ describe("handleToolExecutionEnd media emission", () => { const ctx = createMockContext({ shouldEmitToolOutput: true, toolResultFormat: "plain", - trustedLocalMediaToolNames: new Set(["meeting_notes"]), + trustedLocalMediaToolNames: new Set(["plugin_media_tool"]), }); await handleToolExecutionEnd(ctx, { type: "tool_execution_end", - toolName: "meeting_notes", + toolName: "plugin_media_tool", toolCallId: "tc-1", isError: false, result: { diff --git a/src/agents/pi-embedded-subscribe.tools.media.test.ts b/src/agents/pi-embedded-subscribe.tools.media.test.ts index 88688bc7498..51372b5bf5a 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.media.test.ts @@ -341,13 +341,13 @@ describe("extractToolResultMediaPaths", () => { }); it("does not trust bundled plugin tool names without run-local metadata", () => { - expect(isToolResultMediaTrusted("meeting_notes")).toBe(false); + expect(isToolResultMediaTrusted("plugin_media_tool")).toBe(false); }); it("trusts bundled plugin tool names carried by run-local metadata", () => { - expect(isToolResultMediaTrusted("meeting_notes", undefined, new Set(["meeting_notes"]))).toBe( - true, - ); + expect( + isToolResultMediaTrusted("plugin_media_tool", undefined, new Set(["plugin_media_tool"])), + ).toBe(true); }); it("blocks trusted-media aliases that are not exact registered built-ins", () => { @@ -391,10 +391,10 @@ describe("extractToolResultMediaPaths", () => { it("keeps local media for bundled plugin tool names trusted in this run", () => { expect( filterToolResultMediaUrls( - "meeting_notes", + "plugin_media_tool", ["/tmp/meeting.wav"], undefined, - new Set(["meeting_notes"]), + new Set(["plugin_media_tool"]), ), ).toEqual(["/tmp/meeting.wav"]); }); diff --git a/extensions/meeting-notes/index.test.ts b/src/agents/tools/transcripts-tool.test.ts similarity index 70% rename from extensions/meeting-notes/index.test.ts rename to src/agents/tools/transcripts-tool.test.ts index 57ae002f8ad..bfdfd52fc86 100644 --- a/extensions/meeting-notes/index.test.ts +++ b/src/agents/tools/transcripts-tool.test.ts @@ -1,25 +1,24 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AnyAgentTool, OpenClawPluginService } from "openclaw/plugin-sdk/plugin-entry"; -import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MeetingNotesStore } from "./src/store.js"; +import { TranscriptsStore } from "../../transcripts/store.js"; +import { createTranscriptsAutoStartService, createTranscriptsTool } from "./transcripts-tool.js"; -const { getMeetingNotesSourceProviderMock } = vi.hoisted(() => ({ - getMeetingNotesSourceProviderMock: vi.fn(), +const { getTranscriptSourceProviderMock } = vi.hoisted(() => ({ + getTranscriptSourceProviderMock: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/meeting-notes", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../transcripts/provider-registry.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - getMeetingNotesSourceProvider: getMeetingNotesSourceProviderMock, + getTranscriptSourceProvider: getTranscriptSourceProviderMock, }; }); async function makeStateDir(): Promise { - return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-meeting-notes-")); + return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcripts-")); } function currentDateDir(): string { @@ -27,44 +26,34 @@ function currentDateDir(): string { } async function createHarness(stateDir: string, pluginConfig: Record = {}) { - const providers: unknown[] = []; - const tools: AnyAgentTool[] = []; - const services: OpenClawPluginService[] = []; - const cliRegistrars: Array<{ - registrar: unknown; - opts: unknown; - }> = []; - const api = createTestPluginApi({ - pluginConfig, - runtime: { - state: { - resolveStateDir: () => stateDir, - }, - } as never, - registerMeetingNotesSourceProvider: (provider) => providers.push(provider), - registerTool: (tool) => tools.push(tool as AnyAgentTool), - registerService: (service) => services.push(service), - registerCli: (registrar, opts) => cliRegistrars.push({ registrar, opts }), - }); - const { default: meetingNotesPlugin } = await import("./index.js"); - meetingNotesPlugin.register(api); - return { cliRegistrars, providers, services, tool: tools[0] }; + const config = { transcripts: { enabled: true, ...pluginConfig } }; + const logger = { warn: vi.fn() }; + return { + logger, + service: createTranscriptsAutoStartService({ config, stateDir, logger }), + tool: createTranscriptsTool({ config, stateDir, logger }), + }; } -describe("meeting-notes plugin", () => { +describe("transcripts tool", () => { beforeEach(() => { - getMeetingNotesSourceProviderMock.mockReset(); + getTranscriptSourceProviderMock.mockReset(); }); - it("registers the manual transcript source and tool", async () => { + it("creates the core transcripts tool", async () => { const stateDir = await makeStateDir(); - const { cliRegistrars, providers, tool } = await createHarness(stateDir); + const { tool } = await createHarness(stateDir); - expect(providers).toHaveLength(1); - expect(tool?.name).toBe("meeting_notes"); - expect(cliRegistrars[0]?.opts).toMatchObject({ - descriptors: [{ name: "meeting-notes", hasSubcommands: true }], - }); + expect(tool.name).toBe("transcripts"); + }); + + it("requires explicit enablement before execution", async () => { + const stateDir = await makeStateDir(); + const { tool } = await createHarness(stateDir, { enabled: false }); + + await expect(tool.execute("call-1", { action: "status" }, undefined, vi.fn())).rejects.toThrow( + "transcripts are disabled", + ); }); it("imports a speaker transcript and writes summary artifacts", async () => { @@ -93,19 +82,19 @@ describe("meeting-notes plugin", () => { }); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "design-review", "summary.md"), + path.join(stateDir, "transcripts", currentDateDir(), "design-review", "summary.md"), "utf8", ), ).resolves.toContain("Sam: Action item: add Slack import later."); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "design-review", "summary.json"), + path.join(stateDir, "transcripts", currentDateDir(), "design-review", "summary.json"), "utf8", ), ).resolves.toContain('"Alex: We decided to ship Discord first."'); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "design-review", "transcript.jsonl"), + path.join(stateDir, "transcripts", currentDateDir(), "design-review", "transcript.jsonl"), "utf8", ), ).resolves.toContain("Alex"); @@ -130,7 +119,7 @@ describe("meeting-notes plugin", () => { ); const summary = await fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "long-meeting", "summary.md"), + path.join(stateDir, "transcripts", currentDateDir(), "long-meeting", "summary.md"), "utf8", ); expect(summary).toContain("Decision: ship the final plan."); @@ -138,7 +127,7 @@ describe("meeting-notes plugin", () => { expect(summary).toContain("## Transcript"); expect(summary).toContain("Sam: Decision: ship the final plan."); const transcript = await fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "long-meeting", "transcript.jsonl"), + path.join(stateDir, "transcripts", currentDateDir(), "long-meeting", "transcript.jsonl"), "utf8", ); expect(transcript).toContain("Action item: write the first draft."); @@ -147,7 +136,7 @@ describe("meeting-notes plugin", () => { it("requires date-qualified selectors for repeated stored session ids", async () => { const stateDir = await makeStateDir(); - const store = new MeetingNotesStore(path.join(stateDir, "meeting-notes")); + const store = new TranscriptsStore(path.join(stateDir, "transcripts")); await store.writeSession({ sessionId: "standup", title: "Tuesday standup", @@ -162,7 +151,7 @@ describe("meeting-notes plugin", () => { }); await expect(store.readSession("standup")).rejects.toThrow( - "multiple meeting notes sessions match standup", + "multiple transcripts sessions match standup", ); await expect(store.readSession("2026-05-21/standup")).resolves.toMatchObject({ title: "Tuesday standup", @@ -178,7 +167,7 @@ describe("meeting-notes plugin", () => { return { ok: true, session: request.session }; }); const stop = vi.fn(async () => ({ ok: true })); - getMeetingNotesSourceProviderMock.mockReturnValue({ + getTranscriptSourceProviderMock.mockReturnValue({ id: "discord-voice", name: "Discord Voice", sourceKinds: ["live-audio"], @@ -220,7 +209,7 @@ describe("meeting-notes plugin", () => { }); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "standup", "summary.md"), + path.join(stateDir, "transcripts", currentDateDir(), "standup", "summary.md"), "utf8", ), ).resolves.toContain("date-qualified selectors"); @@ -235,7 +224,7 @@ describe("meeting-notes plugin", () => { return { ok: true, session: request.session }; }); const stop = vi.fn(async () => ({ ok: false, error: "Discord voice manager is unavailable" })); - getMeetingNotesSourceProviderMock.mockReturnValue({ + getTranscriptSourceProviderMock.mockReturnValue({ id: "discord-voice", name: "Discord Voice", sourceKinds: ["live-audio"], @@ -272,13 +261,13 @@ describe("meeting-notes plugin", () => { }); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "standup", "summary.md"), + path.join(stateDir, "transcripts", currentDateDir(), "standup", "summary.md"), "utf8", ), ).resolves.toContain("publish the notes"); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "standup", "metadata.json"), + path.join(stateDir, "transcripts", currentDateDir(), "standup", "metadata.json"), "utf8", ), ).resolves.toContain("providerStopError"); @@ -286,7 +275,7 @@ describe("meeting-notes plugin", () => { it("does not stop a current active session when summarizing an older dated duplicate", async () => { const stateDir = await makeStateDir(); - const store = new MeetingNotesStore(path.join(stateDir, "meeting-notes")); + const store = new TranscriptsStore(path.join(stateDir, "transcripts")); const olderSession = { sessionId: "standup", title: "Older standup", @@ -300,7 +289,7 @@ describe("meeting-notes plugin", () => { }); const start = vi.fn(async (request) => ({ ok: true, session: request.session })); const stop = vi.fn(async () => ({ ok: true })); - getMeetingNotesSourceProviderMock.mockReturnValue({ + getTranscriptSourceProviderMock.mockReturnValue({ id: "discord-voice", name: "Discord Voice", sourceKinds: ["live-audio"], @@ -333,7 +322,7 @@ describe("meeting-notes plugin", () => { expect(stop).not.toHaveBeenCalled(); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", "2026-05-21", "standup", "summary.md"), + path.join(stateDir, "transcripts", "2026-05-21", "standup", "summary.md"), "utf8", ), ).resolves.toContain("preserve historical dated notes"); @@ -357,13 +346,13 @@ describe("meeting-notes plugin", () => { it("auto-starts configured live meeting sources", async () => { const stateDir = await makeStateDir(); const start = vi.fn(async (request) => ({ ok: true, session: request.session })); - getMeetingNotesSourceProviderMock.mockReturnValue({ + getTranscriptSourceProviderMock.mockReturnValue({ id: "discord-voice", name: "Discord Voice", sourceKinds: ["live-audio"], start, }); - const { services } = await createHarness(stateDir, { + const { service } = await createHarness(stateDir, { autoStart: [ { providerId: "discord-voice", @@ -374,22 +363,20 @@ describe("meeting-notes plugin", () => { }, ], }); - expect(services).toHaveLength(1); - await services[0]?.start({ - config: {}, - logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, - stateDir, - }); + service.start(); for (let i = 0; i < 20 && start.mock.calls.length === 0; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } - expect(getMeetingNotesSourceProviderMock).toHaveBeenCalledWith("discord-voice", {}); + expect(getTranscriptSourceProviderMock).toHaveBeenCalledWith( + "discord-voice", + expect.objectContaining({ transcripts: expect.any(Object) }), + ); expect(start).toHaveBeenCalledOnce(); const request = start.mock.calls[0]?.[0]; if (!request) { - throw new Error("Expected meeting notes source start request"); + throw new Error("Expected transcripts source start request"); } expect(request.session).toMatchObject({ sessionId: "standup", @@ -403,7 +390,7 @@ describe("meeting-notes plugin", () => { expect(request.startupWaitMs).toBe(30_000); await expect( fs.readFile( - path.join(stateDir, "meeting-notes", currentDateDir(), "standup", "metadata.json"), + path.join(stateDir, "transcripts", currentDateDir(), "standup", "metadata.json"), "utf8", ), ).resolves.toContain("Standup"); @@ -422,14 +409,14 @@ describe("meeting-notes plugin", () => { ); }), ); - getMeetingNotesSourceProviderMock.mockReturnValue({ + getTranscriptSourceProviderMock.mockReturnValue({ id: "discord-voice", name: "Discord Voice", sourceKinds: ["live-audio"], start, stop, }); - const { services } = await createHarness(stateDir, { + const { service, logger } = await createHarness(stateDir, { autoStart: [ { providerId: "discord-voice", @@ -439,20 +426,14 @@ describe("meeting-notes plugin", () => { }, ], }); - const logger = { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }; - const service = services[0]; - if (!service?.stop) { - throw new Error("Expected meeting notes service with stop hook"); - } - - await service.start({ config: {}, logger, stateDir }); + service.start(); await vi.waitFor(() => { expect(start).toHaveBeenCalledOnce(); }); const request = start.mock.calls[0]?.[0]; expect(request.abortSignal?.aborted).toBe(false); - await service.stop({ config: {}, logger, stateDir }); + await service.stop(); expect(request.abortSignal?.aborted).toBe(true); expect(stop).not.toHaveBeenCalled(); diff --git a/extensions/meeting-notes/src/tool.ts b/src/agents/tools/transcripts-tool.ts similarity index 67% rename from extensions/meeting-notes/src/tool.ts rename to src/agents/tools/transcripts-tool.ts index 94333c35aad..20e96f65930 100644 --- a/extensions/meeting-notes/src/tool.ts +++ b/src/agents/tools/transcripts-tool.ts @@ -1,38 +1,50 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; -import { - getMeetingNotesSourceProvider, - listMeetingNotesSourceProviders, - type MeetingNotesSessionDescriptor, - type MeetingNotesSourceLocator, -} from "openclaw/plugin-sdk/meeting-notes"; -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginService, - OpenClawPluginToolContext, -} from "openclaw/plugin-sdk/plugin-entry"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; -import { type MeetingNotesAutoStartConfig, resolveMeetingNotesConfig } from "./config.js"; -import { manualTranscriptSourceProvider } from "./manual-source.js"; -import { MeetingNotesStore, type MeetingNotesSessionEntry } from "./store.js"; -import { summarizeMeetingNotes } from "./summary.js"; +import { resolveStateDir } from "../../config/paths.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; +import { + type ResolvedTranscriptsAutoStartConfig, + resolveTranscriptsConfig, +} from "../../transcripts/config.js"; +import { manualTranscriptSourceProvider } from "../../transcripts/manual-source.js"; +import { + getTranscriptSourceProvider, + listTranscriptSourceProviders, +} from "../../transcripts/provider-registry.js"; +import type { + TranscriptSessionDescriptor, + TranscriptSourceLocator, +} from "../../transcripts/provider-types.js"; +import { TranscriptsStore, type TranscriptsSessionEntry } from "../../transcripts/store.js"; +import { summarizeTranscripts } from "../../transcripts/summary.js"; +import { type AnyAgentTool } from "./common.js"; -type ActiveMeetingNotesSession = { - session: MeetingNotesSessionDescriptor; +type TranscriptsLogger = { + warn: (message: string) => void; +}; + +type TranscriptsRuntimeContext = { + config?: OpenClawConfig; + stateDir: string; + logger: TranscriptsLogger; +}; + +type ActiveTranscriptsSession = { + session: TranscriptSessionDescriptor; providerId: string; }; -const activeSessions = new Map(); +const activeSessions = new Map(); const AUTO_START_RETRY_ATTEMPTS = 12; const AUTO_START_RETRY_MS = 5_000; const AUTO_START_STOP_TIMEOUT_MS = 5_000; const AUTO_START_PROVIDER_READY_TIMEOUT_MS = 30_000; function sameSessionIdentity( - left: MeetingNotesSessionDescriptor, - right: MeetingNotesSessionDescriptor, + left: TranscriptSessionDescriptor, + right: TranscriptSessionDescriptor, ): boolean { return left.sessionId === right.sessionId && left.startedAt === right.startedAt; } @@ -72,7 +84,7 @@ function readStringParam( return normalized || undefined; } -const MeetingNotesSchema = Type.Object( +const TranscriptsSchema = Type.Object( { action: Type.String({ description: "start, stop, status, import, or summarize.", @@ -91,11 +103,11 @@ const MeetingNotesSchema = Type.Object( ); function createSessionId(): string { - return `meeting-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; + return `transcript-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; } -function createStore(api: OpenClawPluginApi): MeetingNotesStore { - return new MeetingNotesStore(path.join(api.runtime.state.resolveStateDir(), "meeting-notes")); +function createStore(ctx: TranscriptsRuntimeContext): TranscriptsStore { + return new TranscriptsStore(path.join(ctx.stateDir, "transcripts")); } async function waitForPendingAutoStartsToSettle( @@ -120,7 +132,7 @@ async function waitForPendingAutoStartsToSettle( } } -function sourceFromParams(params: Record): MeetingNotesSourceLocator { +function sourceFromParams(params: Record): TranscriptSourceLocator { const providerId = readStringParam(params, "providerId", { trim: true }) ?? "manual-transcript"; return { providerId, @@ -131,10 +143,10 @@ function sourceFromParams(params: Record): MeetingNotesSourceLo }; } -function resolveSourceProvider(providerId: string, api: OpenClawPluginApi) { +function resolveSourceProvider(providerId: string, ctx: TranscriptsRuntimeContext) { return providerId === manualTranscriptSourceProvider.id ? manualTranscriptSourceProvider - : getMeetingNotesSourceProvider(providerId, api.config); + : getTranscriptSourceProvider(providerId, ctx.config); } function toolText(text: string, details?: Record) { @@ -145,9 +157,9 @@ function toolText(text: string, details?: Record) { } async function summarizeAndPersist(params: { - config: ReturnType; - store: MeetingNotesStore; - session: MeetingNotesSessionDescriptor; + config: ReturnType; + store: TranscriptsStore; + session: TranscriptSessionDescriptor; sessionDir?: string; }) { const utterances = @@ -158,7 +170,7 @@ async function summarizeAndPersist(params: { : await params.store.readUtterancesForSession(params.session, { maxUtterances: params.config.maxUtterances, }); - const summary = summarizeMeetingNotes({ session: params.session, utterances }); + const summary = summarizeTranscripts({ session: params.session, utterances }); const summaryPath = params.sessionDir !== undefined ? await params.store.writeSummaryToDir(summary, params.sessionDir) @@ -166,22 +178,22 @@ async function summarizeAndPersist(params: { return { summary, summaryPath }; } -async function startMeetingNotes(params: { - api: OpenClawPluginApi; - store: MeetingNotesStore; +async function startTranscripts(params: { + ctx: TranscriptsRuntimeContext; + store: TranscriptsStore; rawParams: Record; abortSignal?: AbortSignal; startupWaitMs?: number; }) { if (params.abortSignal?.aborted) { - throw new Error("meeting notes start aborted"); + throw new Error("transcripts start aborted"); } const source = sourceFromParams(params.rawParams); - const provider = resolveSourceProvider(source.providerId, params.api); + const provider = resolveSourceProvider(source.providerId, params.ctx); if (!provider?.start) { - throw new Error(`meeting notes provider ${source.providerId} cannot start live capture`); + throw new Error(`transcripts provider ${source.providerId} cannot start live capture`); } - const session: MeetingNotesSessionDescriptor = { + const session: TranscriptSessionDescriptor = { sessionId: readStringParam(params.rawParams, "sessionId", { trim: true }) ?? createSessionId(), title: readStringParam(params.rawParams, "title", { trim: true }), source, @@ -189,7 +201,7 @@ async function startMeetingNotes(params: { }; await params.store.writeSession(session); const result = await provider.start({ - cfg: params.api.config, + cfg: params.ctx.config, session, abortSignal: params.abortSignal, startupWaitMs: params.startupWaitMs, @@ -200,23 +212,23 @@ async function startMeetingNotes(params: { } if (params.abortSignal?.aborted) { await provider.stop?.({ - cfg: params.api.config, + cfg: params.ctx.config, sessionId: session.sessionId, source: session.source, reason: "service-stop", }); - throw new Error("meeting notes start aborted"); + throw new Error("transcripts start aborted"); } activeSessions.set(session.sessionId, { session, providerId: provider.id }); - return toolText(`Meeting notes started: ${session.sessionId}`, { + return toolText(`Transcripts started: ${session.sessionId}`, { sessionId: session.sessionId, providerId: provider.id, }); } -async function stopMeetingNotes(params: { - api: OpenClawPluginApi; - store: MeetingNotesStore; +async function stopTranscripts(params: { + ctx: TranscriptsRuntimeContext; + store: TranscriptsStore; rawParams: Record; }) { const sessionSelector = readStringParam(params.rawParams, "sessionId", { @@ -224,7 +236,7 @@ async function stopMeetingNotes(params: { trim: true, }); const directActive = activeSessions.get(sessionSelector); - const resolvedEntry: MeetingNotesSessionEntry | undefined = directActive + const resolvedEntry: TranscriptsSessionEntry | undefined = directActive ? { session: directActive.session, sessionDir: params.store.sessionDir(directActive.session) } : await params.store.readSessionEntry(sessionSelector); const resolvedSession = resolvedEntry?.session; @@ -237,15 +249,15 @@ async function stopMeetingNotes(params: { const selectedActive = directActive ?? (activeMatchesResolved ? activeCandidate : undefined); const session = selectedActive?.session ?? resolvedSession; if (!session) { - throw new Error(`meeting notes session not found: ${sessionSelector}`); + throw new Error(`transcripts session not found: ${sessionSelector}`); } const sessionId = session.sessionId; const providerId = selectedActive?.providerId ?? session.source.providerId; - const provider = resolveSourceProvider(providerId, params.api); + const provider = resolveSourceProvider(providerId, params.ctx); let providerStopError: string | undefined; if (selectedActive && provider?.stop) { const result = await provider.stop({ - cfg: params.api.config, + cfg: params.ctx.config, sessionId, source: session.source, reason: "tool-stop", @@ -258,7 +270,7 @@ async function stopMeetingNotes(params: { if (selectedActive) { activeSessions.delete(sessionId); } - const stoppedSession: MeetingNotesSessionDescriptor = { + const stoppedSession: TranscriptSessionDescriptor = { ...session, stoppedAt, ...(providerStopError @@ -277,12 +289,12 @@ async function stopMeetingNotes(params: { await params.store.updateStopped(sessionSelector, stoppedAt); } const { summaryPath, summary } = await summarizeAndPersist({ - config: resolveMeetingNotesConfig(params.api.pluginConfig), + config: resolveTranscriptsConfig(params.ctx.config?.transcripts), store: params.store, session: stoppedSession, sessionDir: selectedActive ? undefined : resolvedEntry?.sessionDir, }); - return toolText(`Meeting notes stopped: ${sessionId}\nSummary: ${summaryPath}`, { + return toolText(`Transcripts stopped: ${sessionId}\nSummary: ${summaryPath}`, { sessionId, ...(providerStopError ? { providerStopError } : {}), summary, @@ -290,17 +302,17 @@ async function stopMeetingNotes(params: { }); } -async function importMeetingNotes(params: { - api: OpenClawPluginApi; - store: MeetingNotesStore; +async function importTranscripts(params: { + ctx: TranscriptsRuntimeContext; + store: TranscriptsStore; rawParams: Record; }) { const source = sourceFromParams(params.rawParams); - const provider = resolveSourceProvider(source.providerId, params.api); + const provider = resolveSourceProvider(source.providerId, params.ctx); if (!provider?.importTranscript) { - throw new Error(`meeting notes provider ${source.providerId} cannot import transcripts`); + throw new Error(`transcripts provider ${source.providerId} cannot import transcripts`); } - const session: MeetingNotesSessionDescriptor = { + const session: TranscriptSessionDescriptor = { sessionId: readStringParam(params.rawParams, "sessionId", { trim: true }) ?? createSessionId(), title: readStringParam(params.rawParams, "title", { trim: true }), source, @@ -313,7 +325,7 @@ async function importMeetingNotes(params: { }); await params.store.writeSession(session); const utterances = await provider.importTranscript({ - cfg: params.api.config, + cfg: params.ctx.config, session, text: transcript, speakerLabel: readStringParam(params.rawParams, "speakerLabel", { trim: true }), @@ -322,11 +334,11 @@ async function importMeetingNotes(params: { await params.store.appendUtteranceForSession(session, utterance); } const { summaryPath, summary } = await summarizeAndPersist({ - config: resolveMeetingNotesConfig(params.api.pluginConfig), + config: resolveTranscriptsConfig(params.ctx.config?.transcripts), store: params.store, session, }); - return toolText(`Meeting transcript imported: ${session.sessionId}\nSummary: ${summaryPath}`, { + return toolText(`Transcript imported: ${session.sessionId}\nSummary: ${summaryPath}`, { sessionId: session.sessionId, utteranceCount: utterances.length, summary, @@ -335,8 +347,8 @@ async function importMeetingNotes(params: { } async function summarizeExisting(params: { - config: ReturnType; - store: MeetingNotesStore; + config: ReturnType; + store: TranscriptsStore; rawParams: Record; }) { const sessionId = readStringParam(params.rawParams, "sessionId", { @@ -345,7 +357,7 @@ async function summarizeExisting(params: { }); const entry = await params.store.readSessionEntry(sessionId); if (!entry) { - throw new Error(`meeting notes session not found: ${sessionId}`); + throw new Error(`transcripts session not found: ${sessionId}`); } const { summaryPath, summary } = await summarizeAndPersist({ config: params.config, @@ -353,17 +365,17 @@ async function summarizeExisting(params: { session: entry.session, sessionDir: entry.sessionDir, }); - return toolText(`Meeting notes summarized: ${sessionId}\nSummary: ${summaryPath}`, { + return toolText(`Transcripts summarized: ${sessionId}\nSummary: ${summaryPath}`, { sessionId, summary, summaryPath, }); } -async function statusMeetingNotes(api: OpenClawPluginApi) { +async function statusTranscripts(ctx: TranscriptsRuntimeContext) { const providers = [ manualTranscriptSourceProvider.id, - ...listMeetingNotesSourceProviders(api.config).map((provider) => provider.id), + ...listTranscriptSourceProviders(ctx.config).map((provider) => provider.id), ]; const uniqueProviders = uniqueStrings(providers); const active = [...activeSessions.values()].map((entry) => ({ @@ -374,50 +386,59 @@ async function statusMeetingNotes(api: OpenClawPluginApi) { })); return toolText( [ - `Meeting notes providers: ${uniqueProviders.length ? uniqueProviders.join(", ") : "none"}`, + `Transcripts providers: ${uniqueProviders.length ? uniqueProviders.join(", ") : "none"}`, `Active sessions: ${active.length}`, ].join("\n"), { providers: uniqueProviders, active }, ); } -export function createMeetingNotesTool( - api: OpenClawPluginApi, - _ctx?: OpenClawPluginToolContext, -): AnyAgentTool { +export function createTranscriptsTool(options?: { + config?: OpenClawConfig; + stateDir?: string; + logger?: TranscriptsLogger; +}): AnyAgentTool { + const ctx: TranscriptsRuntimeContext = { + config: options?.config, + stateDir: options?.stateDir ?? resolveStateDir(), + logger: options?.logger ?? console, + }; return { - name: "meeting_notes", - label: "Meeting Notes", + name: "transcripts", + label: "Transcripts", description: - "Start, stop, import, summarize, or inspect meeting notes from Discord, Google Meet, Slack huddles, and other meeting sources.", - parameters: MeetingNotesSchema, + "Start, stop, import, summarize, or inspect transcripts from Discord, Google Meet, Slack huddles, and other meeting sources.", + parameters: TranscriptsSchema, async execute(_toolCallId, rawParams) { - const config = resolveMeetingNotesConfig(api.pluginConfig); + const config = resolveTranscriptsConfig(ctx.config?.transcripts); if (!config.enabled) { - throw new Error("meeting notes plugin is disabled"); + throw new Error("transcripts are disabled"); } const params = asParamsRecord(rawParams); const action = readStringParam(params, "action", { required: true, trim: true }); - const store = createStore(api); + const store = createStore(ctx); switch (action) { case "start": - return await startMeetingNotes({ api, store, rawParams: params }); + return await startTranscripts({ ctx, store, rawParams: params }); case "stop": - return await stopMeetingNotes({ api, store, rawParams: params }); + return await stopTranscripts({ ctx, store, rawParams: params }); case "import": - return await importMeetingNotes({ api, store, rawParams: params }); + return await importTranscripts({ ctx, store, rawParams: params }); case "summarize": return await summarizeExisting({ config, store, rawParams: params }); case "status": - return await statusMeetingNotes(api); + return await statusTranscripts(ctx); default: - throw new Error(`unsupported meeting_notes action: ${action}`); + throw new Error(`unsupported transcripts action: ${action}`); } }, }; } -export function createMeetingNotesAutoStartService(api: OpenClawPluginApi): OpenClawPluginService { +export function createTranscriptsAutoStartService(ctx: TranscriptsRuntimeContext): { + start: () => void; + stop: () => Promise; +} { let stopped = false; const timers = new Set>(); const startedSessionIds = new Set(); @@ -433,18 +454,17 @@ export function createMeetingNotesAutoStartService(api: OpenClawPluginApi): Open }; const startEntry = ( - entry: MeetingNotesAutoStartConfig, + entry: ResolvedTranscriptsAutoStartConfig, attempt: number, - serviceApi: OpenClawPluginApi, - store: MeetingNotesStore, + store: TranscriptsStore, ) => { - if (stopped || !entry.enabled || startedSessionIds.has(entry.sessionId ?? "")) { + if (stopped || startedSessionIds.has(entry.sessionId ?? "")) { return; } const abortController = new AbortController(); pendingStartControllers.add(abortController); - const startTask = startMeetingNotes({ - api: serviceApi, + const startTask = startTranscripts({ + ctx, store, abortSignal: abortController.signal, startupWaitMs: AUTO_START_PROVIDER_READY_TIMEOUT_MS, @@ -465,14 +485,14 @@ export function createMeetingNotesAutoStartService(api: OpenClawPluginApi): Open return; } if (attempt >= AUTO_START_RETRY_ATTEMPTS) { - api.logger.warn( - `meeting-notes autoStart failed provider=${entry.providerId}: ${ + ctx.logger.warn( + `transcripts autoStart failed provider=${entry.providerId}: ${ err instanceof Error ? err.message : String(err) }`, ); return; } - schedule(() => startEntry(entry, attempt + 1, serviceApi, store), AUTO_START_RETRY_MS); + schedule(() => startEntry(entry, attempt + 1, store), AUTO_START_RETRY_MS); }) .finally(() => { pendingStartControllers.delete(abortController); @@ -482,14 +502,12 @@ export function createMeetingNotesAutoStartService(api: OpenClawPluginApi): Open }; return { - id: "meeting-notes-auto-start", - start(ctx) { - const config = resolveMeetingNotesConfig(api.pluginConfig); + start() { + const config = resolveTranscriptsConfig(ctx.config?.transcripts); if (!config.enabled || config.autoStart.length === 0) { return; } - const serviceApi = { ...api, config: ctx.config }; - const store = new MeetingNotesStore(path.join(ctx.stateDir, "meeting-notes")); + const store = new TranscriptsStore(path.join(ctx.stateDir, "transcripts")); for (const entry of config.autoStart) { startEntry( { @@ -497,12 +515,11 @@ export function createMeetingNotesAutoStartService(api: OpenClawPluginApi): Open sessionId: entry.sessionId ?? createSessionId(), }, 1, - serviceApi, store, ); } }, - async stop(ctx) { + async stop() { stopped = true; for (const timer of timers) { clearTimeout(timer); @@ -513,22 +530,21 @@ export function createMeetingNotesAutoStartService(api: OpenClawPluginApi): Open } const pendingStartsSettled = await waitForPendingAutoStartsToSettle(pendingStarts); if (!pendingStartsSettled) { - api.logger.warn( - `meeting-notes autoStart stop timed out waiting for ${pendingStarts.size} pending start${ + ctx.logger.warn( + `transcripts autoStart stop timed out waiting for ${pendingStarts.size} pending start${ pendingStarts.size === 1 ? "" : "s" }`, ); } - const serviceApi = { ...api, config: ctx.config }; - const store = new MeetingNotesStore(path.join(ctx.stateDir, "meeting-notes")); + const store = new TranscriptsStore(path.join(ctx.stateDir, "transcripts")); for (const sessionId of startedSessionIds) { - await stopMeetingNotes({ - api: serviceApi, + await stopTranscripts({ + ctx, store, rawParams: { action: "stop", sessionId }, }).catch((err) => - api.logger.warn( - `meeting-notes autoStart stop failed session=${sessionId}: ${ + ctx.logger.warn( + `transcripts autoStart stop failed session=${sessionId}: ${ err instanceof Error ? err.message : String(err) }`, ), diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts index 6f476f5d2d6..8008ec4f3ab 100644 --- a/src/auto-reply/reply/commands-diagnostics.test.ts +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -122,7 +122,7 @@ function createBundledPluginRecord(id: string): PluginRecord { realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], diff --git a/src/cli/program/command-registry-core.ts b/src/cli/program/command-registry-core.ts index c0b777147f5..084a697cbbe 100644 --- a/src/cli/program/command-registry-core.ts +++ b/src/cli/program/command-registry-core.ts @@ -107,6 +107,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec< loadModule: () => import("../mcp-cli.js"), exportName: "registerMcpCli", }, + { + commandNames: ["transcripts"], + loadModule: () => import("./register.transcripts.js"), + exportName: "registerTranscriptsCli", + }, ]), ), defineImportedCommandGroupSpec( diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index a3074202617..6ed2f349663 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -71,6 +71,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([ hasSubcommands: true, parentDefaultHelp: true, }, + { + name: "transcripts", + description: "Inspect stored transcripts", + hasSubcommands: true, + }, { name: "agent", description: "Run one agent turn via the Gateway", diff --git a/extensions/meeting-notes/src/cli.test.ts b/src/cli/program/register.transcripts.test.ts similarity index 73% rename from extensions/meeting-notes/src/cli.test.ts rename to src/cli/program/register.transcripts.test.ts index d704da4ed68..08c7c03c989 100644 --- a/extensions/meeting-notes/src/cli.test.ts +++ b/src/cli/program/register.transcripts.test.ts @@ -3,12 +3,12 @@ import os from "node:os"; import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { registerMeetingNotesCli } from "./cli.js"; +import { registerTranscriptsCli } from "./register.transcripts.js"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; async function makeStateDir(): Promise { - return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-meeting-notes-cli-")); + return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcripts-cli-")); } async function writeSession( @@ -16,7 +16,7 @@ async function writeSession( sessionId: string, date = "2026-05-22", ): Promise { - const sessionDir = path.join(stateDir, "meeting-notes", date, sessionId); + const sessionDir = path.join(stateDir, "transcripts", date, sessionId); await fs.mkdir(sessionDir, { recursive: true }); await fs.writeFile( path.join(sessionDir, "metadata.json"), @@ -39,7 +39,7 @@ async function writeSession( return sessionDir; } -async function runMeetingNotesCli(args: string[]): Promise { +async function runTranscriptsCli(args: string[]): Promise { let output = ""; const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((( chunk: string | Uint8Array, @@ -50,15 +50,15 @@ async function runMeetingNotesCli(args: string[]): Promise { try { const program = new Command(); program.name("openclaw"); - registerMeetingNotesCli(program); - await program.parseAsync(["meeting-notes", ...args], { from: "user" }); + registerTranscriptsCli(program); + await program.parseAsync(["transcripts", ...args], { from: "user" }); return output; } finally { writeSpy.mockRestore(); } } -describe("meeting-notes CLI", () => { +describe("transcripts CLI", () => { let stateDir = ""; beforeEach(async () => { @@ -76,15 +76,15 @@ describe("meeting-notes CLI", () => { it("registers a kebab-case command", () => { const program = new Command(); - registerMeetingNotesCli(program); + registerTranscriptsCli(program); - expect(program.commands.map((command) => command.name())).toContain("meeting-notes"); + expect(program.commands.map((command) => command.name())).toContain("transcripts"); }); - it("lists stored meeting note sessions", async () => { + it("lists stored transcript sessions", async () => { const sessionDir = await writeSession(stateDir, "design-review"); - const output = await runMeetingNotesCli(["list"]); + const output = await runTranscriptsCli(["list"]); expect(output).toContain("2026-05-22/design-review"); expect(output).toContain("Design review"); @@ -94,7 +94,7 @@ describe("meeting-notes CLI", () => { it("prints summary markdown for a session", async () => { await writeSession(stateDir, "design-review"); - const output = await runMeetingNotesCli(["show", "design-review"]); + const output = await runTranscriptsCli(["show", "design-review"]); expect(output).toContain("# Design review"); expect(output).toContain("Ship CLI"); @@ -102,12 +102,12 @@ describe("meeting-notes CLI", () => { it("ignores unrelated corrupt metadata while reading a valid session", async () => { await writeSession(stateDir, "design-review"); - const corruptDir = path.join(stateDir, "meeting-notes", "corrupt"); + const corruptDir = path.join(stateDir, "transcripts", "corrupt"); await fs.mkdir(corruptDir, { recursive: true }); await fs.writeFile(path.join(corruptDir, "metadata.json"), "{nope"); - const listOutput = await runMeetingNotesCli(["list"]); - const showOutput = await runMeetingNotesCli(["show", "design-review"]); + const listOutput = await runTranscriptsCli(["list"]); + const showOutput = await runTranscriptsCli(["show", "design-review"]); expect(listOutput).toContain("design-review"); expect(listOutput).not.toContain("corrupt"); @@ -118,10 +118,10 @@ describe("meeting-notes CLI", () => { const olderSessionDir = await writeSession(stateDir, "standup", "2026-05-21"); await writeSession(stateDir, "standup", "2026-05-22"); - await expect(runMeetingNotesCli(["path", "standup"])).rejects.toThrow( - "multiple meeting notes sessions match standup", + await expect(runTranscriptsCli(["path", "standup"])).rejects.toThrow( + "multiple transcripts sessions match standup", ); - const output = await runMeetingNotesCli(["path", "2026-05-21/standup"]); + const output = await runTranscriptsCli(["path", "2026-05-21/standup"]); expect(output.trim()).toBe(path.join(olderSessionDir, "summary.md")); }); @@ -129,7 +129,7 @@ describe("meeting-notes CLI", () => { it("prints the summary path by default", async () => { const sessionDir = await writeSession(stateDir, "design-review"); - const output = await runMeetingNotesCli(["path", "design-review"]); + const output = await runTranscriptsCli(["path", "design-review"]); expect(output.trim()).toBe(path.join(sessionDir, "summary.md")); }); diff --git a/extensions/meeting-notes/src/cli.ts b/src/cli/program/register.transcripts.ts similarity index 72% rename from extensions/meeting-notes/src/cli.ts rename to src/cli/program/register.transcripts.ts index 8b9a6ced55c..32230c1db6c 100644 --- a/extensions/meeting-notes/src/cli.ts +++ b/src/cli/program/register.transcripts.ts @@ -2,40 +2,40 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { Command } from "commander"; -import type { MeetingNotesSessionDescriptor } from "openclaw/plugin-sdk/meeting-notes"; -import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { resolveStateDir } from "../../config/paths.js"; +import type { TranscriptSessionDescriptor } from "../../transcripts/provider-types.js"; -type MeetingNotesCliOptions = { +type TranscriptsCliOptions = { json?: boolean; }; -type MeetingNotesPathOptions = MeetingNotesCliOptions & { +type TranscriptsPathOptions = TranscriptsCliOptions & { dir?: boolean; metadata?: boolean; transcript?: boolean; }; -type StoredMeetingNotesSession = { - session: MeetingNotesSessionDescriptor; +type StoredTranscriptsSession = { + session: TranscriptSessionDescriptor; sessionDir: string; date: string; summaryPath: string; hasSummary: boolean; }; -const MEETING_NOTES_STATE_SUBDIR = "meeting-notes"; +const TRANSCRIPTS_STATE_SUBDIR = "transcripts"; function safeSegment(value: string): string { return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "session"; } function stateRootDir(): string { - return path.join(resolveStateDir(), MEETING_NOTES_STATE_SUBDIR); + return path.join(resolveStateDir(), TRANSCRIPTS_STATE_SUBDIR); } function dateFromSessionId(sessionId: string): string | undefined { return sessionId - .match(/^meeting-(\d{4})-(\d{2})-(\d{2})T/) + .match(/^transcript-(\d{4})-(\d{2})-(\d{2})T/) ?.slice(1, 4) .join("-"); } @@ -47,12 +47,12 @@ function sessionDir(date: string, sessionId: string): string { function readDateFromSessionDir(sessionDir: string): string { const candidate = path.basename(path.dirname(sessionDir)); if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { - throw new Error(`invalid meeting notes date directory: ${candidate}`); + throw new Error(`invalid transcripts date directory: ${candidate}`); } return candidate; } -function formatSelector(entry: StoredMeetingNotesSession): string { +function formatSelector(entry: StoredTranscriptsSession): string { return `${entry.date}/${entry.session.sessionId}`; } @@ -99,10 +99,10 @@ function formatErrorMessage(err: unknown): string { async function readStoredSession( sessionDir: string, options: { ignoreInvalid?: boolean } = {}, -): Promise { +): Promise { const metadataPath = path.join(sessionDir, "metadata.json"); try { - const session = await readJsonFile(metadataPath); + const session = await readJsonFile(metadataPath); const summaryPath = path.join(sessionDir, "summary.md"); return { session, @@ -118,12 +118,9 @@ async function readStoredSession( if (options.ignoreInvalid) { return null; } - throw new Error( - `invalid meeting notes metadata at ${metadataPath}: ${formatErrorMessage(err)}`, - { - cause: err, - }, - ); + throw new Error(`invalid transcripts metadata at ${metadataPath}: ${formatErrorMessage(err)}`, { + cause: err, + }); } } @@ -157,23 +154,23 @@ async function listStoredSessionDirs(): Promise { } function assertRequestedSession( - entry: StoredMeetingNotesSession, + entry: StoredTranscriptsSession, sessionId: string, -): StoredMeetingNotesSession { +): StoredTranscriptsSession { if (entry.session.sessionId !== sessionId) { throw new Error( - `meeting notes metadata mismatch for ${sessionId}: found ${entry.session.sessionId}`, + `transcripts metadata mismatch for ${sessionId}: found ${entry.session.sessionId}`, ); } return entry; } -async function requireStoredSession(selector: string): Promise { +async function requireStoredSession(selector: string): Promise { const qualified = parseQualifiedSelector(selector); if (qualified) { const session = await readStoredSession(sessionDir(qualified.date, qualified.sessionId)); if (!session) { - throw new Error(`meeting notes session not found: ${selector}`); + throw new Error(`transcripts session not found: ${selector}`); } return assertRequestedSession(session, qualified.sessionId); } @@ -190,15 +187,15 @@ async function requireStoredSession(selector: string): Promise 1) { throw new Error( - `multiple meeting notes sessions match ${selector}; use one of: ${matches + `multiple transcripts sessions match ${selector}; use one of: ${matches .map(formatSelector) .join(", ")}`, ); } - throw new Error(`meeting notes session not found: ${selector}`); + throw new Error(`transcripts session not found: ${selector}`); } -async function listStoredSessions(): Promise { +async function listStoredSessions(): Promise { const dirs = await listStoredSessionDirs(); const sessions = await Promise.all( dirs.map((dir) => @@ -208,20 +205,20 @@ async function listStoredSessions(): Promise { ), ); return sessions - .filter((session): session is StoredMeetingNotesSession => session !== null) + .filter((session): session is StoredTranscriptsSession => session !== null) .toSorted((left, right) => (right.session.startedAt ?? "").localeCompare(left.session.startedAt ?? ""), ); } -function formatSessionLine(entry: StoredMeetingNotesSession): string { - const title = entry.session.title?.trim() || "Meeting notes"; +function formatSessionLine(entry: StoredTranscriptsSession): string { + const title = entry.session.title?.trim() || "Transcripts"; const started = entry.session.startedAt || "unknown"; const summary = entry.hasSummary ? entry.summaryPath : "no summary.md"; return `${formatSelector(entry)}\t${started}\t${title}\t${summary}`; } -async function listCommand(options: MeetingNotesCliOptions): Promise { +async function listCommand(options: TranscriptsCliOptions): Promise { const sessions = await listStoredSessions(); if (options.json) { writeJson( @@ -241,7 +238,7 @@ async function listCommand(options: MeetingNotesCliOptions): Promise { return; } if (sessions.length === 0) { - writeLine("No meeting notes found."); + writeLine("No transcripts found."); return; } for (const session of sessions) { @@ -249,7 +246,7 @@ async function listCommand(options: MeetingNotesCliOptions): Promise { } } -async function showCommand(sessionId: string, options: MeetingNotesCliOptions): Promise { +async function showCommand(sessionId: string, options: TranscriptsCliOptions): Promise { const session = await requireStoredSession(sessionId); if (options.json) { const summary = session.hasSummary ? await fs.readFile(session.summaryPath, "utf8") : null; @@ -263,12 +260,12 @@ async function showCommand(sessionId: string, options: MeetingNotesCliOptions): return; } if (!session.hasSummary) { - throw new Error(`summary.md not found for meeting notes session: ${sessionId}`); + throw new Error(`summary.md not found for transcripts session: ${sessionId}`); } process.stdout.write(await fs.readFile(session.summaryPath, "utf8")); } -async function pathCommand(selector: string, options: MeetingNotesPathOptions): Promise { +async function pathCommand(selector: string, options: TranscriptsPathOptions): Promise { const session = await requireStoredSession(selector); const selectedPath = options.dir ? session.sessionDir @@ -289,35 +286,35 @@ async function pathCommand(selector: string, options: MeetingNotesPathOptions): writeLine(selectedPath); } -export function registerMeetingNotesCli(program: Command): void { - const meetingNotes = program.command("meeting-notes").description("Inspect stored meeting notes"); +export function registerTranscriptsCli(program: Command): void { + const transcripts = program.command("transcripts").description("Inspect stored transcripts"); - meetingNotes + transcripts .command("list") - .description("List stored meeting note sessions") + .description("List stored transcript sessions") .option("--json", "Print JSON") - .action(async (options: MeetingNotesCliOptions) => { + .action(async (options: TranscriptsCliOptions) => { await listCommand(options); }); - meetingNotes + transcripts .command("show") - .description("Print a meeting summary markdown file") - .argument("", "Meeting notes session id or YYYY-MM-DD/session selector") + .description("Print a transcript summary markdown file") + .argument("", "Transcripts session id or YYYY-MM-DD/session selector") .option("--json", "Print JSON") - .action(async (sessionId: string, options: MeetingNotesCliOptions) => { + .action(async (sessionId: string, options: TranscriptsCliOptions) => { await showCommand(sessionId, options); }); - meetingNotes + transcripts .command("path") - .description("Print a stored meeting notes artifact path") - .argument("", "Meeting notes session id or YYYY-MM-DD/session selector") + .description("Print a stored transcripts artifact path") + .argument("", "Transcripts session id or YYYY-MM-DD/session selector") .option("--dir", "Print the session directory") .option("--metadata", "Print metadata.json") .option("--transcript", "Print transcript.jsonl") .option("--json", "Print JSON") - .action(async (sessionId: string, options: MeetingNotesPathOptions) => { + .action(async (sessionId: string, options: TranscriptsPathOptions) => { await pathCommand(sessionId, options); }); } diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index ed6a79d1fb7..cd7e0b4bd26 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -28,6 +28,7 @@ const ROOT_SECTIONS = [ "approvals", "session", "cron", + "transcripts", "hooks", "web", "channels", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 153908d6ee9..305ca4c9a61 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1706,6 +1706,28 @@ export const FIELD_HELP: Record = { "Maximum bytes per cron run-log file before pruning rewrites to the last keepLines entries (for example `2mb`, default `2000000`).", "cron.runLog.keepLines": "How many trailing run-log lines to retain when a file exceeds maxBytes (default `2000`). Increase for longer forensic history or lower for smaller disks.", + transcripts: + "Core transcript capture settings for recording-capable agent tools and configured live meeting auto-start sources. Keep disabled unless operators explicitly want agents to capture or import meeting transcripts.", + "transcripts.enabled": + "Enables the recording-capable transcripts agent tool and configured auto-start sources. Default: false. Enable only on hosts where operators have reviewed meeting capture policy and provider permissions.", + "transcripts.maxUtterances": + "Maximum utterances retained in a transcript summary operation before truncation. Use lower values to limit prompt/storage footprint, or raise carefully for long meetings where summary completeness matters.", + "transcripts.autoStart": + "Live transcript sources started automatically when the gateway starts. Each entry is enabled by being present; remove an entry to disable that source.", + "transcripts.autoStart[].providerId": + "Transcript source provider id, such as a Discord voice or future Slack huddle provider. Use the exact id exposed by the provider plugin.", + "transcripts.autoStart[].sessionId": + "Optional fixed transcript session id for this auto-start source. Leave unset for generated ids unless you need a stable daily selector and can avoid same-day collisions.", + "transcripts.autoStart[].title": + "Optional human-readable title stored with the transcript session and shown in transcript listings. Use concise meeting names that help operators identify the captured source.", + "transcripts.autoStart[].accountId": + "Optional provider account or workspace identifier for transcript sources that need account disambiguation. Use the provider's documented account id format.", + "transcripts.autoStart[].guildId": + "Optional Discord guild id for Discord voice transcript sources. Configure this with the matching channelId when the provider needs guild-scoped voice channel lookup.", + "transcripts.autoStart[].channelId": + "Provider channel id for the live transcript source, such as a Discord voice channel or Slack huddle channel. Verify provider-specific id semantics before enabling auto-start.", + "transcripts.autoStart[].meetingUrl": + "Optional meeting URL for providers that join by URL instead of channel id. Use only trusted meeting links because auto-start may join and capture that meeting.", hooks: "Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.", "hooks.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index e3d702bba12..278f6497ed7 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -837,6 +837,17 @@ export const FIELD_LABELS: Record = { "cron.runLog": "Cron Run Log Pruning", "cron.runLog.maxBytes": "Cron Run Log Max Bytes", "cron.runLog.keepLines": "Cron Run Log Keep Lines", + transcripts: "Transcripts", + "transcripts.enabled": "Transcripts Enabled", + "transcripts.maxUtterances": "Transcripts Max Utterances", + "transcripts.autoStart": "Transcripts Auto-start Sources", + "transcripts.autoStart[].providerId": "Transcript Source Provider ID", + "transcripts.autoStart[].sessionId": "Transcript Session ID", + "transcripts.autoStart[].title": "Transcript Title", + "transcripts.autoStart[].accountId": "Transcript Account ID", + "transcripts.autoStart[].guildId": "Discord Guild ID", + "transcripts.autoStart[].channelId": "Transcript Channel ID", + "transcripts.autoStart[].meetingUrl": "Transcript Meeting URL", hooks: "Hooks", "hooks.enabled": "Hooks Enabled", "hooks.path": "Hooks Endpoint Path", diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 304bf38e84d..c19d885afeb 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -1,4 +1,5 @@ import type { SilentReplyPolicyShape } from "../shared/silent-reply-policy.js"; +import type { TranscriptsConfig } from "../transcripts/config.js"; import type { AccessGroupsConfig } from "./types.access-groups.js"; import type { AcpConfig } from "./types.acp.js"; import type { AgentBinding, AgentsConfig } from "./types.agents.js"; @@ -142,6 +143,7 @@ export type OpenClawConfig = { web?: WebConfig; channels?: ChannelsConfig; cron?: CronConfig; + transcripts?: TranscriptsConfig; commitments?: CommitmentsConfig; hooks?: HooksConfig; discovery?: DiscoveryConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ba3921ee6cf..79644bdf3e4 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -817,6 +817,28 @@ export const OpenClawSchema = z } }) .optional(), + transcripts: z + .object({ + enabled: z.boolean().optional(), + maxUtterances: z.number().int().min(1).max(10_000).optional(), + autoStart: z + .array( + z + .object({ + providerId: z.string().min(1), + sessionId: z.string().min(1).optional(), + title: z.string().min(1).optional(), + accountId: z.string().min(1).optional(), + guildId: z.string().min(1).optional(), + channelId: z.string().min(1).optional(), + meetingUrl: z.string().min(1).optional(), + }) + .strict(), + ) + .optional(), + }) + .strict() + .optional(), commitments: CommitmentsSchema, hooks: z .object({ diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 6d8e433f201..113cdec5ee4 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -198,6 +198,47 @@ describe("createGatewayCloseHandler", () => { expect(stopChannel).toHaveBeenCalledWith("discord"); }); + it("awaits post-ready sidecars before plugin services and channels", async () => { + const events: string[] = []; + let releaseSidecar!: () => void; + const sidecarReleased = new Promise((resolve) => { + releaseSidecar = resolve; + }); + const postReadySidecar = { + stop: vi.fn(async () => { + events.push("sidecar:start"); + await sidecarReleased; + events.push("sidecar:end"); + }), + }; + const pluginServices = { + stop: vi.fn(async () => { + events.push("plugin-services"); + }), + }; + const stopChannel = vi.fn(async (channelId: string) => { + events.push(`channel:${channelId}`); + }); + const close = createGatewayCloseHandler( + createGatewayCloseTestDeps({ + channelIds: ["discord"], + postReadySidecars: [postReadySidecar], + pluginServices: pluginServices as never, + stopChannel, + }), + ); + + const closePromise = close({ reason: "test" }); + await vi.waitFor(() => { + expect(events).toEqual(["sidecar:start"]); + }); + releaseSidecar(); + await closePromise; + + expect(events).toEqual(["sidecar:start", "sidecar:end", "plugin-services", "channel:discord"]); + expect(postReadySidecar.stop).toHaveBeenCalledTimes(1); + }); + it("emits gateway shutdown and pre-restart hooks", async () => { const close = createGatewayCloseHandler(createGatewayCloseTestDeps()); diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 5f12186c63c..b1073565e1a 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -527,6 +527,13 @@ export function createGatewayCloseHandler(params: { if (params.tailscaleCleanup) { await shutdownStep("tailscale", () => params.tailscaleCleanup!(), warnings); } + if (params.postReadySidecars?.length) { + await measureCloseStep("post-ready-sidecars", async () => { + for (const [index, sidecar] of params.postReadySidecars!.entries()) { + await shutdownStep(`post-ready-sidecar/${index}`, () => sidecar.stop(), warnings); + } + }); + } if (params.pluginServices) { await measureCloseStep("plugin-services", () => shutdownStep("plugin-services", () => params.pluginServices!.stop(), warnings), diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 687873f02cf..330a6c4a129 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -93,7 +93,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ realtimeTranscriptionProviders: [], realtimeVoiceProviders: [], mediaUnderstandingProviders: [], - meetingNotesSourceProviders: [], + transcriptSourceProviders: [], imageGenerationProviders: [], musicGenerationProviders: [], videoGenerationProviders: [], diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 0f70ddb2ec9..36562c1f483 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -143,7 +143,7 @@ type GatewayReloadHandlerParams = { setState: (state: GatewayHotReloadState) => void; startChannel: GatewayChannelManager["startChannel"]; stopChannel: GatewayChannelManager["stopChannel"]; - stopPostReadySidecars?: () => void; + stopPostReadySidecars?: () => Promise | void; reloadPlugins: (params: { nextConfig: OpenClawConfig; changedPaths: readonly string[]; @@ -430,7 +430,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) } if (plan.restartGmailWatcher) { - params.stopPostReadySidecars?.(); + await params.stopPostReadySidecars?.(); const restartAbortController = params.createGmailRestartAbortController?.() ?? new AbortController(); try { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 39f71bea74c..99917219410 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -49,6 +49,11 @@ const hoisted = vi.hoisted(() => { const clearCurrentProviderAuthState = vi.fn(); const warmCurrentProviderAuthState = vi.fn(async (_cfg?: unknown, _options?: unknown) => {}); const setAuthProfileFailureHook = vi.fn(); + const transcriptsAutoStartService = { + start: vi.fn(), + stop: vi.fn(async () => {}), + }; + const createTranscriptsAutoStartService = vi.fn(() => transcriptsAutoStartService); return { startPluginServices, startGmailWatcherWithLogs, @@ -76,6 +81,8 @@ const hoisted = vi.hoisted(() => { clearCurrentProviderAuthState, warmCurrentProviderAuthState, setAuthProfileFailureHook, + transcriptsAutoStartService, + createTranscriptsAutoStartService, }; }); @@ -179,6 +186,10 @@ vi.mock("../agents/auth-profiles.js", async () => { }; }); +vi.mock("../agents/tools/transcripts-tool.js", () => ({ + createTranscriptsAutoStartService: hoisted.createTranscriptsAutoStartService, +})); + vi.mock("./server-tailscale.js", () => ({ startGatewayTailscaleExposure: hoisted.startGatewayTailscaleExposure, })); @@ -277,6 +288,10 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.warmCurrentProviderAuthState.mockReset(); hoisted.warmCurrentProviderAuthState.mockResolvedValue(undefined); hoisted.setAuthProfileFailureHook.mockClear(); + hoisted.transcriptsAutoStartService.start.mockClear(); + hoisted.transcriptsAutoStartService.stop.mockClear(); + hoisted.transcriptsAutoStartService.stop.mockResolvedValue(undefined); + hoisted.createTranscriptsAutoStartService.mockClear(); }); afterEach(() => { @@ -888,6 +903,52 @@ describe("startGatewayPostAttachRuntime", () => { } }); + it("keeps transcripts auto-start alive when Gmail post-ready sidecars stop", async () => { + const onPostReadySidecars = vi.fn(); + const onGatewayLifetimeSidecars = vi.fn(); + const config = { + hooks: { + enabled: true, + internal: { enabled: false }, + gmail: { account: "me" }, + }, + transcripts: { + autoStart: [{ providerId: "discord-voice", guildId: "g", channelId: "c" }], + }, + }; + + await startGatewayPostAttachRuntime({ + ...createPostAttachParams({ + cfgAtStart: config as never, + gatewayPluginConfigAtStart: config as never, + }), + providerAuthPrewarm: { enabled: false }, + onPostReadySidecars, + onGatewayLifetimeSidecars, + }); + + const gmailSidecars = onPostReadySidecars.mock.calls[0]?.[0] as + | Array<{ stop: () => Promise | void }> + | undefined; + const lifetimeSidecars = onGatewayLifetimeSidecars.mock.calls[0]?.[0] as + | Array<{ stop: () => Promise | void }> + | undefined; + expect(gmailSidecars).toHaveLength(1); + expect(lifetimeSidecars).toHaveLength(1); + + await vi.waitFor(() => { + expect(hoisted.transcriptsAutoStartService.start).toHaveBeenCalledTimes(1); + }); + + for (const sidecar of gmailSidecars ?? []) { + await sidecar.stop(); + } + expect(hoisted.transcriptsAutoStartService.stop).not.toHaveBeenCalled(); + + await lifetimeSidecars?.[0]?.stop(); + expect(hoisted.transcriptsAutoStartService.stop).toHaveBeenCalledTimes(1); + }); + it("cancels delayed provider auth prewarm when the sidecar stops before the timer fires", async () => { vi.useFakeTimers(); const log = { info: vi.fn(), warn: vi.fn() }; @@ -903,7 +964,7 @@ describe("startGatewayPostAttachRuntime", () => { expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1); }); - sidecar.stop(); + await sidecar.stop(); await vi.advanceTimersByTimeAsync(1_000); expect(hoisted.warmCurrentProviderAuthState).not.toHaveBeenCalled(); @@ -1339,7 +1400,7 @@ describe("startGatewayPostAttachRuntime", () => { if (!resolveWatcher) { throw new Error("Expected gmail watcher resolver to be initialized"); } - result.postReadySidecars[0]?.stop(); + await result.postReadySidecars[0]?.stop(); expect(watcherSignal?.aborted).toBe(true); resolveWatcher(); }); @@ -1398,7 +1459,7 @@ describe("startGatewayPostAttachRuntime", () => { }); expect(result.postReadySidecars).toHaveLength(1); - result.postReadySidecars[0]?.stop(); + await result.postReadySidecars[0]?.stop(); await new Promise((resolve) => setImmediate(resolve)); expect(hoisted.startGmailWatcherWithLogs).not.toHaveBeenCalled(); @@ -1442,7 +1503,7 @@ describe("startGatewayPostAttachRuntime", () => { await vi.waitFor(() => { expect(releaseImport).toBeDefined(); }); - result.postReadySidecars[0]?.stop(); + await result.postReadySidecars[0]?.stop(); releaseImport?.(); await new Promise((resolve) => setImmediate(resolve)); @@ -1453,7 +1514,7 @@ describe("startGatewayPostAttachRuntime", () => { } }); - it("keeps already-started Gmail watcher cleanup on close", async () => { + it("stops already-started Gmail watcher cleanup on close", async () => { const postReadySidecars = [{ stop: vi.fn() }]; const stopChannel = vi.fn(async () => {}); const pluginServices = { stop: vi.fn(async () => {}) }; @@ -1492,7 +1553,7 @@ describe("startGatewayPostAttachRuntime", () => { await close(); - expect(postReadySidecars[0]?.stop).not.toHaveBeenCalled(); + expect(postReadySidecars[0]?.stop).toHaveBeenCalledTimes(1); expect(pluginServices.stop).toHaveBeenCalledTimes(1); }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 856e7bbc31e..51a9ad05b16 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { monitorEventLoopDelay, performance } from "node:perf_hooks"; import { setTimeout as sleep } from "node:timers/promises"; import type { CliDeps } from "../cli/deps.types.js"; +import { resolveStateDir } from "../config/paths.js"; import type { GatewayTailscaleMode } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredInternalHooks } from "../hooks/configured.js"; @@ -45,7 +46,7 @@ type GatewayMemoryStartupPolicy = | { mode: "idle"; delayMs: number }; export type GatewayPostReadySidecarHandle = { - stop: () => void; + stop: () => Awaitable; }; export function stopPostReadySidecarsAfterCloseStarted(params: { @@ -56,7 +57,7 @@ export function stopPostReadySidecarsAfterCloseStarted(params: { return; } for (const postReadySidecar of params.postReadySidecars) { - postReadySidecar.stop(); + void postReadySidecar.stop(); } } @@ -283,6 +284,7 @@ function schedulePostReadySidecarTask(params: { name: string; log: { warn: (msg: string) => void }; run: (isStopped: () => boolean, signal: AbortSignal) => Awaitable; + stop?: () => Awaitable; }): GatewayPostReadySidecarHandle { let stopped = false; const abortController = new AbortController(); @@ -299,14 +301,45 @@ function schedulePostReadySidecarTask(params: { }); handle.unref?.(); return { - stop: () => { + stop: async () => { stopped = true; abortController.abort(); clearImmediate(handle); + await params.stop?.(); }, }; } +function scheduleTranscriptsAutoStartSidecar(params: { + cfg: OpenClawConfig; + startupTrace?: GatewayStartupTrace; + log: { warn: (msg: string) => void }; +}): GatewayPostReadySidecarHandle { + let stopTranscriptsAutoStart: (() => Promise) | undefined; + return schedulePostReadySidecarTask({ + startupTrace: params.startupTrace, + name: "sidecars.transcripts-auto-start", + log: params.log, + run: async (isStopped) => { + const { createTranscriptsAutoStartService } = + await import("../agents/tools/transcripts-tool.js"); + if (isStopped()) { + return; + } + const service = createTranscriptsAutoStartService({ + config: params.cfg, + stateDir: resolveStateDir(), + logger: params.log, + }); + stopTranscriptsAutoStart = () => service.stop(); + service.start(); + }, + stop: async () => { + await stopTranscriptsAutoStart?.(); + }, + }); +} + async function pathExists(filePath: string): Promise { try { await fs.promises.access(filePath); @@ -981,6 +1014,15 @@ export async function startGatewayPostAttachRuntime( }), ); } + if (params.gatewayPluginConfigAtStart.transcripts?.autoStart?.length) { + gatewayLifetimeSidecars.push( + scheduleTranscriptsAutoStartSidecar({ + cfg: params.gatewayPluginConfigAtStart, + startupTrace: params.startupTrace, + log: params.log, + }), + ); + } params.onPostReadySidecars?.(postReadySidecars); params.onGatewayLifetimeSidecars?.(gatewayLifetimeSidecars); params.onSidecarsReady?.(); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 273be5b6d16..a1750c98382 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -999,18 +999,18 @@ export async function startGatewayServer( getRuntimeSnapshot, getEventLoopHealth: readinessEventLoopHealth.snapshot, }); - const stopRegisteredPostReadySidecars = () => { + const stopRegisteredPostReadySidecars = async () => { const postReadySidecars = runtimeState.postReadySidecars; runtimeState.postReadySidecars = []; for (const postReadySidecar of postReadySidecars) { - postReadySidecar.stop(); + await postReadySidecar.stop(); } }; - const stopRegisteredGatewayLifetimeSidecars = () => { + const stopRegisteredGatewayLifetimeSidecars = async () => { const gatewayLifetimeSidecars = runtimeState.gatewayLifetimeSidecars; runtimeState.gatewayLifetimeSidecars = []; for (const gatewayLifetimeSidecar of gatewayLifetimeSidecars) { - gatewayLifetimeSidecar.stop(); + await gatewayLifetimeSidecar.stop(); } }; const createCloseHandler = () => async (opts?: GatewayCloseOptions) => { @@ -1056,8 +1056,8 @@ export async function startGatewayServer( let clearFallbackGatewayContextForServer = () => {}; const closeOnStartupFailure = async () => { try { - stopRegisteredGatewayLifetimeSidecars(); - stopRegisteredPostReadySidecars(); + await stopRegisteredGatewayLifetimeSidecars(); + await stopRegisteredPostReadySidecars(); await runClosePrelude(); await createCloseHandler()({ reason: "gateway startup failed" }); } finally { @@ -1745,8 +1745,8 @@ export async function startGatewayServer( close: async (opts) => { try { markClosePreludeStarted(); - stopRegisteredGatewayLifetimeSidecars(); - stopRegisteredPostReadySidecars(); + await stopRegisteredGatewayLifetimeSidecars(); + await stopRegisteredPostReadySidecars(); // Run gateway_stop plugin hook before shutdown const { runGlobalGatewayStopSafely } = await import("../plugins/hook-runner-global.js"); await runGlobalGatewayStopSafely({ diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index 03d7061dde7..d2926f40230 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -19,7 +19,7 @@ function createStubPluginRegistry(): PluginRegistry { realtimeTranscriptionProviders: [], realtimeVoiceProviders: [], mediaUnderstandingProviders: [], - meetingNotesSourceProviders: [], + transcriptSourceProviders: [], imageGenerationProviders: [], videoGenerationProviders: [], musicGenerationProviders: [], diff --git a/src/meeting-notes/provider-types.ts b/src/meeting-notes/provider-types.ts deleted file mode 100644 index b4ebb73ddcf..00000000000 --- a/src/meeting-notes/provider-types.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; - -export type MeetingNotesSourceKind = - | "live-audio" - | "live-caption" - | "posthoc-transcript" - | "recording-stt"; - -export type MeetingNotesSourceLocator = { - providerId: string; - kind?: MeetingNotesSourceKind; - accountId?: string; - guildId?: string; - channelId?: string; - meetingUrl?: string; - threadTs?: string; - fileId?: string; - [key: string]: string | undefined; -}; - -export type MeetingNotesParticipant = { - id?: string; - label: string; -}; - -export type MeetingNotesUtterance = { - id?: string; - sessionId?: string; - startedAt?: string; - endedAt?: string; - speaker?: MeetingNotesParticipant; - text: string; - final?: boolean; - metadata?: Record; -}; - -export type MeetingNotesSessionDescriptor = { - sessionId: string; - title?: string; - source: MeetingNotesSourceLocator; - startedAt: string; - stoppedAt?: string; - metadata?: Record; -}; - -export type MeetingNotesStartRequest = { - cfg?: OpenClawConfig; - session: MeetingNotesSessionDescriptor; - abortSignal?: AbortSignal; - startupWaitMs?: number; - onUtterance: (utterance: MeetingNotesUtterance) => void | Promise; - onStatus?: (status: MeetingNotesSourceStatus) => void | Promise; -}; - -export type MeetingNotesStartResult = - | { - ok: true; - session: MeetingNotesSessionDescriptor; - } - | { - ok: false; - error: string; - }; - -export type MeetingNotesStopRequest = { - cfg?: OpenClawConfig; - sessionId: string; - source: MeetingNotesSourceLocator; - reason?: string; -}; - -export type MeetingNotesStopResult = - | { - ok: true; - sessionId: string; - stoppedAt?: string; - } - | { - ok: false; - error: string; - }; - -export type MeetingNotesSourceStatus = { - sessionId?: string; - active: boolean; - message?: string; - source?: MeetingNotesSourceLocator; -}; - -export type MeetingNotesImportRequest = { - cfg?: OpenClawConfig; - session: MeetingNotesSessionDescriptor; - text: string; - speakerLabel?: string; -}; - -export type MeetingNotesSourceProviderPlugin = { - id: string; - aliases?: readonly string[]; - name: string; - sourceKinds: readonly MeetingNotesSourceKind[]; - start?: (request: MeetingNotesStartRequest) => Promise; - stop?: (request: MeetingNotesStopRequest) => Promise; - status?: ( - source: MeetingNotesSourceLocator, - cfg?: OpenClawConfig, - ) => Promise; - importTranscript?: (request: MeetingNotesImportRequest) => Promise; -}; diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts index 4b3491625f8..e2c242af5a0 100644 --- a/src/plugin-sdk/entrypoints.ts +++ b/src/plugin-sdk/entrypoints.ts @@ -76,7 +76,6 @@ export const publicPluginOwnedSdkEntrypoints = [ "memory-host-markdown", "memory-host-search", "memory-host-status", - "meeting-notes", "speech-core", "telegram-command-config", "video-generation-core", diff --git a/src/plugin-sdk/meeting-notes.ts b/src/plugin-sdk/meeting-notes.ts deleted file mode 100644 index 7b4f318690d..00000000000 --- a/src/plugin-sdk/meeting-notes.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { - MeetingNotesImportRequest, - MeetingNotesParticipant, - MeetingNotesSessionDescriptor, - MeetingNotesSourceKind, - MeetingNotesSourceLocator, - MeetingNotesSourceProviderPlugin, - MeetingNotesSourceStatus, - MeetingNotesStartRequest, - MeetingNotesStartResult, - MeetingNotesStopRequest, - MeetingNotesStopResult, - MeetingNotesUtterance, -} from "../meeting-notes/provider-types.js"; -export { - getMeetingNotesSourceProvider, - listMeetingNotesSourceProviders, - normalizeMeetingNotesSourceProviderId, -} from "../meeting-notes/provider-registry.js"; diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 3315a21b348..0b7b54608ac 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -7,7 +7,7 @@ import type { AgentPromptGuidanceEntry, AgentPromptSurfaceKind, MediaUnderstandingProviderPlugin, - MeetingNotesSourceProviderPlugin, + TranscriptSourceProvider, MigrationApplyResult, MigrationDetection, MigrationItem, @@ -128,7 +128,7 @@ export type { AgentPromptGuidanceEntry, AgentPromptSurfaceKind, MediaUnderstandingProviderPlugin, - MeetingNotesSourceProviderPlugin, + TranscriptSourceProvider, MigrationApplyResult, MigrationDetection, MigrationItem, diff --git a/src/plugin-sdk/plugin-test-api.ts b/src/plugin-sdk/plugin-test-api.ts index a4ce739a283..a59bb00cfb3 100644 --- a/src/plugin-sdk/plugin-test-api.ts +++ b/src/plugin-sdk/plugin-test-api.ts @@ -42,7 +42,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerRealtimeTranscriptionProvider() {}, registerRealtimeVoiceProvider() {}, registerMediaUnderstandingProvider() {}, - registerMeetingNotesSourceProvider() {}, + registerTranscriptSourceProvider() {}, registerImageGenerationProvider() {}, registerMusicGenerationProvider() {}, registerVideoGenerationProvider() {}, diff --git a/src/plugin-sdk/test-helpers/plugin-registration-contract.ts b/src/plugin-sdk/test-helpers/plugin-registration-contract.ts index ff41b5babcd..c895c833edb 100644 --- a/src/plugin-sdk/test-helpers/plugin-registration-contract.ts +++ b/src/plugin-sdk/test-helpers/plugin-registration-contract.ts @@ -11,7 +11,7 @@ type PluginRegistrationContractParams = { realtimeTranscriptionProviderIds?: string[]; realtimeVoiceProviderIds?: string[]; mediaUnderstandingProviderIds?: string[]; - meetingNotesSourceProviderIds?: string[]; + transcriptSourceProviderIds?: string[]; imageGenerationProviderIds?: string[]; videoGenerationProviderIds?: string[]; musicGenerationProviderIds?: string[]; @@ -102,10 +102,10 @@ export function describePluginRegistrationContract(params: PluginRegistrationCon }); } - if (params.meetingNotesSourceProviderIds) { - it("keeps bundled meeting-notes source ownership explicit", () => { - expect(findRegistration(params.pluginId).meetingNotesSourceProviderIds).toEqual( - params.meetingNotesSourceProviderIds, + if (params.transcriptSourceProviderIds) { + it("keeps bundled transcripts source ownership explicit", () => { + expect(findRegistration(params.pluginId).transcriptSourceProviderIds).toEqual( + params.transcriptSourceProviderIds, ); }); } diff --git a/src/plugin-sdk/transcripts.ts b/src/plugin-sdk/transcripts.ts new file mode 100644 index 00000000000..0428a872a47 --- /dev/null +++ b/src/plugin-sdk/transcripts.ts @@ -0,0 +1,19 @@ +export type { + TranscriptImportRequest, + TranscriptParticipant, + TranscriptSessionDescriptor, + TranscriptSourceKind, + TranscriptSourceLocator, + TranscriptSourceProvider, + TranscriptSourceStatus, + TranscriptStartRequest, + TranscriptsStartResult, + TranscriptStopRequest, + TranscriptsStopResult, + TranscriptUtterance, +} from "../transcripts/provider-types.js"; +export { + getTranscriptSourceProvider, + listTranscriptSourceProviders, + normalizeTranscriptSourceProviderId, +} from "../transcripts/provider-registry.js"; diff --git a/src/plugin-state/plugin-state-store.runtime.test.ts b/src/plugin-state/plugin-state-store.runtime.test.ts index 635d4976781..758c13a658a 100644 --- a/src/plugin-state/plugin-state-store.runtime.test.ts +++ b/src/plugin-state/plugin-state-store.runtime.test.ts @@ -29,7 +29,7 @@ function createPluginRecord( realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 7c6d375859d..61390527284 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -44,7 +44,7 @@ export type BuildPluginApiParams = { | "registerRealtimeTranscriptionProvider" | "registerRealtimeVoiceProvider" | "registerMediaUnderstandingProvider" - | "registerMeetingNotesSourceProvider" + | "registerTranscriptSourceProvider" | "registerImageGenerationProvider" | "registerVideoGenerationProvider" | "registerMusicGenerationProvider" @@ -118,7 +118,7 @@ const noopRegisterRealtimeVoiceProvider: OpenClawPluginApi["registerRealtimeVoic () => {}; const noopRegisterMediaUnderstandingProvider: OpenClawPluginApi["registerMediaUnderstandingProvider"] = () => {}; -const noopRegisterMeetingNotesSourceProvider: OpenClawPluginApi["registerMeetingNotesSourceProvider"] = +const noopRegisterTranscriptsSourceProvider: OpenClawPluginApi["registerTranscriptSourceProvider"] = () => {}; const noopRegisterImageGenerationProvider: OpenClawPluginApi["registerImageGenerationProvider"] = () => {}; @@ -231,8 +231,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi handlers.registerRealtimeVoiceProvider ?? noopRegisterRealtimeVoiceProvider, registerMediaUnderstandingProvider: handlers.registerMediaUnderstandingProvider ?? noopRegisterMediaUnderstandingProvider, - registerMeetingNotesSourceProvider: - handlers.registerMeetingNotesSourceProvider ?? noopRegisterMeetingNotesSourceProvider, + registerTranscriptSourceProvider: + handlers.registerTranscriptSourceProvider ?? noopRegisterTranscriptsSourceProvider, registerImageGenerationProvider: handlers.registerImageGenerationProvider ?? noopRegisterImageGenerationProvider, registerVideoGenerationProvider: diff --git a/src/plugins/bundled-capability-metadata.test.ts b/src/plugins/bundled-capability-metadata.test.ts index 713dc2e5950..24b23aca8c8 100644 --- a/src/plugins/bundled-capability-metadata.test.ts +++ b/src/plugins/bundled-capability-metadata.test.ts @@ -91,7 +91,7 @@ describe("bundled capability metadata", () => { realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], documentExtractorIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 1fdf8f05f23..99319052aa1 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -158,7 +158,7 @@ function createCapabilityPluginRecord(params: { realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], @@ -329,8 +329,8 @@ export function loadBundledCapabilityRuntimeRegistry(params: { record.mediaUnderstandingProviderIds.push( ...captured.mediaUnderstandingProviders.map((entry) => entry.id), ); - record.meetingNotesSourceProviderIds.push( - ...captured.meetingNotesSourceProviders.map((entry) => entry.id), + record.transcriptSourceProviderIds.push( + ...captured.transcriptSourceProviders.map((entry) => entry.id), ); record.imageGenerationProviderIds.push( ...captured.imageGenerationProviders.map((entry) => entry.id), @@ -422,8 +422,8 @@ export function loadBundledCapabilityRuntimeRegistry(params: { rootDir: record.rootDir, })), ); - registry.meetingNotesSourceProviders.push( - ...captured.meetingNotesSourceProviders.map((provider) => ({ + registry.transcriptSourceProviders.push( + ...captured.transcriptSourceProviders.map((provider) => ({ pluginId: record.id, pluginName: record.name, provider, diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index d1b7ce88fa6..7c5a839a004 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -32,7 +32,7 @@ type CapabilityProviderRegistryKey = | "realtimeTranscriptionProviders" | "realtimeVoiceProviders" | "mediaUnderstandingProviders" - | "meetingNotesSourceProviders" + | "transcriptSourceProviders" | "imageGenerationProviders" | "videoGenerationProviders" | "musicGenerationProviders"; @@ -44,7 +44,7 @@ type CapabilityContractKey = | "realtimeTranscriptionProviders" | "realtimeVoiceProviders" | "mediaUnderstandingProviders" - | "meetingNotesSourceProviders" + | "transcriptSourceProviders" | "imageGenerationProviders" | "videoGenerationProviders" | "musicGenerationProviders"; @@ -67,7 +67,7 @@ const CAPABILITY_CONTRACT_KEY: Record value.trim(), ), - meetingNotesSourceProviderIds: uniqueStrings( - manifest.contracts?.meetingNotesSourceProviders, + transcriptSourceProviderIds: uniqueStrings( + manifest.contracts?.transcriptSourceProviders, (value) => value.trim(), ), documentExtractorIds: uniqueStrings(manifest.contracts?.documentExtractors, (value) => @@ -191,7 +191,7 @@ export function hasBundledPluginContractSnapshotCapabilities( entry.realtimeTranscriptionProviderIds.length > 0 || entry.realtimeVoiceProviderIds.length > 0 || entry.mediaUnderstandingProviderIds.length > 0 || - entry.meetingNotesSourceProviderIds.length > 0 || + entry.transcriptSourceProviderIds.length > 0 || entry.documentExtractorIds.length > 0 || entry.imageGenerationProviderIds.length > 0 || entry.videoGenerationProviderIds.length > 0 || diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 8a42c78a020..96f8742a8b9 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -37,7 +37,7 @@ describe("plugin contract registry", () => { realtimeTranscriptionProviders: entry.realtimeTranscriptionProviderIds, realtimeVoiceProviders: entry.realtimeVoiceProviderIds, mediaUnderstandingProviders: entry.mediaUnderstandingProviderIds, - meetingNotesSourceProviders: entry.meetingNotesSourceProviderIds, + transcriptSourceProviders: entry.transcriptSourceProviderIds, documentExtractors: entry.documentExtractorIds, imageGenerationProviders: entry.imageGenerationProviderIds, videoGenerationProviders: entry.videoGenerationProviderIds, @@ -93,9 +93,9 @@ describe("plugin contract registry", () => { pluginRegistrationContractRegistry.flatMap((entry) => entry.mediaUnderstandingProviderIds), }, { - name: "does not duplicate bundled meeting-notes source provider ids", + name: "does not duplicate bundled transcripts source provider ids", ids: () => - pluginRegistrationContractRegistry.flatMap((entry) => entry.meetingNotesSourceProviderIds), + pluginRegistrationContractRegistry.flatMap((entry) => entry.transcriptSourceProviderIds), }, { name: "does not duplicate bundled realtime transcription provider ids", @@ -214,14 +214,14 @@ describe("plugin contract registry", () => { }); }); - it("covers every bundled meeting-notes source plugin discovered from manifests", () => { + it("covers every bundled transcripts source plugin discovered from manifests", () => { expectRegistryPluginIds({ actualPluginIds: pluginRegistrationContractRegistry - .filter((entry) => entry.meetingNotesSourceProviderIds.length > 0) + .filter((entry) => entry.transcriptSourceProviderIds.length > 0) .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && - (plugin.contracts?.meetingNotesSourceProviders?.length ?? 0) > 0, + (plugin.contracts?.transcriptSourceProviders?.length ?? 0) > 0, }); }); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 321d8bf2cd4..6bc5956d529 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -8,7 +8,7 @@ import { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../p import type { ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, - MeetingNotesSourceProviderPlugin, + TranscriptSourceProvider, MusicGenerationProviderPlugin, ProviderPlugin, RealtimeTranscriptionProviderPlugin, @@ -27,7 +27,7 @@ import { uniqueStrings } from "./shared.js"; import { loadVitestImageGenerationProviderContractRegistry, loadVitestMediaUnderstandingProviderContractRegistry, - loadVitestMeetingNotesSourceProviderContractRegistry, + loadVitestTranscriptsSourceProviderContractRegistry, loadVitestMusicGenerationProviderContractRegistry, loadVitestRealtimeTranscriptionProviderContractRegistry, loadVitestRealtimeVoiceProviderContractRegistry, @@ -54,8 +54,7 @@ type RealtimeTranscriptionProviderContractEntry = type RealtimeVoiceProviderContractEntry = CapabilityContractEntry; type MediaUnderstandingProviderContractEntry = CapabilityContractEntry; -type MeetingNotesSourceProviderContractEntry = - CapabilityContractEntry; +type TranscriptsSourceProviderContractEntry = CapabilityContractEntry; type ImageGenerationProviderContractEntry = CapabilityContractEntry; type VideoGenerationProviderContractEntry = CapabilityContractEntry; type MusicGenerationProviderContractEntry = CapabilityContractEntry; @@ -68,7 +67,7 @@ type ManifestContractKey = | "realtimeTranscriptionProviders" | "realtimeVoiceProviders" | "mediaUnderstandingProviders" - | "meetingNotesSourceProviders" + | "transcriptSourceProviders" | "documentExtractors" | "imageGenerationProviders" | "videoGenerationProviders" @@ -104,7 +103,7 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { realtimeTranscriptionProviderIds: [...entry.realtimeTranscriptionProviderIds], realtimeVoiceProviderIds: [...entry.realtimeVoiceProviderIds], mediaUnderstandingProviderIds: [...entry.mediaUnderstandingProviderIds], - meetingNotesSourceProviderIds: [...entry.meetingNotesSourceProviderIds], + transcriptSourceProviderIds: [...entry.transcriptSourceProviderIds], documentExtractorIds: [...entry.documentExtractorIds], imageGenerationProviderIds: [...entry.imageGenerationProviderIds], videoGenerationProviderIds: [...entry.videoGenerationProviderIds], @@ -127,7 +126,7 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { (plugin.contracts?.realtimeTranscriptionProviders?.length ?? 0) > 0 || (plugin.contracts?.realtimeVoiceProviders?.length ?? 0) > 0 || (plugin.contracts?.mediaUnderstandingProviders?.length ?? 0) > 0 || - (plugin.contracts?.meetingNotesSourceProviders?.length ?? 0) > 0 || + (plugin.contracts?.transcriptSourceProviders?.length ?? 0) > 0 || (plugin.contracts?.documentExtractors?.length ?? 0) > 0 || (plugin.contracts?.imageGenerationProviders?.length ?? 0) > 0 || (plugin.contracts?.videoGenerationProviders?.length ?? 0) > 0 || @@ -152,9 +151,7 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { mediaUnderstandingProviderIds: uniqueStrings( plugin.contracts?.mediaUnderstandingProviders ?? [], ), - meetingNotesSourceProviderIds: uniqueStrings( - plugin.contracts?.meetingNotesSourceProviders ?? [], - ), + transcriptSourceProviderIds: uniqueStrings(plugin.contracts?.transcriptSourceProviders ?? []), documentExtractorIds: uniqueStrings(plugin.contracts?.documentExtractors ?? []), imageGenerationProviderIds: uniqueStrings(plugin.contracts?.imageGenerationProviders ?? []), videoGenerationProviderIds: uniqueStrings(plugin.contracts?.videoGenerationProviders ?? []), @@ -211,8 +208,8 @@ function resolveBundledManifestPluginIdsForContract(contract: ManifestContractKe return entry.realtimeVoiceProviderIds.length > 0; case "mediaUnderstandingProviders": return entry.mediaUnderstandingProviderIds.length > 0; - case "meetingNotesSourceProviders": - return entry.meetingNotesSourceProviderIds.length > 0; + case "transcriptSourceProviders": + return entry.transcriptSourceProviderIds.length > 0; case "documentExtractors": return entry.documentExtractorIds.length > 0; case "imageGenerationProviders": @@ -562,13 +559,13 @@ function loadMediaUnderstandingProviderContractRegistry(): MediaUnderstandingPro })); } -function loadMeetingNotesSourceProviderContractRegistry(): MeetingNotesSourceProviderContractEntry[] { +function loadTranscriptsSourceProviderContractRegistry(): TranscriptsSourceProviderContractEntry[] { return process.env.VITEST - ? loadVitestMeetingNotesSourceProviderContractRegistry() + ? loadVitestTranscriptsSourceProviderContractRegistry() : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("meetingNotesSourceProviders"), + pluginIds: resolveBundledManifestPluginIdsForContract("transcriptSourceProviders"), pluginSdkResolution: "dist", - }).meetingNotesSourceProviders.map((entry) => ({ + }).transcriptSourceProviders.map((entry) => ({ pluginId: entry.pluginId, provider: entry.provider, })); @@ -733,8 +730,8 @@ export const realtimeVoiceProviderContractRegistry: RealtimeVoiceProviderContrac createLazyArrayView(loadRealtimeVoiceProviderContractRegistry); export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] = createLazyArrayView(loadMediaUnderstandingProviderContractRegistry); -export const meetingNotesSourceProviderContractRegistry: MeetingNotesSourceProviderContractEntry[] = - createLazyArrayView(loadMeetingNotesSourceProviderContractRegistry); +export const transcriptsSourceProviderContractRegistry: TranscriptsSourceProviderContractEntry[] = + createLazyArrayView(loadTranscriptsSourceProviderContractRegistry); export const imageGenerationProviderContractRegistry: ImageGenerationProviderContractEntry[] = createLazyArrayView(loadImageGenerationProviderContractRegistry); export const videoGenerationProviderContractRegistry: VideoGenerationProviderContractEntry[] = diff --git a/src/plugins/contracts/speech-vitest-registry.ts b/src/plugins/contracts/speech-vitest-registry.ts index d7bda267d5e..9d2f6717d69 100644 --- a/src/plugins/contracts/speech-vitest-registry.ts +++ b/src/plugins/contracts/speech-vitest-registry.ts @@ -2,7 +2,7 @@ import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runt import type { ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, - MeetingNotesSourceProviderPlugin, + TranscriptSourceProvider, MusicGenerationProviderPlugin, RealtimeTranscriptionProviderPlugin, RealtimeVoiceProviderPlugin, @@ -21,9 +21,9 @@ export type MediaUnderstandingProviderContractEntry = { provider: MediaUnderstandingProviderPlugin; }; -export type MeetingNotesSourceProviderContractEntry = { +export type TranscriptsSourceProviderContractEntry = { pluginId: string; - provider: MeetingNotesSourceProviderPlugin; + provider: TranscriptSourceProvider; }; export type RealtimeVoiceProviderContractEntry = { @@ -55,7 +55,7 @@ type ManifestContractKey = | "imageGenerationProviders" | "speechProviders" | "mediaUnderstandingProviders" - | "meetingNotesSourceProviders" + | "transcriptSourceProviders" | "realtimeVoiceProviders" | "realtimeTranscriptionProviders" | "videoGenerationProviders" @@ -71,8 +71,8 @@ const VITEST_CONTRACT_PLUGIN_IDS = { mediaUnderstandingProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter( (entry) => entry.mediaUnderstandingProviderIds.length > 0, ).map((entry) => entry.pluginId), - meetingNotesSourceProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter( - (entry) => entry.meetingNotesSourceProviderIds.length > 0, + transcriptSourceProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter( + (entry) => entry.transcriptSourceProviderIds.length > 0, ).map((entry) => entry.pluginId), realtimeVoiceProviders: BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter( (entry) => entry.realtimeVoiceProviderIds.length > 0, @@ -215,12 +215,12 @@ export function loadVitestMediaUnderstandingProviderContractRegistry(): MediaUnd }); } -export function loadVitestMeetingNotesSourceProviderContractRegistry(): MeetingNotesSourceProviderContractEntry[] { +export function loadVitestTranscriptsSourceProviderContractRegistry(): TranscriptsSourceProviderContractEntry[] { return loadVitestCapabilityContractEntries({ - contract: "meetingNotesSourceProviders", + contract: "transcriptSourceProviders", pluginSdkResolution: "src", pickEntries: (registry) => - registry.meetingNotesSourceProviders.map((entry) => ({ + registry.transcriptSourceProviders.map((entry) => ({ pluginId: entry.pluginId, provider: entry.provider, })), diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 0fb85acbec7..15a920f104b 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -39,7 +39,7 @@ export function createMockPluginRegistry( embeddingProviders: [], speechProviders: [], mediaUnderstandingProviders: [], - meetingNotesSourceProviders: [], + transcriptSourceProviders: [], imageGenerationProviders: [], videoGenerationProviders: [], musicGenerationProviders: [], diff --git a/src/plugins/inspect-shape.ts b/src/plugins/inspect-shape.ts index a7815d118fc..0684a7b1245 100644 --- a/src/plugins/inspect-shape.ts +++ b/src/plugins/inspect-shape.ts @@ -9,7 +9,7 @@ export type PluginCapabilityKind = | "realtime-transcription" | "realtime-voice" | "media-understanding" - | "meeting-notes-source" + | "transcript-source" | "image-generation" | "video-generation" | "music-generation" @@ -48,7 +48,7 @@ function buildPluginCapabilityEntries( { kind: "realtime-transcription" as const, ids: plugin.realtimeTranscriptionProviderIds }, { kind: "realtime-voice" as const, ids: plugin.realtimeVoiceProviderIds }, { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, - { kind: "meeting-notes-source" as const, ids: plugin.meetingNotesSourceProviderIds }, + { kind: "transcript-source" as const, ids: plugin.transcriptSourceProviderIds }, { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, { kind: "video-generation" as const, ids: plugin.videoGenerationProviderIds }, { kind: "music-generation" as const, ids: plugin.musicGenerationProviderIds }, diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index ab578c436b4..c7996aa7911 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -61,7 +61,7 @@ export function createPluginRecord(params: { realtimeTranscriptionProviderIds: [...(params.contracts?.realtimeTranscriptionProviders ?? [])], realtimeVoiceProviderIds: [...(params.contracts?.realtimeVoiceProviders ?? [])], mediaUnderstandingProviderIds: [...(params.contracts?.mediaUnderstandingProviders ?? [])], - meetingNotesSourceProviderIds: [...(params.contracts?.meetingNotesSourceProviders ?? [])], + transcriptSourceProviderIds: [...(params.contracts?.transcriptSourceProviders ?? [])], imageGenerationProviderIds: [...(params.contracts?.imageGenerationProviders ?? [])], videoGenerationProviderIds: [...(params.contracts?.videoGenerationProviders ?? [])], musicGenerationProviderIds: [...(params.contracts?.musicGenerationProviders ?? [])], diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 1df516ab250..be184147e9e 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -49,7 +49,7 @@ function createLoadedPluginRecord(id: string): PluginRecord { realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7bf82ead296..100b1cdadb2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -365,7 +365,7 @@ type PluginRegistrySnapshot = { realtimeTranscriptionProviders: PluginRegistry["realtimeTranscriptionProviders"]; realtimeVoiceProviders: PluginRegistry["realtimeVoiceProviders"]; mediaUnderstandingProviders: PluginRegistry["mediaUnderstandingProviders"]; - meetingNotesSourceProviders: PluginRegistry["meetingNotesSourceProviders"]; + transcriptSourceProviders: PluginRegistry["transcriptSourceProviders"]; imageGenerationProviders: PluginRegistry["imageGenerationProviders"]; videoGenerationProviders: PluginRegistry["videoGenerationProviders"]; musicGenerationProviders: PluginRegistry["musicGenerationProviders"]; @@ -410,7 +410,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho realtimeTranscriptionProviders: [...registry.realtimeTranscriptionProviders], realtimeVoiceProviders: [...registry.realtimeVoiceProviders], mediaUnderstandingProviders: [...registry.mediaUnderstandingProviders], - meetingNotesSourceProviders: [...registry.meetingNotesSourceProviders], + transcriptSourceProviders: [...registry.transcriptSourceProviders], imageGenerationProviders: [...registry.imageGenerationProviders], videoGenerationProviders: [...registry.videoGenerationProviders], musicGenerationProviders: [...registry.musicGenerationProviders], @@ -454,7 +454,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr registry.realtimeTranscriptionProviders = snapshot.arrays.realtimeTranscriptionProviders; registry.realtimeVoiceProviders = snapshot.arrays.realtimeVoiceProviders; registry.mediaUnderstandingProviders = snapshot.arrays.mediaUnderstandingProviders; - registry.meetingNotesSourceProviders = snapshot.arrays.meetingNotesSourceProviders; + registry.transcriptSourceProviders = snapshot.arrays.transcriptSourceProviders; registry.imageGenerationProviders = snapshot.arrays.imageGenerationProviders; registry.videoGenerationProviders = snapshot.arrays.videoGenerationProviders; registry.musicGenerationProviders = snapshot.arrays.musicGenerationProviders; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 2b60dd13180..9e9a1ca6e2e 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -163,7 +163,7 @@ export type PluginManifestContractListKey = | "externalAuthProviders" | "embeddingProviders" | "mediaUnderstandingProviders" - | "meetingNotesSourceProviders" + | "transcriptSourceProviders" | "documentExtractors" | "realtimeVoiceProviders" | "realtimeTranscriptionProviders" @@ -384,7 +384,7 @@ function mergeManifestContracts( "realtimeTranscriptionProviders", "realtimeVoiceProviders", "mediaUnderstandingProviders", - "meetingNotesSourceProviders", + "transcriptSourceProviders", "documentExtractors", "imageGenerationProviders", "videoGenerationProviders", diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index a44bac65a08..0483e9ea41e 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -408,7 +408,7 @@ export type PluginManifestContracts = { realtimeTranscriptionProviders?: string[]; realtimeVoiceProviders?: string[]; mediaUnderstandingProviders?: string[]; - meetingNotesSourceProviders?: string[]; + transcriptSourceProviders?: string[]; documentExtractors?: string[]; imageGenerationProviders?: string[]; videoGenerationProviders?: string[]; @@ -840,7 +840,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u ); const realtimeVoiceProviders = normalizeTrimmedStringList(value.realtimeVoiceProviders); const mediaUnderstandingProviders = normalizeTrimmedStringList(value.mediaUnderstandingProviders); - const meetingNotesSourceProviders = normalizeTrimmedStringList(value.meetingNotesSourceProviders); + const transcriptSourceProviders = normalizeTrimmedStringList(value.transcriptSourceProviders); const documentExtractors = normalizeTrimmedStringList(value.documentExtractors); const imageGenerationProviders = normalizeTrimmedStringList(value.imageGenerationProviders); const videoGenerationProviders = normalizeTrimmedStringList(value.videoGenerationProviders); @@ -861,7 +861,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u ...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}), ...(realtimeVoiceProviders.length > 0 ? { realtimeVoiceProviders } : {}), ...(mediaUnderstandingProviders.length > 0 ? { mediaUnderstandingProviders } : {}), - ...(meetingNotesSourceProviders.length > 0 ? { meetingNotesSourceProviders } : {}), + ...(transcriptSourceProviders.length > 0 ? { transcriptSourceProviders } : {}), ...(documentExtractors.length > 0 ? { documentExtractors } : {}), ...(imageGenerationProviders.length > 0 ? { imageGenerationProviders } : {}), ...(videoGenerationProviders.length > 0 ? { videoGenerationProviders } : {}), diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 5430e0c14e1..f7f2dccb892 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -17,7 +17,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { realtimeTranscriptionProviders: [], realtimeVoiceProviders: [], mediaUnderstandingProviders: [], - meetingNotesSourceProviders: [], + transcriptSourceProviders: [], imageGenerationProviders: [], videoGenerationProviders: [], musicGenerationProviders: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 71de6db82ca..0b714c25183 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -37,7 +37,7 @@ import type { CliBackendPlugin, ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, - MeetingNotesSourceProviderPlugin, + TranscriptSourceProvider, MusicGenerationProviderPlugin, OpenClawPluginChannelRegistration, OpenClawPluginCliCommandDescriptor, @@ -183,8 +183,8 @@ export type PluginRealtimeVoiceProviderRegistration = PluginOwnedProviderRegistration; export type PluginMediaUnderstandingProviderRegistration = PluginOwnedProviderRegistration; -export type PluginMeetingNotesSourceProviderRegistration = - PluginOwnedProviderRegistration; +export type PluginTranscriptsSourceProviderRegistration = + PluginOwnedProviderRegistration; export type PluginImageGenerationProviderRegistration = PluginOwnedProviderRegistration; export type PluginVideoGenerationProviderRegistration = @@ -402,7 +402,7 @@ export type PluginRecord = { realtimeTranscriptionProviderIds: string[]; realtimeVoiceProviderIds: string[]; mediaUnderstandingProviderIds: string[]; - meetingNotesSourceProviderIds: string[]; + transcriptSourceProviderIds: string[]; imageGenerationProviderIds: string[]; videoGenerationProviderIds: string[]; musicGenerationProviderIds: string[]; @@ -442,7 +442,7 @@ export type PluginRegistry = { realtimeTranscriptionProviders: PluginRealtimeTranscriptionProviderRegistration[]; realtimeVoiceProviders: PluginRealtimeVoiceProviderRegistration[]; mediaUnderstandingProviders: PluginMediaUnderstandingProviderRegistration[]; - meetingNotesSourceProviders: PluginMeetingNotesSourceProviderRegistration[]; + transcriptSourceProviders: PluginTranscriptsSourceProviderRegistration[]; imageGenerationProviders: PluginImageGenerationProviderRegistration[]; videoGenerationProviders: PluginVideoGenerationProviderRegistration[]; musicGenerationProviders: PluginMusicGenerationProviderRegistration[]; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 4bbc617358f..63f3a65d474 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -175,7 +175,7 @@ import type { OpenClawPluginReloadRegistration, OpenClawPluginSecurityAuditCollector, MediaUnderstandingProviderPlugin, - MeetingNotesSourceProviderPlugin, + TranscriptSourceProvider, MigrationProviderPlugin, OpenClawPluginService, OpenClawPluginToolContext, @@ -1287,16 +1287,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; - const registerMeetingNotesSourceProvider = ( + const registerTranscriptSourceProvider = ( record: PluginRecord, - provider: MeetingNotesSourceProviderPlugin, + provider: TranscriptSourceProvider, ) => { registerUniqueProviderLike({ record, provider, - kindLabel: "meeting notes source provider", - registrations: registry.meetingNotesSourceProviders, - ownedIds: record.meetingNotesSourceProviderIds, + kindLabel: "transcripts source provider", + registrations: registry.transcriptSourceProviders, + ownedIds: record.transcriptSourceProviderIds, }); }; @@ -2639,8 +2639,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerRealtimeVoiceProvider(record, provider), registerMediaUnderstandingProvider: (provider) => registerMediaUnderstandingProvider(record, provider), - registerMeetingNotesSourceProvider: (provider) => - registerMeetingNotesSourceProvider(record, provider), + registerTranscriptSourceProvider: (provider) => + registerTranscriptSourceProvider(record, provider), registerImageGenerationProvider: (provider) => registerImageGenerationProvider(record, provider), registerVideoGenerationProvider: (provider) => @@ -3104,7 +3104,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerRealtimeTranscriptionProvider, registerRealtimeVoiceProvider, registerMediaUnderstandingProvider, - registerMeetingNotesSourceProvider, + registerTranscriptSourceProvider, registerImageGenerationProvider, registerVideoGenerationProvider, registerMusicGenerationProvider, diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index b78c8be9b15..5116e33062a 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -61,7 +61,7 @@ export function createPluginRecord( realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], @@ -140,7 +140,7 @@ export function createPluginLoadResult( embeddingProviders: embeddingProviders ?? [], speechProviders: [], mediaUnderstandingProviders: [], - meetingNotesSourceProviders: [], + transcriptSourceProviders: [], imageGenerationProviders: [], videoGenerationProviders: [], musicGenerationProviders: [], diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 1c933290d3a..9de78eabc7a 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -199,7 +199,7 @@ function buildPluginRecordFromInstalledIndex( ], realtimeVoiceProviderIds: [...(manifest?.contracts?.realtimeVoiceProviders ?? [])], mediaUnderstandingProviderIds: [...(manifest?.contracts?.mediaUnderstandingProviders ?? [])], - meetingNotesSourceProviderIds: [...(manifest?.contracts?.meetingNotesSourceProviders ?? [])], + transcriptSourceProviderIds: [...(manifest?.contracts?.transcriptSourceProviders ?? [])], imageGenerationProviderIds: [...(manifest?.contracts?.imageGenerationProviders ?? [])], videoGenerationProviderIds: [...(manifest?.contracts?.videoGenerationProviders ?? [])], musicGenerationProviderIds: [...(manifest?.contracts?.musicGenerationProviders ?? [])], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 60745c0fd89..13fe21fae34 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -35,7 +35,6 @@ import type { } from "../infra/diagnostic-events.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; import type { MediaUnderstandingProvider } from "../media-understanding/types.js"; -import type { MeetingNotesSourceProviderPlugin as MeetingNotesSourceProviderCapability } from "../meeting-notes/provider-types.js"; import type { UnifiedModelCatalogEntry, UnifiedModelCatalogKind } from "../model-catalog/types.js"; import type { MusicGenerationProvider } from "../music-generation/types.js"; import type { @@ -60,6 +59,7 @@ import type { RealtimeVoiceProviderId, RealtimeVoiceProviderResolveConfigContext, } from "../talk/provider-types.js"; +import type { TranscriptSourceProvider as TranscriptsSourceProviderCapability } from "../transcripts/provider-types.js"; import type { SpeechDirectiveTokenParseContext, SpeechDirectiveTokenParseResult, @@ -1879,10 +1879,10 @@ export type PluginRealtimeTranscriptionProviderEntry = RealtimeTranscriptionProv pluginId: string; }; -/** Meeting-notes source capability registered by a channel or meeting plugin. */ -export type MeetingNotesSourceProviderPlugin = MeetingNotesSourceProviderCapability; +/** Transcript source capability registered by a channel or meeting plugin. */ +export type TranscriptSourceProvider = TranscriptsSourceProviderCapability; -export type PluginMeetingNotesSourceProviderEntry = MeetingNotesSourceProviderPlugin & { +export type PluginTranscriptsSourceProviderEntry = TranscriptSourceProvider & { pluginId: string; }; @@ -2692,8 +2692,8 @@ export type OpenClawPluginApi = { registerRealtimeVoiceProvider: (provider: RealtimeVoiceProviderPlugin) => void; /** Register a media understanding provider (media understanding capability). */ registerMediaUnderstandingProvider: (provider: MediaUnderstandingProviderPlugin) => void; - /** Register a meeting-notes source provider (live or imported meeting transcript capability). */ - registerMeetingNotesSourceProvider: (provider: MeetingNotesSourceProviderPlugin) => void; + /** Register a transcripts source provider (live or imported meeting transcript capability). */ + registerTranscriptSourceProvider: (provider: TranscriptSourceProvider) => void; /** Register an image generation provider (image generation capability). */ registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void; /** Register a video generation provider (video generation capability). */ diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 96e362c6fea..8498b296b93 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -32,7 +32,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl realtimeTranscriptionProviders: [], realtimeVoiceProviders: [], mediaUnderstandingProviders: [], - meetingNotesSourceProviders: [], + transcriptSourceProviders: [], imageGenerationProviders: [], videoGenerationProviders: [], musicGenerationProviders: [], diff --git a/src/trajectory/metadata.test.ts b/src/trajectory/metadata.test.ts index 2037849a5e0..79449b7ca62 100644 --- a/src/trajectory/metadata.test.ts +++ b/src/trajectory/metadata.test.ts @@ -109,7 +109,7 @@ describe("trajectory metadata", () => { realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], - meetingNotesSourceProviderIds: [], + transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], diff --git a/extensions/meeting-notes/src/config.ts b/src/transcripts/config.ts similarity index 59% rename from extensions/meeting-notes/src/config.ts rename to src/transcripts/config.ts index 0219a72d3d4..a8bd5bea28b 100644 --- a/extensions/meeting-notes/src/config.ts +++ b/src/transcripts/config.ts @@ -1,7 +1,6 @@ -import { normalizeOptionalString as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString as readString } from "../shared/string-coerce.js"; -export type MeetingNotesAutoStartConfig = { - enabled: boolean; +export type TranscriptsAutoStartConfig = { providerId: string; sessionId?: string; title?: string; @@ -11,18 +10,34 @@ export type MeetingNotesAutoStartConfig = { meetingUrl?: string; }; -export type MeetingNotesConfig = { - enabled: boolean; - maxUtterances: number; - autoStart: MeetingNotesAutoStartConfig[]; +export type ResolvedTranscriptsAutoStartConfig = { + providerId: string; + sessionId?: string; + title?: string; + accountId?: string; + guildId?: string; + channelId?: string; + meetingUrl?: string; }; -function resolveAutoStart(raw: unknown): MeetingNotesAutoStartConfig[] { +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): MeetingNotesAutoStartConfig | undefined => { + .map((entry): ResolvedTranscriptsAutoStartConfig | undefined => { const config = entry && typeof entry === "object" ? (entry as Record) : {}; const providerId = readString(config.providerId); if (!providerId) { @@ -30,7 +45,6 @@ function resolveAutoStart(raw: unknown): MeetingNotesAutoStartConfig[] { } return { providerId, - enabled: config.enabled !== false, sessionId: readString(config.sessionId), title: readString(config.title), accountId: readString(config.accountId), @@ -39,17 +53,17 @@ function resolveAutoStart(raw: unknown): MeetingNotesAutoStartConfig[] { meetingUrl: readString(config.meetingUrl), }; }) - .filter((entry): entry is MeetingNotesAutoStartConfig => entry !== undefined); + .filter((entry): entry is ResolvedTranscriptsAutoStartConfig => entry !== undefined); } -export function resolveMeetingNotesConfig(raw: unknown): MeetingNotesConfig { +export function resolveTranscriptsConfig(raw: unknown): ResolvedTranscriptsConfig { const config = raw && typeof raw === "object" ? (raw as Record) : {}; 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 !== false, + enabled: config.enabled === true, maxUtterances, autoStart: resolveAutoStart(config.autoStart), }; diff --git a/extensions/meeting-notes/src/manual-source.ts b/src/transcripts/manual-source.ts similarity index 84% rename from extensions/meeting-notes/src/manual-source.ts rename to src/transcripts/manual-source.ts index d2d89928eee..fea43dee964 100644 --- a/extensions/meeting-notes/src/manual-source.ts +++ b/src/transcripts/manual-source.ts @@ -1,4 +1,4 @@ -import type { MeetingNotesSourceProviderPlugin } from "openclaw/plugin-sdk/meeting-notes"; +import type { TranscriptSourceProvider } from "./provider-types.js"; function parseSpeakerLine(line: string): { speakerLabel?: string; text: string } { const match = /^([^:\n]{1,80}):\s+(.+)$/.exec(line.trim()); @@ -8,7 +8,7 @@ function parseSpeakerLine(line: string): { speakerLabel?: string; text: string } return { speakerLabel: match[1]?.trim(), text: match[2]?.trim() ?? "" }; } -export const manualTranscriptSourceProvider: MeetingNotesSourceProviderPlugin = { +export const manualTranscriptSourceProvider: TranscriptSourceProvider = { id: "manual-transcript", aliases: ["import", "transcript"], name: "Manual Transcript Import", diff --git a/src/meeting-notes/provider-registry.ts b/src/transcripts/provider-registry.ts similarity index 53% rename from src/meeting-notes/provider-registry.ts rename to src/transcripts/provider-registry.ts index ccc070bb6a5..ac080578dab 100644 --- a/src/meeting-notes/provider-registry.ts +++ b/src/transcripts/provider-registry.ts @@ -7,46 +7,42 @@ import { buildCapabilityProviderMaps, normalizeCapabilityProviderId, } from "../plugins/provider-registry-shared.js"; -import type { MeetingNotesSourceProviderPlugin } from "./provider-types.js"; +import type { TranscriptSourceProvider } from "./provider-types.js"; -export function normalizeMeetingNotesSourceProviderId( +export function normalizeTranscriptSourceProviderId( providerId: string | undefined, ): string | undefined { return normalizeCapabilityProviderId(providerId); } -function resolveMeetingNotesSourceProviderEntries( - cfg?: OpenClawConfig, -): MeetingNotesSourceProviderPlugin[] { +function resolveTranscriptsSourceProviderEntries(cfg?: OpenClawConfig): TranscriptSourceProvider[] { return resolvePluginCapabilityProviders({ - key: "meetingNotesSourceProviders", + key: "transcriptSourceProviders", cfg, }); } function buildProviderMaps(cfg?: OpenClawConfig): { - canonical: Map; - aliases: Map; + canonical: Map; + aliases: Map; } { - return buildCapabilityProviderMaps(resolveMeetingNotesSourceProviderEntries(cfg)); + return buildCapabilityProviderMaps(resolveTranscriptsSourceProviderEntries(cfg)); } -export function listMeetingNotesSourceProviders( - cfg?: OpenClawConfig, -): MeetingNotesSourceProviderPlugin[] { +export function listTranscriptSourceProviders(cfg?: OpenClawConfig): TranscriptSourceProvider[] { return [...buildProviderMaps(cfg).canonical.values()]; } -export function getMeetingNotesSourceProvider( +export function getTranscriptSourceProvider( providerId: string | undefined, cfg?: OpenClawConfig, -): MeetingNotesSourceProviderPlugin | undefined { - const normalized = normalizeMeetingNotesSourceProviderId(providerId); +): TranscriptSourceProvider | undefined { + const normalized = normalizeTranscriptSourceProviderId(providerId); if (!normalized) { return undefined; } const directProvider = resolvePluginCapabilityProvider({ - key: "meetingNotesSourceProviders", + key: "transcriptSourceProviders", providerId: normalized, cfg, }); diff --git a/src/transcripts/provider-types.ts b/src/transcripts/provider-types.ts new file mode 100644 index 00000000000..5ede51c106d --- /dev/null +++ b/src/transcripts/provider-types.ts @@ -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; +}; + +export type TranscriptSessionDescriptor = { + sessionId: string; + title?: string; + source: TranscriptSourceLocator; + startedAt: string; + stoppedAt?: string; + metadata?: Record; +}; + +export type TranscriptStartRequest = { + cfg?: OpenClawConfig; + session: TranscriptSessionDescriptor; + abortSignal?: AbortSignal; + startupWaitMs?: number; + onUtterance: (utterance: TranscriptUtterance) => void | Promise; + onStatus?: (status: TranscriptSourceStatus) => void | Promise; +}; + +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; + stop?: (request: TranscriptStopRequest) => Promise; + status?: ( + source: TranscriptSourceLocator, + cfg?: OpenClawConfig, + ) => Promise; + importTranscript?: (request: TranscriptImportRequest) => Promise; +}; diff --git a/extensions/meeting-notes/src/store.ts b/src/transcripts/store.ts similarity index 77% rename from extensions/meeting-notes/src/store.ts rename to src/transcripts/store.ts index 0445a0db573..e782b659014 100644 --- a/extensions/meeting-notes/src/store.ts +++ b/src/transcripts/store.ts @@ -3,15 +3,12 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { createInterface } from "node:readline"; -import type { - MeetingNotesSessionDescriptor, - MeetingNotesUtterance, -} from "openclaw/plugin-sdk/meeting-notes"; -import type { MeetingNotesSummary } from "./summary.js"; -import { renderMeetingNotesMarkdown } from "./summary.js"; +import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js"; +import type { TranscriptsSummary } from "./summary.js"; +import { renderTranscriptsMarkdown } from "./summary.js"; -export type MeetingNotesSessionEntry = { - session: MeetingNotesSessionDescriptor; +export type TranscriptsSessionEntry = { + session: TranscriptSessionDescriptor; sessionDir: string; }; @@ -43,16 +40,16 @@ function normalizeMaxUtterances(value: number | undefined): number | undefined { } function sameSessionIdentity( - left: MeetingNotesSessionDescriptor, - right: MeetingNotesSessionDescriptor, + left: TranscriptSessionDescriptor, + right: TranscriptSessionDescriptor, ): boolean { return left.sessionId === right.sessionId && left.startedAt === right.startedAt; } -export class MeetingNotesStore { +export class TranscriptsStore { constructor(private readonly rootDir: string) {} - sessionDir(session: MeetingNotesSessionDescriptor): string { + sessionDir(session: TranscriptSessionDescriptor): string { return path.join(this.rootDir, dateSegment(session.startedAt), safeSegment(session.sessionId)); } @@ -60,9 +57,9 @@ export class MeetingNotesStore { return (await readJsonFile(path.join(dir, "metadata.json"))) !== undefined; } - private async findSessionDirForSession(session: MeetingNotesSessionDescriptor): Promise { + private async findSessionDirForSession(session: TranscriptSessionDescriptor): Promise { const datedDir = this.sessionDir(session); - const datedSession = await readJsonFile( + const datedSession = await readJsonFile( path.join(datedDir, "metadata.json"), ); if (datedSession && sameSessionIdentity(datedSession, session)) { @@ -102,7 +99,7 @@ export class MeetingNotesStore { const matches: string[] = []; for (const entry of datedEntries) { const candidate = path.join(this.rootDir, entry.name, safeSessionId); - const session = await readJsonFile( + const session = await readJsonFile( path.join(candidate, "metadata.json"), ); if (session?.sessionId === selector) { @@ -111,34 +108,34 @@ export class MeetingNotesStore { } if (matches.length > 1) { throw new Error( - `multiple meeting notes sessions match ${selector}; use a YYYY-MM-DD/${selector} selector`, + `multiple transcripts sessions match ${selector}; use a YYYY-MM-DD/${selector} selector`, ); } return matches[0]; } - async writeSession(session: MeetingNotesSessionDescriptor): Promise { + async writeSession(session: TranscriptSessionDescriptor): Promise { 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 { + async readSession(sessionId: string): Promise { return (await this.readSessionEntry(sessionId))?.session; } - async readSessionEntry(sessionId: string): Promise { + async readSessionEntry(sessionId: string): Promise { const dir = await this.findSessionDir(sessionId); if (!dir) { return undefined; } - const session = await readJsonFile( + const session = await readJsonFile( path.join(dir, "metadata.json"), ); return session ? { session, sessionDir: dir } : undefined; } - async appendUtterance(sessionId: string, utterance: MeetingNotesUtterance): Promise { + async appendUtterance(sessionId: string, utterance: TranscriptUtterance): Promise { const dir = (await this.findSessionDir(sessionId)) ?? path.join(this.rootDir, dateSegment(sessionId), safeSegment(sessionId)); @@ -146,8 +143,8 @@ export class MeetingNotesStore { } async appendUtteranceForSession( - session: MeetingNotesSessionDescriptor, - utterance: MeetingNotesUtterance, + session: TranscriptSessionDescriptor, + utterance: TranscriptUtterance, ): Promise { const dir = await this.findSessionDirForSession(session); await this.appendUtteranceToDir(dir, session.sessionId, utterance); @@ -156,7 +153,7 @@ export class MeetingNotesStore { private async appendUtteranceToDir( dir: string, sessionId: string, - utterance: MeetingNotesUtterance, + utterance: TranscriptUtterance, ): Promise { await fs.mkdir(dir, { recursive: true }); await fs.appendFile( @@ -166,23 +163,23 @@ export class MeetingNotesStore { } async readUtterancesForSession( - session: MeetingNotesSessionDescriptor, + session: TranscriptSessionDescriptor, options: { maxUtterances?: number } = {}, - ): Promise { + ): Promise { return await this.readUtterancesFromDir(await this.findSessionDirForSession(session), options); } async readUtterancesFromSessionDir( sessionDir: string, options: { maxUtterances?: number } = {}, - ): Promise { + ): Promise { return await this.readUtterancesFromDir(sessionDir, options); } async readUtterances( sessionId: string, options: { maxUtterances?: number } = {}, - ): Promise { + ): Promise { const dir = await this.findSessionDir(sessionId); if (!dir) { return []; @@ -193,11 +190,11 @@ export class MeetingNotesStore { private async readUtterancesFromDir( dir: string, options: { maxUtterances?: number } = {}, - ): Promise { + ): Promise { const transcriptPath = path.join(dir, "transcript.jsonl"); const maxUtterances = normalizeMaxUtterances(options.maxUtterances); if (maxUtterances !== undefined) { - const utterances: MeetingNotesUtterance[] = []; + const utterances: TranscriptUtterance[] = []; try { const lines = createInterface({ input: createReadStream(transcriptPath, { encoding: "utf8" }), @@ -207,7 +204,7 @@ export class MeetingNotesStore { if (!line) { continue; } - utterances.push(JSON.parse(line) as MeetingNotesUtterance); + utterances.push(JSON.parse(line) as TranscriptUtterance); if (utterances.length > maxUtterances) { utterances.shift(); } @@ -232,7 +229,7 @@ export class MeetingNotesStore { return raw .split(/\r?\n/) .filter(Boolean) - .map((line) => JSON.parse(line) as MeetingNotesUtterance); + .map((line) => JSON.parse(line) as TranscriptUtterance); } async updateStopped(sessionId: string, stoppedAt: string): Promise { @@ -240,7 +237,7 @@ export class MeetingNotesStore { if (!dir) { return; } - const session = await readJsonFile( + const session = await readJsonFile( path.join(dir, "metadata.json"), ); if (!session) { @@ -253,8 +250,8 @@ export class MeetingNotesStore { } async writeSummary( - summary: MeetingNotesSummary, - session?: MeetingNotesSessionDescriptor, + summary: TranscriptsSummary, + session?: TranscriptSessionDescriptor, ): Promise { const dir = session !== undefined @@ -264,10 +261,10 @@ export class MeetingNotesStore { return await this.writeSummaryToDir(summary, dir); } - async writeSummaryToDir(summary: MeetingNotesSummary, dir: string): Promise { + async writeSummaryToDir(summary: TranscriptsSummary, dir: string): Promise { await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); - const markdown = renderMeetingNotesMarkdown(summary); + const markdown = renderTranscriptsMarkdown(summary); const markdownPath = path.join(dir, "summary.md"); await fs.writeFile(markdownPath, `${markdown}\n`); return markdownPath; diff --git a/extensions/meeting-notes/src/summary.ts b/src/transcripts/summary.ts similarity index 72% rename from extensions/meeting-notes/src/summary.ts rename to src/transcripts/summary.ts index b7bc18e4f16..043b85ffe51 100644 --- a/extensions/meeting-notes/src/summary.ts +++ b/src/transcripts/summary.ts @@ -1,10 +1,7 @@ -import type { - MeetingNotesSessionDescriptor, - MeetingNotesUtterance, -} from "openclaw/plugin-sdk/meeting-notes"; -import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; +import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js"; -export type MeetingNotesSummary = { +export type TranscriptsSummary = { sessionId: string; title: string; generatedAt: string; @@ -22,13 +19,13 @@ const DECISION_PATTERNS = /\b(decided|decision|we will|we'll|agreed|approved|go const RISK_PATTERNS = /\b(risk|blocked|blocker|concern|issue|problem|unknown|deadline|privacy|security)\b/i; -function firstSentences(utterances: MeetingNotesUtterance[], limit: number): string { +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: MeetingNotesUtterance[], pattern: RegExp): string[] { +function collectMatches(utterances: TranscriptUtterance[], pattern: RegExp): string[] { return utterances .filter((utterance) => pattern.test(utterance.text)) .map(formatSpeakerLine) @@ -36,7 +33,7 @@ function collectMatches(utterances: MeetingNotesUtterance[], pattern: RegExp): s .slice(0, 12); } -function formatSpeakerLine(utterance: MeetingNotesUtterance): string { +function formatSpeakerLine(utterance: TranscriptUtterance): string { const text = utterance.text.trim(); if (!text) { return ""; @@ -45,15 +42,15 @@ function formatSpeakerLine(utterance: MeetingNotesUtterance): string { return speaker ? `${speaker}: ${text}` : text; } -function formatTranscript(utterances: MeetingNotesUtterance[]): string[] { +function formatTranscript(utterances: TranscriptUtterance[]): string[] { return utterances.map(formatSpeakerLine).filter(Boolean); } -export function summarizeMeetingNotes(params: { - session: MeetingNotesSessionDescriptor; - utterances: MeetingNotesUtterance[]; -}): MeetingNotesSummary { - const title = params.session.title?.trim() || "Meeting notes"; +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, @@ -72,7 +69,7 @@ function renderList(items: string[]): string { return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- None captured"; } -export function renderMeetingNotesMarkdown(summary: MeetingNotesSummary): string { +export function renderTranscriptsMarkdown(summary: TranscriptsSummary): string { return [ `# ${summary.title}`, "", diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index c9bb60bd180..9463367cec3 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -186,16 +186,6 @@ describe("bundled plugin build entries", () => { } }); - it("keeps source-only external plugins out of bundled dist entries", () => { - const entries = listBundledPluginBuildEntries(); - const artifacts = listBundledPluginPackArtifacts(); - - for (const pluginId of ["meeting-notes"]) { - expectNoPrefixMatches(Object.keys(entries), `extensions/${pluginId}/`); - expectNoPrefixMatches(artifacts, `dist/extensions/${pluginId}/`); - } - }); - it("keeps bundled channel secret contracts on packed top-level sidecars", () => { const artifacts = listBundledPluginPackArtifacts(); const excludedPackageDirs = collectRootPackageExcludedExtensionDirs();