refactor: move transcripts into core

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

View File

@@ -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 <name>] <command>
status
index
search
meeting-notes
transcripts
list
show
path

View File

@@ -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/<session>/
$OPENCLAW_STATE_DIR/transcripts/YYYY-MM-DD/<session>/
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 <session>
openclaw meeting-notes show YYYY-MM-DD/<session>
openclaw meeting-notes path <session>
openclaw meeting-notes path YYYY-MM-DD/<session>
openclaw meeting-notes path <session> --dir
openclaw meeting-notes path <session> --metadata
openclaw meeting-notes path <session> --transcript
openclaw meeting-notes list --json
openclaw meeting-notes show <session> --json
openclaw meeting-notes path <session> --json
openclaw transcripts list
openclaw transcripts show <session>
openclaw transcripts show YYYY-MM-DD/<session>
openclaw transcripts path <session>
openclaw transcripts path YYYY-MM-DD/<session>
openclaw transcripts path <session> --dir
openclaw transcripts path <session> --metadata
openclaw transcripts path <session> --transcript
openclaw transcripts list --json
openclaw transcripts show <session> --json
openclaw transcripts path <session> --json
```
- `list`: list stored sessions, date-qualified selector, start time, title, and `summary.md` path.
@@ -57,7 +56,7 @@ openclaw meeting-notes path <session> --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 <session> --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"
}
]
}
}
```

View File

@@ -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",

View File

@@ -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` |

View File

@@ -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. |

View File

@@ -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.<pluginId>.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/<session>/
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 <session>
openclaw meeting-notes path <session>
openclaw meeting-notes path <session> --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 <session>
```
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)

View File

@@ -151,7 +151,7 @@ commands.
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord; contracts: meetingNotesSourceProviders |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord; contracts: transcriptSourceProviders |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />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`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />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`<br />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`<br />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`<br />source checkout only | plugin |
| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`<br />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`<br />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`<br />source checkout only | plugin |
| [qa-matrix](/plugins/reference/qa-matrix) | Matrix QA transport runner and substrate. | `@openclaw/qa-matrix`<br />source checkout only | plugin |

View File

@@ -44,7 +44,7 @@ pnpm plugins:inventory:gen
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord; contracts: meetingNotesSourceProviders |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />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`<br />included in OpenClaw | contracts: documentExtractors |
| [duckduckgo](/plugins/reference/duckduckgo) | Adds web search provider support. | `@openclaw/duckduckgo-plugin`<br />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`<br />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`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [mattermost](/plugins/reference/mattermost) | Adds the Mattermost channel surface for sending and receiving OpenClaw messages. | `@openclaw/mattermost`<br />included in OpenClaw | channels: mattermost |
| [meeting-notes](/plugins/reference/meeting-notes) | Capture meeting transcripts from channel-owned sources and write summaries. | `@openclaw/meeting-notes`<br />source checkout only | contracts: meetingNotesSourceProviders, tools |
| [memory-core](/plugins/reference/memory-core) | Adds memory embedding provider support. Adds agent-callable tools. | `@openclaw/memory-core`<br />included in OpenClaw | contracts: memoryEmbeddingProviders, tools |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [memory-wiki](/plugins/reference/memory-wiki) | Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw. | `@openclaw/memory-wiki`<br />included in OpenClaw | contracts: tools; skills |

View File

@@ -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

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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);
},
});

View File

@@ -1 +0,0 @@
export { discordVoiceMeetingNotesSourceProvider } from "./src/voice/meeting-notes-source.js";

View File

@@ -5,7 +5,7 @@
},
"channels": ["discord"],
"contracts": {
"meetingNotesSourceProviders": ["discord-voice"]
"transcriptSourceProviders": ["discord-voice"]
},
"channelEnvVars": {
"discord": ["DISCORD_BOT_TOKEN"]

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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<void> };
transcripts?: { sessionId: string; onUtterance: (event: unknown) => Promise<void> };
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" },

View File

