mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
45feb37b13
commit
cac0b2db18
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { discordVoiceMeetingNotesSourceProvider } from "./src/voice/meeting-notes-source.js";
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"channels": ["discord"],
|
||||
"contracts": {
|
||||
"meetingNotesSourceProviders": ["discord-voice"]
|
||||
"transcriptSourceProviders": ["discord-voice"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"discord": ["DISCORD_BOT_TOKEN"]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
1
extensions/discord/transcripts-source-api.ts
Normal file
1
extensions/discord/transcripts-source-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { discordVoiceTranscriptsSourceProvider } from "./src/voice/transcripts-source.js";
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
"realtime-transcription",
|
||||
"realtime-bootstrap-context",
|
||||
"realtime-voice",
|
||||
"meeting-notes",
|
||||
"transcripts",
|
||||
"media-understanding",
|
||||
"media-understanding-runtime",
|
||||
"messaging-targets",
|
||||
|
||||
@@ -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
|
||||
? []
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
}`,
|
||||
),
|
||||
@@ -122,7 +122,7 @@ function createBundledPluginRecord(id: string): PluginRecord {
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
meetingNotesSourceProviderIds: [],
|
||||
transcriptSourceProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
musicGenerationProviderIds: [],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const ROOT_SECTIONS = [
|
||||
"approvals",
|
||||
"session",
|
||||
"cron",
|
||||
"transcripts",
|
||||
"hooks",
|
||||
"web",
|
||||
"channels",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -93,7 +93,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
realtimeTranscriptionProviders: [],
|
||||
realtimeVoiceProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
meetingNotesSourceProviders: [],
|
||||
transcriptSourceProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
musicGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -19,7 +19,7 @@ function createStubPluginRegistry(): PluginRegistry {
|
||||
realtimeTranscriptionProviders: [],
|
||||
realtimeVoiceProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
meetingNotesSourceProviders: [],
|
||||
transcriptSourceProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
musicGenerationProviders: [],
|
||||
|
||||
@@ -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[]>;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -42,7 +42,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerRealtimeTranscriptionProvider() {},
|
||||
registerRealtimeVoiceProvider() {},
|
||||
registerMediaUnderstandingProvider() {},
|
||||
registerMeetingNotesSourceProvider() {},
|
||||
registerTranscriptSourceProvider() {},
|
||||
registerImageGenerationProvider() {},
|
||||
registerMusicGenerationProvider() {},
|
||||
registerVideoGenerationProvider() {},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
19
src/plugin-sdk/transcripts.ts
Normal file
19
src/plugin-sdk/transcripts.ts
Normal 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";
|
||||
@@ -29,7 +29,7 @@ function createPluginRecord(
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
meetingNotesSourceProviderIds: [],
|
||||
transcriptSourceProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
musicGenerationProviderIds: [],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("bundled capability metadata", () => {
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
meetingNotesSourceProviderIds: [],
|
||||
transcriptSourceProviderIds: [],
|
||||
documentExtractorIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,7 +52,7 @@ function createBundledPluginRecord(id: string): PluginRecord {
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
meetingNotesSourceProviderIds: [],
|
||||
transcriptSourceProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
musicGenerationProviderIds: [],
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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[] =
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -39,7 +39,7 @@ export function createMockPluginRegistry(
|
||||
embeddingProviders: [],
|
||||
speechProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
meetingNotesSourceProviders: [],
|
||||
transcriptSourceProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
musicGenerationProviders: [],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 ?? [])],
|
||||
|
||||
@@ -49,7 +49,7 @@ function createLoadedPluginRecord(id: string): PluginRecord {
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
meetingNotesSourceProviderIds: [],
|
||||
transcriptSourceProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
musicGenerationProviderIds: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
realtimeTranscriptionProviders: [],
|
||||
realtimeVoiceProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
meetingNotesSourceProviders: [],
|
||||
transcriptSourceProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
musicGenerationProviders: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 ?? [])],
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -32,7 +32,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
realtimeTranscriptionProviders: [],
|
||||
realtimeVoiceProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
meetingNotesSourceProviders: [],
|
||||
transcriptSourceProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
musicGenerationProviders: [],
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("trajectory metadata", () => {
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
meetingNotesSourceProviderIds: [],
|
||||
transcriptSourceProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
musicGenerationProviderIds: [],
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
});
|
||||
109
src/transcripts/provider-types.ts
Normal file
109
src/transcripts/provider-types.ts
Normal 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[]>;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}`,
|
||||
"",
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user