@@ -419,7 +419,7 @@ export class DiscordVoiceManager {
params: { guildId: string; channelId: string },
options?: {
preserveFollowState?: boolean;
meetingNotes?: VoiceSessionEntry["meetingNotes"];
transcripts?: VoiceSessionEntry["transcripts"];
},
): Promise<VoiceOperationResult> {
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<VoiceOperationResult> {
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<VoiceOperationResult> {
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()

View File

@@ -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)}`,
);
});
}

View File

@@ -40,7 +40,7 @@ export async function processDiscordVoiceSegment(params: {
ownerAllowFrom?: string[];
fetchGuildName: (guildId: string) => Promise<string | undefined>;
speakerContext: DiscordVoiceSpeakerContextResolver;
meetingNotes?: VoiceSessionEntry["meetingNotes"];
transcripts?: VoiceSessionEntry["transcripts"];
enqueuePlayback: (entry: VoiceSessionEntry, task: () => Promise<void>) => 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: {

View File

@@ -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<void>;
onUtterance: (utterance: TranscriptUtterance) => void | Promise<void>;
};
receiveRecovery: VoiceReceiveRecoveryState;
isStopped: () => boolean;

View File

@@ -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",
},
);
});

View File

@@ -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<string, DiscordVoiceManager>();
@@ -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<DiscordVoiceManager | undefined> {
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) {

View File

@@ -0,0 +1 @@
export { discordVoiceTranscriptsSourceProvider } from "./src/voice/transcripts-source.js";

View File

@@ -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));
},
});

View File

@@ -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
}
}
}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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",

10
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -228,7 +228,7 @@
"realtime-transcription",
"realtime-bootstrap-context",
"realtime-voice",
"meeting-notes",
"transcripts",
"media-understanding",
"media-understanding-runtime",
"messaging-targets",

View File

@@ -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
? []

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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"]);
});

View File

@@ -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<typeof import("openclaw/plugin-sdk/meeting-notes")>();
vi.mock("../../transcripts/provider-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../transcripts/provider-registry.js")>();
return {
...actual,
getMeetingNotesSourceProvider: getMeetingNotesSourceProviderMock,
getTranscriptSourceProvider: getTranscriptSourceProviderMock,
};
});
async function makeStateDir(): Promise<string> {
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<string, unknown> = {}) {
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();

View File

@@ -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<string, ActiveMeetingNotesSession>();
const activeSessions = new Map<string, ActiveTranscriptsSession>();
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<string, unknown>): MeetingNotesSourceLocator {
function sourceFromParams(params: Record<string, unknown>): TranscriptSourceLocator {
const providerId = readStringParam(params, "providerId", { trim: true }) ?? "manual-transcript";
return {
providerId,
@@ -131,10 +143,10 @@ function sourceFromParams(params: Record<string, unknown>): 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<string, unknown>) {
@@ -145,9 +157,9 @@ function toolText(text: string, details?: Record<string, unknown>) {
}
async function summarizeAndPersist(params: {
config: ReturnType<typeof resolveMeetingNotesConfig>;
store: MeetingNotesStore;
session: MeetingNotesSessionDescriptor;
config: ReturnType<typeof resolveTranscriptsConfig>;
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<string, unknown>;
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<string, unknown>;
}) {
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<string, unknown>;
}) {
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<typeof resolveMeetingNotesConfig>;
store: MeetingNotesStore;
config: ReturnType<typeof resolveTranscriptsConfig>;
store: TranscriptsStore;
rawParams: Record<string, unknown>;
}) {
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<void>;
} {
let stopped = false;
const timers = new Set<ReturnType<typeof setTimeout>>();
const startedSessionIds = new Set<string>();
@@ -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)
}`,
),

View File

@@ -122,7 +122,7 @@ function createBundledPluginRecord(id: string): PluginRecord {
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
meetingNotesSourceProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],

View File

@@ -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(

View File

@@ -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",

View File

@@ -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<string> {
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<string> {
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<string> {
async function runTranscriptsCli(args: string[]): Promise<string> {
let output = "";
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((
chunk: string | Uint8Array,
@@ -50,15 +50,15 @@ async function runMeetingNotesCli(args: string[]): Promise<string> {
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"));
});

View File

@@ -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<StoredMeetingNotesSession | null> {
): Promise<StoredTranscriptsSession | null> {
const metadataPath = path.join(sessionDir, "metadata.json");
try {
const session = await readJsonFile<MeetingNotesSessionDescriptor>(metadataPath);
const session = await readJsonFile<TranscriptSessionDescriptor>(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<string[]> {
}
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<StoredMeetingNotesSession> {
async function requireStoredSession(selector: string): Promise<StoredTranscriptsSession> {
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<StoredMeetingNote
}
if (matches.length > 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<StoredMeetingNotesSession[]> {
async function listStoredSessions(): Promise<StoredTranscriptsSession[]> {
const dirs = await listStoredSessionDirs();
const sessions = await Promise.all(
dirs.map((dir) =>
@@ -208,20 +205,20 @@ async function listStoredSessions(): Promise<StoredMeetingNotesSession[]> {
),
);
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<void> {
async function listCommand(options: TranscriptsCliOptions): Promise<void> {
const sessions = await listStoredSessions();
if (options.json) {
writeJson(
@@ -241,7 +238,7 @@ async function listCommand(options: MeetingNotesCliOptions): Promise<void> {
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<void> {
}
}
async function showCommand(sessionId: string, options: MeetingNotesCliOptions): Promise<void> {
async function showCommand(sessionId: string, options: TranscriptsCliOptions): Promise<void> {
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<void> {
async function pathCommand(selector: string, options: TranscriptsPathOptions): Promise<void> {
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("<session>", "Meeting notes session id or YYYY-MM-DD/session selector")
.description("Print a transcript summary markdown file")
.argument("<session>", "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("<session>", "Meeting notes session id or YYYY-MM-DD/session selector")
.description("Print a stored transcripts artifact path")
.argument("<session>", "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);
});
}

View File

@@ -28,6 +28,7 @@ const ROOT_SECTIONS = [
"approvals",
"session",
"cron",
"transcripts",
"hooks",
"web",
"channels",

View File

@@ -1706,6 +1706,28 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -837,6 +837,17 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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;

View File

@@ -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({

View File

@@ -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<void>((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());

View File

@@ -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),

View File

@@ -93,7 +93,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],
mediaUnderstandingProviders: [],
meetingNotesSourceProviders: [],
transcriptSourceProviders: [],
imageGenerationProviders: [],
musicGenerationProviders: [],
videoGenerationProviders: [],

View File

@@ -143,7 +143,7 @@ type GatewayReloadHandlerParams = {
setState: (state: GatewayHotReloadState) => void;
startChannel: GatewayChannelManager["startChannel"];
stopChannel: GatewayChannelManager["stopChannel"];
stopPostReadySidecars?: () => void;
stopPostReadySidecars?: () => Promise<void> | 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 {

View File

@@ -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> | void }>
| undefined;
const lifetimeSidecars = onGatewayLifetimeSidecars.mock.calls[0]?.[0] as
| Array<{ stop: () => Promise<void> | 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<void>((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<void>((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);
});

View File

@@ -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<void>;
};
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<void>;
stop?: () => Awaitable<void>;
}): 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<void>) | 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<boolean> {
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?.();

View File

@@ -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({

View File

@@ -19,7 +19,7 @@ function createStubPluginRegistry(): PluginRegistry {
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],
mediaUnderstandingProviders: [],
meetingNotesSourceProviders: [],
transcriptSourceProviders: [],
imageGenerationProviders: [],
videoGenerationProviders: [],
musicGenerationProviders: [],

View File

@@ -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<string, unknown>;
};
export type MeetingNotesSessionDescriptor = {
sessionId: string;
title?: string;
source: MeetingNotesSourceLocator;
startedAt: string;
stoppedAt?: string;
metadata?: Record<string, unknown>;
};
export type MeetingNotesStartRequest = {
cfg?: OpenClawConfig;
session: MeetingNotesSessionDescriptor;
abortSignal?: AbortSignal;
startupWaitMs?: number;
onUtterance: (utterance: MeetingNotesUtterance) => void | Promise<void>;
onStatus?: (status: MeetingNotesSourceStatus) => void | Promise<void>;
};
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<MeetingNotesStartResult>;
stop?: (request: MeetingNotesStopRequest) => Promise<MeetingNotesStopResult>;
status?: (
source: MeetingNotesSourceLocator,
cfg?: OpenClawConfig,
) => Promise<MeetingNotesSourceStatus[]>;
importTranscript?: (request: MeetingNotesImportRequest) => Promise<MeetingNotesUtterance[]>;
};

View File

@@ -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",

View File

@@ -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";

View File

@@ -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,

View File

@@ -42,7 +42,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerRealtimeTranscriptionProvider() {},
registerRealtimeVoiceProvider() {},
registerMediaUnderstandingProvider() {},
registerMeetingNotesSourceProvider() {},
registerTranscriptSourceProvider() {},
registerImageGenerationProvider() {},
registerMusicGenerationProvider() {},
registerVideoGenerationProvider() {},

View File

@@ -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,
);
});
}

View File

@@ -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";

View File

@@ -29,7 +29,7 @@ function createPluginRecord(
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
meetingNotesSourceProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],

View File

@@ -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:

View File

@@ -91,7 +91,7 @@ describe("bundled capability metadata", () => {
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
meetingNotesSourceProviderIds: [],
transcriptSourceProviderIds: [],
documentExtractorIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],

View File

@@ -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,

View File

@@ -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<CapabilityProviderRegistryKey, CapabilityC
realtimeTranscriptionProviders: "realtimeTranscriptionProviders",
realtimeVoiceProviders: "realtimeVoiceProviders",
mediaUnderstandingProviders: "mediaUnderstandingProviders",
meetingNotesSourceProviders: "meetingNotesSourceProviders",
transcriptSourceProviders: "transcriptSourceProviders",
imageGenerationProviders: "imageGenerationProviders",
videoGenerationProviders: "videoGenerationProviders",
musicGenerationProviders: "musicGenerationProviders",

View File

@@ -28,7 +28,7 @@ import type {
OpenClawPluginApi,
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
MeetingNotesSourceProviderPlugin,
TranscriptSourceProvider,
MigrationProviderPlugin,
MusicGenerationProviderPlugin,
OpenClawPluginCliCommandDescriptor,
@@ -65,7 +65,7 @@ export type CapturedPluginRegistration = {
realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[];
realtimeVoiceProviders: RealtimeVoiceProviderPlugin[];
mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[];
meetingNotesSourceProviders: MeetingNotesSourceProviderPlugin[];
transcriptSourceProviders: TranscriptSourceProvider[];
imageGenerationProviders: ImageGenerationProviderPlugin[];
videoGenerationProviders: VideoGenerationProviderPlugin[];
musicGenerationProviders: MusicGenerationProviderPlugin[];
@@ -104,7 +104,7 @@ export function createCapturedPluginRegistration(params?: {
const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = [];
const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = [];
const mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[] = [];
const meetingNotesSourceProviders: MeetingNotesSourceProviderPlugin[] = [];
const transcriptSourceProviders: TranscriptSourceProvider[] = [];
const imageGenerationProviders: ImageGenerationProviderPlugin[] = [];
const videoGenerationProviders: VideoGenerationProviderPlugin[] = [];
const musicGenerationProviders: MusicGenerationProviderPlugin[] = [];
@@ -146,7 +146,7 @@ export function createCapturedPluginRegistration(params?: {
realtimeTranscriptionProviders,
realtimeVoiceProviders,
mediaUnderstandingProviders,
meetingNotesSourceProviders,
transcriptSourceProviders,
imageGenerationProviders,
videoGenerationProviders,
musicGenerationProviders,
@@ -244,8 +244,8 @@ export function createCapturedPluginRegistration(params?: {
registerMediaUnderstandingProvider(provider: MediaUnderstandingProviderPlugin) {
mediaUnderstandingProviders.push(provider);
},
registerMeetingNotesSourceProvider(provider: MeetingNotesSourceProviderPlugin) {
meetingNotesSourceProviders.push(provider);
registerTranscriptSourceProvider(provider: TranscriptSourceProvider) {
transcriptSourceProviders.push(provider);
},
registerImageGenerationProvider(provider: ImageGenerationProviderPlugin) {
imageGenerationProviders.push(provider);

View File

@@ -52,7 +52,7 @@ function createBundledPluginRecord(id: string): PluginRecord {
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
meetingNotesSourceProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],

View File

@@ -28,7 +28,7 @@ export type BundledPluginContractSnapshot = {
realtimeTranscriptionProviderIds: string[];
realtimeVoiceProviderIds: string[];
mediaUnderstandingProviderIds: string[];
meetingNotesSourceProviderIds: string[];
transcriptSourceProviderIds: string[];
documentExtractorIds: string[];
imageGenerationProviderIds: string[];
videoGenerationProviderIds: string[];
@@ -145,8 +145,8 @@ export function buildBundledPluginContractSnapshot(
manifest.contracts?.mediaUnderstandingProviders,
(value) => 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 ||

View File

@@ -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,
});
});

View File

@@ -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<RealtimeVoiceProviderPlugin>;
type MediaUnderstandingProviderContractEntry =
CapabilityContractEntry<MediaUnderstandingProviderPlugin>;
type MeetingNotesSourceProviderContractEntry =
CapabilityContractEntry<MeetingNotesSourceProviderPlugin>;
type TranscriptsSourceProviderContractEntry = CapabilityContractEntry<TranscriptSourceProvider>;
type ImageGenerationProviderContractEntry = CapabilityContractEntry<ImageGenerationProviderPlugin>;
type VideoGenerationProviderContractEntry = CapabilityContractEntry<VideoGenerationProviderPlugin>;
type MusicGenerationProviderContractEntry = CapabilityContractEntry<MusicGenerationProviderPlugin>;
@@ -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[] =

View File

@@ -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,
})),

View File

@@ -39,7 +39,7 @@ export function createMockPluginRegistry(
embeddingProviders: [],
speechProviders: [],
mediaUnderstandingProviders: [],
meetingNotesSourceProviders: [],
transcriptSourceProviders: [],
imageGenerationProviders: [],
videoGenerationProviders: [],
musicGenerationProviders: [],

View File

@@ -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 },

View File

@@ -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 ?? [])],

View File

@@ -49,7 +49,7 @@ function createLoadedPluginRecord(id: string): PluginRecord {
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
meetingNotesSourceProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 } : {}),

View File

@@ -17,7 +17,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],
mediaUnderstandingProviders: [],
meetingNotesSourceProviders: [],
transcriptSourceProviders: [],
imageGenerationProviders: [],
videoGenerationProviders: [],
musicGenerationProviders: [],

View File

@@ -37,7 +37,7 @@ import type {
CliBackendPlugin,
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
MeetingNotesSourceProviderPlugin,
TranscriptSourceProvider,
MusicGenerationProviderPlugin,
OpenClawPluginChannelRegistration,
OpenClawPluginCliCommandDescriptor,
@@ -183,8 +183,8 @@ export type PluginRealtimeVoiceProviderRegistration =
PluginOwnedProviderRegistration<RealtimeVoiceProviderPlugin>;
export type PluginMediaUnderstandingProviderRegistration =
PluginOwnedProviderRegistration<MediaUnderstandingProviderPlugin>;
export type PluginMeetingNotesSourceProviderRegistration =
PluginOwnedProviderRegistration<MeetingNotesSourceProviderPlugin>;
export type PluginTranscriptsSourceProviderRegistration =
PluginOwnedProviderRegistration<TranscriptSourceProvider>;
export type PluginImageGenerationProviderRegistration =
PluginOwnedProviderRegistration<ImageGenerationProviderPlugin>;
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[];

View File

@@ -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,

View File

@@ -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: [],

View File

@@ -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 ?? [])],

View File

@@ -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). */

View File

@@ -32,7 +32,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],
mediaUnderstandingProviders: [],
meetingNotesSourceProviders: [],
transcriptSourceProviders: [],
imageGenerationProviders: [],
videoGenerationProviders: [],
musicGenerationProviders: [],

View File

@@ -109,7 +109,7 @@ describe("trajectory metadata", () => {
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
meetingNotesSourceProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],

View File

@@ -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<string, unknown>) : {};
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<string, unknown>) : {};
const maxUtterances =
typeof config.maxUtterances === "number" && Number.isFinite(config.maxUtterances)
? Math.max(1, Math.min(10_000, Math.floor(config.maxUtterances)))
: 2_000;
return {
enabled: config.enabled !== false,
enabled: config.enabled === true,
maxUtterances,
autoStart: resolveAutoStart(config.autoStart),
};

View File

@@ -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",

View File

@@ -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<string, MeetingNotesSourceProviderPlugin>;
aliases: Map<string, MeetingNotesSourceProviderPlugin>;
canonical: Map<string, TranscriptSourceProvider>;
aliases: Map<string, TranscriptSourceProvider>;
} {
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,
});

View File

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

View File

@@ -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<unknown>(path.join(dir, "metadata.json"))) !== undefined;
}
private async findSessionDirForSession(session: MeetingNotesSessionDescriptor): Promise<string> {
private async findSessionDirForSession(session: TranscriptSessionDescriptor): Promise<string> {
const datedDir = this.sessionDir(session);
const datedSession = await readJsonFile<MeetingNotesSessionDescriptor>(
const datedSession = await readJsonFile<TranscriptSessionDescriptor>(
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<MeetingNotesSessionDescriptor>(
const session = await readJsonFile<TranscriptSessionDescriptor>(
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<void> {
async writeSession(session: TranscriptSessionDescriptor): Promise<void> {
const dir = this.sessionDir(session);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "metadata.json"), `${JSON.stringify(session, null, 2)}\n`);
}
async readSession(sessionId: string): Promise<MeetingNotesSessionDescriptor | undefined> {
async readSession(sessionId: string): Promise<TranscriptSessionDescriptor | undefined> {
return (await this.readSessionEntry(sessionId))?.session;
}
async readSessionEntry(sessionId: string): Promise<MeetingNotesSessionEntry | undefined> {
async readSessionEntry(sessionId: string): Promise<TranscriptsSessionEntry | undefined> {
const dir = await this.findSessionDir(sessionId);
if (!dir) {
return undefined;
}
const session = await readJsonFile<MeetingNotesSessionDescriptor>(
const session = await readJsonFile<TranscriptSessionDescriptor>(
path.join(dir, "metadata.json"),
);
return session ? { session, sessionDir: dir } : undefined;
}
async appendUtterance(sessionId: string, utterance: MeetingNotesUtterance): Promise<void> {
async appendUtterance(sessionId: string, utterance: TranscriptUtterance): Promise<void> {
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<void> {
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<void> {
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<MeetingNotesUtterance[]> {
): Promise<TranscriptUtterance[]> {
return await this.readUtterancesFromDir(await this.findSessionDirForSession(session), options);
}
async readUtterancesFromSessionDir(
sessionDir: string,
options: { maxUtterances?: number } = {},
): Promise<MeetingNotesUtterance[]> {
): Promise<TranscriptUtterance[]> {
return await this.readUtterancesFromDir(sessionDir, options);
}
async readUtterances(
sessionId: string,
options: { maxUtterances?: number } = {},
): Promise<MeetingNotesUtterance[]> {
): Promise<TranscriptUtterance[]> {
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<MeetingNotesUtterance[]> {
): Promise<TranscriptUtterance[]> {
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<void> {
@@ -240,7 +237,7 @@ export class MeetingNotesStore {
if (!dir) {
return;
}
const session = await readJsonFile<MeetingNotesSessionDescriptor>(
const session = await readJsonFile<TranscriptSessionDescriptor>(
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<string> {
const dir =
session !== undefined
@@ -264,10 +261,10 @@ export class MeetingNotesStore {
return await this.writeSummaryToDir(summary, dir);
}
async writeSummaryToDir(summary: MeetingNotesSummary, dir: string): Promise<string> {
async writeSummaryToDir(summary: TranscriptsSummary, dir: string): Promise<string> {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
const markdown = renderMeetingNotesMarkdown(summary);
const markdown = renderTranscriptsMarkdown(summary);
const markdownPath = path.join(dir, "summary.md");
await fs.writeFile(markdownPath, `${markdown}\n`);
return markdownPath;

View File

@@ -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}`,
"",

View File

@@ -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();