7 Commits
Author SHA1 Message Date
renovate-botandManuel 78074a4c00 Update dependency Microsoft.Agents.AI.OpenAI to 1.7.0
PR and Push Build/Test / build-and-test (push) Successful in 6m53s
PR and Push Build/Test / build-and-test (pull_request) Failing after 6m46s
2026-05-28 13:52:45 +02:00
Manuel 9d2153484a Update README.md
PR and Push Build/Test / build-and-test (push) Successful in 7m25s
2026-05-28 13:52:08 +02:00
Manuel 72109ba66f Update MeetingAssistant/appsettings.json
PR and Push Build/Test / build-and-test (push) Successful in 6m34s
2026-05-28 13:51:20 +02:00
codex c46c587e65 Warm up pyannote and FunASR runtimes
PR and Push Build/Test / build-and-test (push) Successful in 6m48s
2026-05-28 13:05:27 +02:00
codex 7ff93b73b3 Make agent file writes append by default
PR and Push Build/Test / build-and-test (push) Failing after 6m36s
2026-05-28 12:36:23 +02:00
Manuel 78549645bc improve dev skills 2026-05-28 12:09:24 +02:00
codex a72cda0c03 Harden speaker identity samples
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
2026-05-28 12:02:44 +02:00
62 changed files with 4415 additions and 167 deletions
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,115 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**CLI reality check**: prefer the real archive command, `openspec archive "<change-name>" -y`. Do not hand-move the change directory unless the CLI is unavailable. Do not call `openspec sync` or `openspec sync specs`; use `openspec-sync-specs` only for the agent-driven manual sync path when archiving is not desired.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
@@ -0,0 +1,246 @@
---
name: openspec-bulk-archive-change
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive multiple completed changes in a single operation.
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
**Input**: None required (prompts for selection)
**Steps**
1. **Get active changes**
Run `openspec list --json` to get all active changes.
If no active changes exist, inform user and stop.
2. **Prompt for change selection**
Use **AskUserQuestion tool** with multi-select to let user choose changes:
- Show each change with its schema
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
3. **Batch validation - gather status for all selected changes**
For each selected change, collect:
a. **Artifact status** - Run `openspec status --change "<name>" --json`
- Parse `schemaName` and `artifacts` list
- Note which artifacts are `done` vs other states
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
- If no tasks file exists, note as "No tasks"
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
- List which capability specs exist
- For each, extract requirement names (lines matching `### Requirement: <name>`)
4. **Detect spec conflicts**
Build a map of `capability -> [changes that touch it]`:
```
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
api -> [change-c] <- OK (only 1 change)
```
A conflict exists when 2+ selected changes have delta specs for the same capability.
5. **Resolve conflicts agentically**
**For each conflict**, investigate the codebase:
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
b. **Search the codebase** for implementation evidence:
- Look for code implementing requirements from each delta spec
- Check for related files, functions, or tests
c. **Determine resolution**:
- If only one change is actually implemented -> sync that one's specs
- If both implemented -> apply in chronological order (older first, newer overwrites)
- If neither implemented -> skip spec sync, warn user
d. **Record resolution** for each conflict:
- Which change's specs to apply
- In what order (if both)
- Rationale (what was found in codebase)
6. **Show consolidated status table**
Display a table summarizing all changes:
```
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
```
For conflicts, show the resolution:
```
* Conflict resolution:
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
```
For incomplete changes, show warnings:
```
Warnings:
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
```
7. **Confirm batch operation**
Use **AskUserQuestion tool** with a single confirmation:
- "Archive N changes?" with options based on status
- Options might include:
- "Archive all N changes"
- "Archive only N ready changes (skip incomplete)"
- "Cancel"
If there are incomplete changes, make clear they'll be archived with warnings.
8. **Execute archive for each confirmed change**
Process changes in the determined order (respecting conflict resolution):
a. **Sync specs** if delta specs exist:
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
- For conflicts, apply in resolved order
- Track if sync was done
b. **Perform the archive**:
```bash
mkdir -p openspec/changes/archive
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
c. **Track outcome** for each change:
- Success: archived successfully
- Failed: error during archive (record error)
- Skipped: user chose not to archive (if applicable)
9. **Display summary**
Show final results:
```
## Bulk Archive Complete
Archived 3 changes:
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
- project-config -> archive/2026-01-19-project-config/
- add-oauth -> archive/2026-01-19-add-oauth/
Skipped 1 change:
- add-verify-skill (user chose not to archive incomplete)
Spec sync summary:
- 4 delta specs synced to main specs
- 1 conflict resolved (auth: applied both in chronological order)
```
If any failures:
```
Failed 1 change:
- some-change: Archive directory already exists
```
**Conflict Resolution Examples**
Example 1: Only one implemented
```
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
Checking add-oauth:
- Delta adds "OAuth Provider Integration" requirement
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
Checking add-jwt:
- Delta adds "JWT Token Handling" requirement
- Searching codebase... no JWT implementation found
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
```
Example 2: Both implemented
```
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
Checking add-rest-api (created 2026-01-10):
- Delta adds "REST Endpoints" requirement
- Searching codebase... found src/api/rest.ts
Checking add-graphql (created 2026-01-15):
- Delta adds "GraphQL Schema" requirement
- Searching codebase... found src/api/graphql.ts
Resolution: Both implemented. Will apply add-rest-api specs first,
then add-graphql specs (chronological order, newer takes precedence).
```
**Output On Success**
```
## Bulk Archive Complete
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
Spec sync summary:
- N delta specs synced to main specs
- No conflicts (or: M conflicts resolved)
```
**Output On Partial Success**
```
## Bulk Archive Complete (partial)
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
Skipped M changes:
- <change-2> (user chose not to archive incomplete)
Failed K changes:
- <change-3>: Archive directory already exists
```
**Output When No Changes**
```
## No Changes to Archive
No active changes found. Create a new change to get started.
```
**Guardrails**
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
- Always prompt for selection, never auto-select
- Detect spec conflicts early and resolve by checking codebase
- When both changes are implemented, apply specs in chronological order
- Skip spec sync only when implementation is missing (warn user)
- Show clear per-change status before confirming
- Use single confirmation for entire batch
- Track and report all outcomes (success/skip/fail)
- Preserve .openspec.yaml when moving to archive
- Archive directory target uses current date: YYYY-MM-DD-<name>
- If archive target exists, fail that change but continue with others
@@ -0,0 +1,118 @@
---
name: openspec-continue-change
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Continue working on a change by creating the next artifact.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check current status**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
3. **Act based on status**:
---
**If all artifacts are complete (`isComplete: true`)**:
- Congratulate the user
- Show final status including the schema used
- Suggest: "All artifacts created! You can now implement this change or archive it."
- STOP
---
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
- Pick the FIRST artifact with `status: "ready"` from the status output
- Get its instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- Parse the JSON. The key fields are:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- **Create the artifact file**:
- Read any completed dependency files for context
- Use `template` as the structure - fill in its sections
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
- Write to the output path specified in instructions
- Show what was created and what's now unlocked
- STOP after creating ONE artifact
---
**If no artifacts are ready (all blocked)**:
- This shouldn't happen with a valid schema
- Show status and suggest checking for issues
4. **After creating an artifact, show progress**
```bash
openspec status --change "<name>"
```
**Output**
After each invocation, show:
- Which artifact was created
- Schema workflow being used
- Current progress (N/M complete)
- What artifacts are now unlocked
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
**Artifact Creation Guidelines**
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
Common artifact patterns:
**spec-driven schema** (proposal → specs → design → tasks):
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
- The Capabilities section is critical - each capability listed will need a spec file.
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
- **design.md**: Document technical decisions, architecture, and implementation approach.
- **tasks.md**: Break down implementation into checkboxed tasks.
For other schemas, follow the `instruction` field from the CLI output.
**Guardrails**
- Create ONE artifact per invocation
- Always read dependency artifacts before creating a new one
- Never skip artifacts or create out of order
- If context is unclear, ask the user before creating
- Verify the artifact file exists after writing before marking progress
- Use the schema's artifact sequence, don't assume specific artifact names
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
+288
View File
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+101
View File
@@ -0,0 +1,101 @@
---
name: openspec-ff-change
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, suggest continuing that change instead
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,74 @@
---
name: openspec-new-change
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Start a new change using the experimental artifact-driven approach.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Determine the workflow schema**
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
**Use a different schema only if the user mentions:**
- A specific schema name → use `--schema <name>`
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
**Otherwise**: Omit `--schema` to use the default.
3. **Create the change directory**
```bash
openspec new change "<name>"
```
Add `--schema <name>` only if the user requested a specific workflow.
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
4. **Show the artifact status**
```bash
openspec status --change "<name>"
```
This shows which artifacts need to be created and which are ready (dependencies satisfied).
5. **Get instructions for the first artifact**
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
Check the status output to find the first artifact with status "ready".
```bash
openspec instructions <first-artifact-id> --change "<name>"
```
This outputs the template and context for creating the first artifact.
6. **STOP and wait for user direction**
**Output**
After completing the steps, summarize:
- Change name and location
- Schema/workflow being used and its artifact sequence
- Current status (0/N artifacts complete)
- The template for the first artifact
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
**Guardrails**
- Do NOT create any artifacts yet - just show the instructions
- Do NOT advance beyond showing the first artifact template
- If the name is invalid (not kebab-case), ask for a valid name
- If a change with that name already exists, suggest continuing that change instead
- Pass --schema if using a non-default workflow
+554
View File
@@ -0,0 +1,554 @@
---
name: openspec-onboard
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
---
## Preflight
Before starting, check if the OpenSpec CLI is installed:
```bash
# Unix/macOS
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
# Windows (PowerShell)
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
```
**If CLI not installed:**
> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`.
Stop here if not installed.
---
## Phase 1: Welcome
Display:
```
## Welcome to OpenSpec!
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
**What we'll do:**
1. Pick a small, real task in your codebase
2. Explore the problem briefly
3. Create a change (the container for our work)
4. Build the artifacts: proposal → specs → design → tasks
5. Implement the tasks
6. Archive the completed change
**Time:** ~15-20 minutes
Let's start by finding something to work on.
```
---
## Phase 2: Task Selection
### Codebase Analysis
Scan the codebase for small improvement opportunities. Look for:
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
3. **Functions without tests** - Cross-reference `src/` with test directories
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
6. **Missing validation** - User input handlers without validation
Also check recent git activity:
```bash
# Unix/macOS
git log --oneline -10 2>/dev/null || echo "No git history"
# Windows (PowerShell)
# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" }
```
### Present Suggestions
From your analysis, present 3-4 specific suggestions:
```
## Task Suggestions
Based on scanning your codebase, here are some good starter tasks:
**1. [Most promising task]**
Location: `src/path/to/file.ts:42`
Scope: ~1-2 files, ~20-30 lines
Why it's good: [brief reason]
**2. [Second task]**
Location: `src/another/file.ts`
Scope: ~1 file, ~15 lines
Why it's good: [brief reason]
**3. [Third task]**
Location: [location]
Scope: [estimate]
Why it's good: [brief reason]
**4. Something else?**
Tell me what you'd like to work on.
Which task interests you? (Pick a number or describe your own)
```
**If nothing found:** Fall back to asking what the user wants to build:
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
### Scope Guardrail
If the user picks or describes something too large (major feature, multi-day work):
```
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
**Options:**
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
2. **Pick something else** - One of the other suggestions, or a different small task?
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
What would you prefer?
```
Let the user override if they insist—this is a soft guardrail.
---
## Phase 3: Explore Demo
Once a task is selected, briefly demonstrate explore mode:
```
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
```
Spend 1-2 minutes investigating the relevant code:
- Read the file(s) involved
- Draw a quick ASCII diagram if it helps
- Note any considerations
```
## Quick Exploration
[Your brief analysis—what you found, any considerations]
┌─────────────────────────────────────────┐
│ [Optional: ASCII diagram if helpful] │
└─────────────────────────────────────────┘
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
Now let's create a change to hold our work.
```
**PAUSE** - Wait for user acknowledgment before proceeding.
---
## Phase 4: Create the Change
**EXPLAIN:**
```
## Creating a Change
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
Let me create one for our task.
```
**DO:** Create the change with a derived kebab-case name:
```bash
openspec new change "<derived-name>"
```
**SHOW:**
```
Created: `openspec/changes/<name>/`
The folder structure:
```
openspec/changes/<name>/
├── proposal.md ← Why we're doing this (empty, we'll fill it)
├── design.md ← How we'll build it (empty)
├── specs/ ← Detailed requirements (empty)
└── tasks.md ← Implementation checklist (empty)
```
Now let's fill in the first artifact—the proposal.
```
---
## Phase 5: Proposal
**EXPLAIN:**
```
## The Proposal
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
I'll draft one based on our task.
```
**DO:** Draft the proposal content (don't save yet):
```
Here's a draft proposal:
---
## Why
[1-2 sentences explaining the problem/opportunity]
## What Changes
[Bullet points of what will be different]
## Capabilities
### New Capabilities
- `<capability-name>`: [brief description]
### Modified Capabilities
<!-- If modifying existing behavior -->
## Impact
- `src/path/to/file.ts`: [what changes]
- [other files if applicable]
---
Does this capture the intent? I can adjust before we save it.
```
**PAUSE** - Wait for user approval/feedback.
After approval, save the proposal:
```bash
openspec instructions proposal --change "<name>" --json
```
Then write the content to `openspec/changes/<name>/proposal.md`.
```
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
Next up: specs.
```
---
## Phase 6: Specs
**EXPLAIN:**
```
## Specs
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
For a small task like this, we might only need one spec file.
```
**DO:** Create the spec file:
```bash
# Unix/macOS
mkdir -p openspec/changes/<name>/specs/<capability-name>
# Windows (PowerShell)
# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-name>"
```
Draft the spec content:
```
Here's the spec:
---
## ADDED Requirements
### Requirement: <Name>
<Description of what the system should do>
#### Scenario: <Scenario name>
- **WHEN** <trigger condition>
- **THEN** <expected outcome>
- **AND** <additional outcome if needed>
---
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
```
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
---
## Phase 7: Design
**EXPLAIN:**
```
## Design
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
```
**DO:** Draft design.md:
```
Here's the design:
---
## Context
[Brief context about the current state]
## Goals / Non-Goals
**Goals:**
- [What we're trying to achieve]
**Non-Goals:**
- [What's explicitly out of scope]
## Decisions
### Decision 1: [Key decision]
[Explanation of approach and rationale]
---
For a small task, this captures the key decisions without over-engineering.
```
Save to `openspec/changes/<name>/design.md`.
---
## Phase 8: Tasks
**EXPLAIN:**
```
## Tasks
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
These should be small, clear, and in logical order.
```
**DO:** Generate tasks based on specs and design:
```
Here are the implementation tasks:
---
## 1. [Category or file]
- [ ] 1.1 [Specific task]
- [ ] 1.2 [Specific task]
## 2. Verify
- [ ] 2.1 [Verification step]
---
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
```
**PAUSE** - Wait for user to confirm they're ready to implement.
Save to `openspec/changes/<name>/tasks.md`.
---
## Phase 9: Apply (Implementation)
**EXPLAIN:**
```
## Implementation
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
```
**DO:** For each task:
1. Announce: "Working on task N: [description]"
2. Implement the change in the codebase
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
4. Mark complete in tasks.md: `- [ ]``- [x]`
5. Brief status: "✓ Task N complete"
Keep narration light—don't over-explain every line of code.
After all tasks:
```
## Implementation Complete
All tasks done:
- [x] Task 1
- [x] Task 2
- [x] ...
The change is implemented! One more step—let's archive it.
```
---
## Phase 10: Archive
**EXPLAIN:**
```
## Archiving
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
```
**DO:**
```bash
openspec archive "<name>"
```
**SHOW:**
```
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
```
---
## Phase 11: Recap & Next Steps
```
## Congratulations!
You just completed a full OpenSpec cycle:
1. **Explore** - Thought through the problem
2. **New** - Created a change container
3. **Proposal** - Captured WHY
4. **Specs** - Defined WHAT in detail
5. **Design** - Decided HOW
6. **Tasks** - Broke it into steps
7. **Apply** - Implemented the work
8. **Archive** - Preserved the record
This same rhythm works for any size change—a small fix or a major feature.
---
## Command Reference
**Core workflow:**
| Command | What it does |
|-------------------|--------------------------------------------|
| `/opsx:propose` | Create a change and generate all artifacts |
| `/opsx:explore` | Think through problems before/during work |
| `/opsx:apply` | Implement tasks from a change |
| `/opsx:archive` | Archive a completed change |
**Additional commands:**
| Command | What it does |
|--------------------|----------------------------------------------------------|
| `/opsx:new` | Start a new change, step through artifacts one at a time |
| `/opsx:continue` | Continue working on an existing change |
| `/opsx:ff` | Fast-forward: create all artifacts at once |
| `/opsx:verify` | Verify implementation matches artifacts |
---
## What's Next?
Try `/opsx:propose` on something you actually want to build. You've got the rhythm now!
```
---
## Graceful Exit Handling
### User wants to stop mid-way
If the user says they need to stop, want to pause, or seem disengaged:
```
No problem! Your change is saved at `openspec/changes/<name>/`.
To pick up where we left off later:
- `/opsx:continue <name>` - Resume artifact creation
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
The work won't be lost. Come back whenever you're ready.
```
Exit gracefully without pressure.
### User just wants command reference
If the user says they just want to see the commands or skip the tutorial:
```
## OpenSpec Quick Reference
**Core workflow:**
| Command | What it does |
|--------------------------|--------------------------------------------|
| `/opsx:propose <name>` | Create a change and generate all artifacts |
| `/opsx:explore` | Think through problems (no code changes) |
| `/opsx:apply <name>` | Implement tasks |
| `/opsx:archive <name>` | Archive when done |
**Additional commands:**
| Command | What it does |
|---------------------------|-------------------------------------|
| `/opsx:new <name>` | Start a new change, step by step |
| `/opsx:continue <name>` | Continue an existing change |
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
| `/opsx:verify <name>` | Verify implementation |
Try `/opsx:propose` to start your first change.
```
Exit gracefully.
---
## Guardrails
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
- **Keep narration light** during implementation—teach without lecturing
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
- **Pause for acknowledgment** at marked points, but don't over-pause
- **Handle exits gracefully**—never pressure the user to continue
- **Use real codebase tasks**—don't simulate or use fake examples
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
+110
View File
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+139
View File
@@ -0,0 +1,139 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**CLI reality check**: do not call `openspec sync` or `openspec sync specs`; the current OpenSpec CLI does not provide those commands. Use this skill's manual merge workflow when the user wants specs updated without archiving. When the change is complete and should be archived, prefer `openspec archive "<change-name>" -y`, then verify with `openspec list`.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Find delta specs**
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
3. **For each delta spec, apply changes to main specs**
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
4. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
@@ -0,0 +1,168 @@
---
name: openspec-verify-change
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Verify that an implementation matches the change artifacts (specs, tasks, design).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have implementation tasks (tasks artifact exists).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
```bash
openspec instructions apply --change "<name>" --json
```
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
4. **Initialize verification report structure**
Create a report structure with three dimensions:
- **Completeness**: Track tasks and spec coverage
- **Correctness**: Track requirement implementation and scenario coverage
- **Coherence**: Track design adherence and pattern consistency
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
5. **Verify Completeness**
**Task Completion**:
- If `contextFiles.tasks` exists, read every file path in it
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
**Spec Coverage**:
- If delta specs exist in `openspec/changes/<name>/specs/`:
- Extract all requirements (marked with "### Requirement:")
- For each requirement:
- Search codebase for keywords related to the requirement
- Assess if implementation likely exists
- If requirements appear unimplemented:
- Add CRITICAL issue: "Requirement not found: <requirement name>"
- Recommendation: "Implement requirement X: <description>"
6. **Verify Correctness**
**Requirement Implementation Mapping**:
- For each requirement from delta specs:
- Search codebase for implementation evidence
- If found, note file paths and line ranges
- Assess if implementation matches requirement intent
- If divergence detected:
- Add WARNING: "Implementation may diverge from spec: <details>"
- Recommendation: "Review <file>:<lines> against requirement X"
**Scenario Coverage**:
- For each scenario in delta specs (marked with "#### Scenario:"):
- Check if conditions are handled in code
- Check if tests exist covering the scenario
- If scenario appears uncovered:
- Add WARNING: "Scenario not covered: <scenario name>"
- Recommendation: "Add test or implementation for scenario: <description>"
7. **Verify Coherence**
**Design Adherence**:
- If `contextFiles.design` exists:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
- Add WARNING: "Design decision not followed: <decision>"
- Recommendation: "Update implementation or revise design.md to match reality"
- If no design.md: Skip design adherence check, note "No design.md to verify against"
**Code Pattern Consistency**:
- Review new code for consistency with project patterns
- Check file naming, directory structure, coding style
- If significant deviations found:
- Add SUGGESTION: "Code pattern deviation: <details>"
- Recommendation: "Consider following project pattern: <example>"
8. **Generate Verification Report**
**Summary Scorecard**:
```
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|------------------|
| Completeness | X/Y tasks, N reqs|
| Correctness | M/N reqs covered |
| Coherence | Followed/Issues |
```
**Issues by Priority**:
1. **CRITICAL** (Must fix before archive):
- Incomplete tasks
- Missing requirement implementations
- Each with specific, actionable recommendation
2. **WARNING** (Should fix):
- Spec/design divergences
- Missing scenario coverage
- Each with specific recommendation
3. **SUGGESTION** (Nice to fix):
- Pattern inconsistencies
- Minor improvements
- Each with specific recommendation
**Final Assessment**:
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
- If all clear: "All checks passed. Ready for archive."
**Verification Heuristics**
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
**Graceful Degradation**
- If only tasks.md exists: verify task completion only, skip spec/design checks
- If tasks + specs exist: verify completeness and correctness, skip design
- If full artifacts: verify all three dimensions
- Always note which checks were skipped and why
**Output Format**
Use clear markdown with:
- Table for summary scorecard
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
- Code references in format: `file.ts:123`
- Specific, actionable recommendations
- No vague suggestions like "consider reviewing"
+1
View File
@@ -9,6 +9,7 @@ Meeting Assistant is a spec-driven .NET service for capturing, transcribing, sum
- all specs under `openspec/specs`
- all active change specs under `openspec/changes/*/specs`
- active change `proposal.md`, `design.md`, and `tasks.md` only when more context is needed
- past change `proposal.md` and `design.md` if a new spec is in conflict with an old one.
Do not treat implementation code as the only source of truth. Requirements belong in OpenSpec changes.
@@ -118,9 +118,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
Assert.Null(match);
}
[Fact]
public async Task MatcherReturnsNullWhenSecondaryValidatorRejectsPrimaryMatch()
{
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known"),
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-1", "unknown")
]);
var validator = new RejectingSpeakerIdentityMatchValidator();
var matcher = CreateMatcher(diarizationClient, validator: validator);
var snippet = CreateWavSnippet();
var match = await matcher.MatchAsync(
new SpeakerIdentityMatchRequest(
"Guest03",
snippet,
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
CancellationToken.None);
Assert.Null(match);
Assert.Equal(1, validator.MatchValidationCount);
}
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(
ISpeakerIdentityDiarizationClient diarizationClient,
TimeSpan? matchTimeout = null)
TimeSpan? matchTimeout = null,
ISpeakerIdentityMatchValidator? validator = null)
{
return new AzureSpeechSpeakerIdentityMatcher(
diarizationClient,
@@ -132,7 +156,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1)
}
}),
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance,
validator ?? new NoopSpeakerIdentityMatchValidator());
}
private static byte[] CreateWavSnippet()
@@ -177,4 +202,22 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
return [];
}
}
private sealed class RejectingSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public int MatchValidationCount { get; private set; }
public Task<bool> ValidateSampleAsync(byte[] wavBytes, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
MatchValidationCount++;
return Task.FromResult(false);
}
}
}
@@ -503,12 +503,42 @@ public sealed class MeetingSummaryToolTests
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteContext("appended");
await tools.WriteContext("inserted", insert: 2);
await tools.WriteContext("replacement", from: 1, to: 1);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("replacement\ninserted\nline two", context);
Assert.Contains("replacement\ninserted\nline two\nappended", context);
}
[Fact]
public async Task ToolsReplaceAssistantContextBodyOnlyWhenRequested()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
line one
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteContext("replacement", replace_file: true);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("replacement", context);
Assert.DoesNotContain("line one", context);
}
[Fact]
@@ -561,7 +591,8 @@ public sealed class MeetingSummaryToolTests
# Assistant Context
## Live Context
""");
""",
replace_file: true);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
@@ -57,14 +57,14 @@ public sealed class ProjectKnowledgeToolTests
Assert.Equal("MeetingAssistant/notes/summary.md", writeResult);
Assert.Equal("# Project Update", await File.ReadAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "summary.md")));
Assert.Equal("IgnoredProject/ignored.md", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
Assert.Equal("changed", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.Equal("alpha should not be searched\nchanged", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.Equal(
"README.md:2 Second alpha line",
await tools.Search("alpha"));
}
[Fact]
public async Task WriteProjectFileSupportsOverwriteReplaceInsertAndCreate()
public async Task WriteProjectFileSupportsAppendReplaceInsertOverwriteAndCreate()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
@@ -88,6 +88,12 @@ public sealed class ProjectKnowledgeToolTests
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "inserted", insert: 2);
Assert.Equal("one\ninserted\nTWO\nTHREE\nfour", await File.ReadAllTextAsync(projectFile));
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "appended");
Assert.Equal("one\ninserted\nTWO\nTHREE\nfour\nappended", await File.ReadAllTextAsync(projectFile));
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "replacement", replace_file: true);
Assert.Equal("replacement", await File.ReadAllTextAsync(projectFile));
await tools.WriteProjectFile("MeetingAssistant", "created/new.md", "created content");
Assert.Equal("created content", await File.ReadAllTextAsync(Path.Combine(projectRoot, "created", "new.md")));
}
@@ -112,6 +118,7 @@ public sealed class ProjectKnowledgeToolTests
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "../outside.md", "content"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, insert: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true));
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
@@ -0,0 +1,123 @@
using MeetingAssistant;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class PyannoteDiarizationWarmupHostedServiceTests
{
[Fact]
public async Task HostedServiceWarmsEnabledValidationRuntimeWithoutBlockingStartup()
{
var commandRunner = new BlockingCommandRunner();
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
var service = new PyannoteDiarizationWarmupHostedService(
finalizer,
new FakeLaunchProfileOptionsProvider(new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" },
SpeakerIdentification =
{
PyannoteValidation =
{
Enabled = true,
Diarization =
{
Enabled = true,
DockerCommand = "docker",
Image = "meeting-assistant-pyannote-validation:local",
ModelsFolder = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-tests",
Guid.NewGuid().ToString("N"),
"models"),
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
}
}
}
}),
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
await commandRunner.WaitForRunAsync();
await service.StopAsync(CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-validation:local"));
Assert.True(commandRunner.RunCancellationWasObserved);
}
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
{
private readonly MeetingAssistantOptions options;
public FakeLaunchProfileOptionsProvider(MeetingAssistantOptions options)
{
this.options = options;
}
public LaunchProfile GetRequiredProfile(string? name)
{
return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
}
public IReadOnlyList<LaunchProfile> GetProfiles()
{
return [GetRequiredProfile(null)];
}
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
{
return [];
}
}
private sealed class BlockingCommandRunner : ICommandRunner
{
private readonly TaskCompletionSource runStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
public bool RunCancellationWasObserved { get; private set; }
public Task WaitForRunAsync()
{
return runStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public async Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
if (arguments.Contains("inspect"))
{
return new CommandResult(0, "image-id", "");
}
runStarted.TrySetResult();
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
RunCancellationWasObserved = true;
throw;
}
return new CommandResult(0, "", "");
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
}
@@ -0,0 +1,137 @@
using MeetingAssistant;
using MeetingAssistant.Recording;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
public sealed class PyannoteSpeakerIdentityMatchValidatorTests
{
[Fact]
public async Task ValidatorRejectsSampleWhenPyannoteReportsMultipleSpeakers()
{
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":10.0,"speaker":"SPEAKER_00"},{"start":10.0,"end":20.0,"speaker":"SPEAKER_01"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var validator = CreateValidator(commandRunner);
var valid = await validator.ValidateSampleAsync(CreateWav(TimeSpan.FromSeconds(20)), CancellationToken.None);
Assert.False(valid);
}
[Fact]
public async Task ValidatorAcceptsMatchWhenPyannoteAssignsKnownAndUnknownSamplesToSameSpeaker()
{
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"},{"start":2.0,"end":3.0,"speaker":"SPEAKER_00"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var validator = CreateValidator(commandRunner);
var wav = CreateWav(TimeSpan.FromSeconds(1));
var valid = await validator.ValidateMatchAsync(
new SpeakerIdentityMatchValidationRequest("Guest03", 42, wav, [wav]),
CancellationToken.None);
Assert.True(valid);
}
[Fact]
public async Task DisabledValidatorDoesNotRunPyannote()
{
var commandRunner = new CapturingCommandRunner("");
var validator = CreateValidator(commandRunner, enabled: false);
var valid = await validator.ValidateSampleAsync(CreateWav(TimeSpan.FromSeconds(1)), CancellationToken.None);
Assert.True(valid);
Assert.Empty(commandRunner.Commands);
}
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
CapturingCommandRunner commandRunner,
bool enabled = true)
{
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
return new PyannoteSpeakerIdentityMatchValidator(
finalizer,
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
PyannoteValidation = new SpeakerIdentityPyannoteValidationOptions
{
Enabled = enabled,
MinimumSingleSpeakerCoverage = 0.90,
MinimumMatchingKnownSnippetRatio = 1,
Diarization = new PyannoteDiarizationOptions
{
Enabled = true,
BuildImage = false,
DockerCommand = "docker",
Image = "meeting-assistant-pyannote:local",
ModelsFolder = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-tests",
Guid.NewGuid().ToString("N"),
"models"),
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1),
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
}
}
}
}),
NullLogger<PyannoteSpeakerIdentityMatchValidator>.Instance);
}
private static byte[] CreateWav(TimeSpan duration)
{
const int sampleRate = 16000;
using var stream = new MemoryStream();
using (var writer = new WaveFileWriter(stream, new WaveFormat(sampleRate, 16, 1)))
{
var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * sizeof(short))];
writer.Write(bytes, 0, bytes.Length);
}
return stream.ToArray();
}
private sealed class CapturingCommandRunner : ICommandRunner
{
private readonly string output;
public CapturingCommandRunner(string output)
{
this.output = output;
}
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
public Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
return Task.FromResult(new CommandResult(0, output, ""));
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
}
@@ -181,6 +181,54 @@ public sealed class PyannoteTranscriptFinalizerTests
Assert.Equal("hf_azure", commandRunner.Environment["HF_TOKEN"]);
}
[Fact]
public async Task WarmUpBuildsImageAndDownloadsConfiguredModelWithoutAudioInput()
{
var commandRunner = new CapturingCommandRunner("");
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
var diarization = new PyannoteDiarizationOptions
{
Enabled = true,
DockerCommand = "docker",
BaseImage = "python:3.11-slim",
Image = "meeting-assistant-pyannote-warmup:local",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models"),
Model = "pyannote/speaker-diarization-3.1",
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
};
await finalizer.WarmUpAsync(diarization, CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("inspect"));
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("build"));
var runCommand = commandRunner.Commands.Last();
Assert.Contains("run", runCommand.Arguments);
Assert.Contains("meeting-assistant-pyannote-warmup:local", runCommand.Arguments);
Assert.DoesNotContain(runCommand.Arguments, argument => argument.Contains("/workspace/input.wav", StringComparison.Ordinal));
Assert.Contains(runCommand.Arguments, argument => argument.Contains("Pipeline.from_pretrained", StringComparison.Ordinal));
Assert.Contains(runCommand.Arguments, argument => argument.Contains("pyannote/speaker-diarization-3.1", StringComparison.Ordinal));
Assert.Equal("hf_test", commandRunner.Environment["HF_TOKEN"]);
}
[Fact]
public async Task WarmUpSkipsWhenTokenIsMissing()
{
var commandRunner = new CapturingCommandRunner("");
var finalizer = CreateFinalizer(commandRunner, token: null);
await finalizer.WarmUpAsync(new PyannoteDiarizationOptions
{
Enabled = true,
Token = "",
TokenEnv = "",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models")
}, CancellationToken.None);
Assert.Empty(commandRunner.Commands);
}
[Fact]
public async Task FinalizerSkipsPyannoteWhenTokenIsMissing()
{
@@ -984,7 +984,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(10)
Interval = TimeSpan.FromMilliseconds(10),
MinimumSampleSpeechDuration = TimeSpan.Zero
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1031,7 +1032,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(10)
Interval = TimeSpan.FromMilliseconds(10),
MinimumSampleSpeechDuration = TimeSpan.Zero
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1066,7 +1068,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(20)
Interval = TimeSpan.FromMilliseconds(20),
MinimumSampleSpeechDuration = TimeSpan.Zero
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1103,7 +1106,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(20)
Interval = TimeSpan.FromMilliseconds(20),
MinimumSampleSpeechDuration = TimeSpan.Zero
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1672,6 +1676,7 @@ public sealed class RecordingCoordinatorTests
values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true";
values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00";
values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050";
values["MeetingAssistant:SpeakerIdentification:MinimumSampleSpeechDuration"] = "00:00:00";
}
var configuration = new ConfigurationBuilder()
@@ -1708,7 +1713,7 @@ public sealed class RecordingCoordinatorTests
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (segments.Any(segment => segment.Text?.Contains(text, StringComparison.Ordinal) == true))
if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true))
{
return;
}
@@ -0,0 +1,76 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
public sealed class SpeakerAudioSampleCollectorTests
{
[Fact]
public void CollectorWaitsForConfiguredUninterruptedSpeechDuration()
{
var collector = new SpeakerAudioSampleCollector(
TimeSpan.FromMinutes(2),
maxSamplesPerSpeaker: 3,
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
maximumSegmentGap: TimeSpan.FromSeconds(1));
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35)));
collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five."));
collector.TryAdd(Segment(12, 24, "Guest01", "six seven eight nine ten."));
Assert.Empty(collector.Snapshot());
collector.TryAdd(Segment(24, 31, "Guest01", "eleven twelve thirteen fourteen fifteen."));
var sample = Assert.Single(collector.Snapshot());
Assert.Equal("Guest01", sample.Speaker);
Assert.Equal(TimeSpan.Zero, sample.Segment.Start);
Assert.Equal(TimeSpan.FromSeconds(31), sample.Segment.End);
Assert.True(ReadDuration(sample.WavBytes) >= TimeSpan.FromSeconds(30));
}
[Fact]
public void CollectorDoesNotCombineSpeechAcrossDifferentSpeakerInterruption()
{
var collector = new SpeakerAudioSampleCollector(
TimeSpan.FromMinutes(2),
maxSamplesPerSpeaker: 3,
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
maximumSegmentGap: TimeSpan.FromSeconds(1));
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(50)));
collector.TryAdd(Segment(0, 20, "Guest01", "one two three four five."));
collector.TryAdd(Segment(20, 22, "Guest02", "interrupting now."));
collector.TryAdd(Segment(22, 35, "Guest01", "six seven eight nine ten."));
Assert.DoesNotContain(collector.Snapshot(), sample => sample.Speaker == "Guest01");
}
private static TranscriptionSegment Segment(
double start,
double end,
string speaker,
string text)
{
return new TranscriptionSegment(
TimeSpan.FromSeconds(start),
TimeSpan.FromSeconds(end),
speaker,
text);
}
private static AudioChunk CreateAudio(TimeSpan duration)
{
const int sampleRate = 16000;
const int channels = 1;
var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * channels * sizeof(short))];
return new AudioChunk(bytes, sampleRate, channels);
}
private static TimeSpan ReadDuration(byte[] wavBytes)
{
using var reader = new WaveFileReader(new MemoryStream(wavBytes));
return reader.TotalTime;
}
}
@@ -199,6 +199,60 @@ public sealed class SpeakerIdentityServiceTests
Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath));
}
[Fact]
public async Task UnmatchedSpeakerIsNotLearnedWhenSecondaryValidationRejectsSample()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
fixture.MatchValidator.SampleIsValid = false;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Manuel"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]),
CancellationToken.None);
Assert.Empty(fixture.Context.SpeakerIdentities);
}
[Fact]
public async Task FinishedSnippetExtractionWaitsForMinimumContinuousSpeechDuration()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options =>
options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30));
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Manuel"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "short")]),
CancellationToken.None);
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
Assert.Empty(fixture.Context.SpeakerIdentities);
}
[Fact]
public async Task FinishedSnippetExtractionDoesNotCombineAcrossSpeakerInterruption()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options =>
options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30));
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Manuel"],
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "first"),
new TranscriptionSegment(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(22), "Guest02", "interrupting"),
new TranscriptionSegment(TimeSpan.FromSeconds(22), TimeSpan.FromSeconds(35), "Guest01", "second")
]),
CancellationToken.None);
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
Assert.Empty(fixture.Context.SpeakerIdentities);
}
[Fact]
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
{
@@ -450,6 +504,87 @@ public sealed class SpeakerIdentityServiceTests
File.Delete(dbPath);
}
[Fact]
public async Task SpeakerOverrideCreatesIdentityWhenLegacyTranscriptionCountHasNoDatabaseDefault()
{
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerIdentities" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentities" PRIMARY KEY AUTOINCREMENT,
"CanonicalName" TEXT NULL,
"TranscriptionCount" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerCandidateNames" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerCandidateNames" PRIMARY KEY AUTOINCREMENT,
"SpeakerIdentityId" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
CONSTRAINT "FK_SpeakerCandidateNames_SpeakerIdentities_SpeakerIdentityId"
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
);
""");
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerSnippets" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerSnippets" PRIMARY KEY AUTOINCREMENT,
"SpeakerIdentityId" INTEGER NOT NULL,
"WavBytes" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
CONSTRAINT "FK_SpeakerSnippets_SpeakerIdentities_SpeakerIdentityId"
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
);
""");
var options = new SpeakerIdentificationOptions
{
DatabasePath = dbPath,
MinimumSampleSpeechDuration = TimeSpan.Zero
};
var service = new SpeakerIdentityService(
new TestSpeakerIdentityDbContextFactory(dbPath),
new FakeSpeakerSnippetExtractor(),
new FakeSpeakerIdentityMatcher(),
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance,
new FakeSpeakerIdentityMatchValidator());
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Daniel");
var root = Path.GetDirectoryName(dbPath)!;
var transcriptPath = Path.Combine(root, "transcript.md");
await File.WriteAllTextAsync(transcriptPath, "Transcript");
var request = new SpeakerIdentificationRequest(
"test.wav",
new MeetingNote(
Path.Combine(root, "meeting.md"),
new MeetingNoteFrontmatter
{
Title = "Test Meeting",
StartTime = DateTimeOffset.Parse("2026-05-28T10:00:00+02:00"),
Attendees = ["Hecht, Daniel"],
Transcript = transcriptPath,
AssistantContext = Path.Combine(root, "context.md"),
Summary = Path.Combine(root, "summary.md")
},
""),
[segment],
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Hecht, Daniel", CancellationToken.None);
context.ChangeTracker.Clear();
var saved = await context.SpeakerIdentities.SingleAsync();
Assert.Equal("Hecht, Daniel", saved.CanonicalName);
Assert.Equal(0, saved.TranscriptionCount);
File.Delete(dbPath);
}
private sealed class SpeakerIdentityFixture : IAsyncDisposable
{
private readonly string tempDirectory;
@@ -472,6 +607,8 @@ public sealed class SpeakerIdentityServiceTests
public FakeSpeakerIdentityMatcher Matcher { get; } = new();
public FakeSpeakerIdentityMatchValidator MatchValidator { get; } = new();
public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new();
public static async Task<SpeakerIdentityFixture> CreateAsync(
@@ -484,7 +621,8 @@ public sealed class SpeakerIdentityServiceTests
{
DatabasePath = dbPath,
MaxSnippetsPerSpeaker = 3,
MatchBatchSize = 6
MatchBatchSize = 6,
MinimumSampleSpeechDuration = TimeSpan.Zero
};
configureOptions?.Invoke(options);
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
@@ -501,7 +639,8 @@ public sealed class SpeakerIdentityServiceTests
SnippetExtractor,
Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance);
NullLogger<SpeakerIdentityService>.Instance,
MatchValidator);
}
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
@@ -627,6 +766,11 @@ public sealed class SpeakerIdentityServiceTests
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken)
{
if (speakerSegments.Count == 0)
{
return Task.FromResult<byte[]>([]);
}
ExtractCalls++;
return Task.FromResult<byte[]>([7, 8, 9]);
}
@@ -657,4 +801,23 @@ public sealed class SpeakerIdentityServiceTests
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
}
}
private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public bool SampleIsValid { get; set; } = true;
public Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
return Task.FromResult(SampleIsValid);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
}
}
@@ -1,3 +1,5 @@
using MeetingAssistant;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
@@ -27,18 +29,118 @@ public sealed class SpeechRecognitionPipelineHostedServiceTests
Assert.True(pipeline.Disposed);
}
[Fact]
public async Task HostedServiceWarmsConfiguredLaunchProfilePipelinesWithoutBlockingStartup()
{
var defaultPipeline = new CapturingSpeechRecognitionPipeline();
var funAsrPipeline = new CapturingSpeechRecognitionPipeline { BlockReadinessUntilCancelled = true };
var englishPipeline = new CapturingSpeechRecognitionPipeline();
var service = new SpeechRecognitionPipelineHostedService(
new CapturingSpeechRecognitionPipelineFactory(new Dictionary<string, ISpeechRecognitionPipeline>
{
[ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = defaultPipeline,
["funasr"] = funAsrPipeline,
["english"] = englishPipeline
}),
new FakeLaunchProfileOptionsProvider(
[
new LaunchProfile(
ConfigurationLaunchProfileOptionsProvider.DefaultProfileName,
new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" }
}),
new LaunchProfile(
"funasr",
new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "funasr" }
}),
new LaunchProfile(
"english",
new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" }
})
]),
NullLogger<SpeechRecognitionPipelineHostedService>.Instance);
var startTask = service.StartAsync(CancellationToken.None);
await startTask.WaitAsync(TimeSpan.FromSeconds(1));
await defaultPipeline.WaitForInitializeAsync();
await defaultPipeline.WaitForReadinessAsync();
await funAsrPipeline.WaitForInitializeAsync();
await funAsrPipeline.WaitForReadinessAsync();
await service.StopAsync(CancellationToken.None);
Assert.Equal(1, defaultPipeline.InitializeCount);
Assert.Equal(1, funAsrPipeline.InitializeCount);
Assert.True(defaultPipeline.Disposed);
Assert.True(funAsrPipeline.Disposed);
Assert.True(funAsrPipeline.ReadinessCancellationWasObserved);
Assert.Equal(0, englishPipeline.InitializeCount);
Assert.False(englishPipeline.Disposed);
}
private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly ISpeechRecognitionPipeline pipeline;
private readonly IReadOnlyDictionary<string, ISpeechRecognitionPipeline> pipelines;
public CapturingSpeechRecognitionPipelineFactory(ISpeechRecognitionPipeline pipeline)
: this(new Dictionary<string, ISpeechRecognitionPipeline>
{
[ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = pipeline
})
{
this.pipeline = pipeline;
}
public CapturingSpeechRecognitionPipelineFactory(
IReadOnlyDictionary<string, ISpeechRecognitionPipeline> pipelines)
{
this.pipelines = pipelines;
}
public ISpeechRecognitionPipeline Create()
{
return pipeline;
return Create(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
var profileName = string.IsNullOrWhiteSpace(launchProfileName)
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
: launchProfileName;
return pipelines[profileName];
}
}
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
{
private readonly IReadOnlyList<LaunchProfile> profiles;
public FakeLaunchProfileOptionsProvider(IReadOnlyList<LaunchProfile> profiles)
{
this.profiles = profiles;
}
public LaunchProfile GetRequiredProfile(string? name)
{
var profileName = string.IsNullOrWhiteSpace(name)
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
: name;
return profiles.Single(profile => profile.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<LaunchProfile> GetProfiles()
{
return profiles;
}
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
{
return [];
}
}
@@ -82,6 +82,46 @@ public sealed class WorkflowRulesEditorTests
Assert.DoesNotContain("other.yaml", searchResult);
}
[Fact]
public async Task RulesEditorToolsAppendByDefaultAndReplaceOnlyWhenRequested()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var rulesPath = Path.Combine(root, "rules.yaml");
await File.WriteAllTextAsync(
rulesPath,
"""
rules:
- name: existing
on:
- created: {}
steps:
- uses: add_attendee
value: Ada
""");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Automation = new AutomationOptions { RulesPath = rulesPath }
});
await tools.WriteRules("""
- name: appended
on:
- created: {}
steps:
- uses: add_attendee
value: Grace
""");
var appended = await File.ReadAllTextAsync(rulesPath);
Assert.Contains("name: existing", appended);
Assert.Contains("name: appended", appended);
await tools.WriteRules("rules: []", replace_file: true);
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
}
[Fact]
public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile()
{
@@ -94,7 +134,7 @@ public sealed class WorkflowRulesEditorTests
Automation = new AutomationOptions { RulesPath = rulesPath }
});
var result = await tools.WriteRules("rules:\n- name: [");
var result = await tools.WriteRules("rules:\n- name: [", replace_file: true);
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
+23 -1
View File
@@ -181,7 +181,7 @@ public sealed class AzureSpeechOptions
public string KeyEnv { get; set; } = "AZURE_SPEECH_KEY";
public TimeSpan RecognitionStopTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan RecognitionStopTimeout { get; set; } = TimeSpan.FromMinutes(3);
public bool DiarizeIntermediateResults { get; set; } = true;
@@ -293,6 +293,10 @@ public sealed class SpeakerIdentificationOptions
public int MaxSnippetsPerSpeaker { get; set; } = 3;
public TimeSpan MinimumSampleSpeechDuration { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan MaximumSampleSegmentGap { get; set; } = TimeSpan.FromSeconds(1);
public double SilenceBetweenSnippetsSeconds { get; set; } = 1;
public TimeSpan LiveSampleBufferDuration { get; set; } = TimeSpan.FromMinutes(10);
@@ -302,6 +306,24 @@ public sealed class SpeakerIdentificationOptions
public TimeSpan MatchTimeout { get; set; } = TimeSpan.FromMinutes(3);
public AzureSpeechOptions AzureSpeech { get; set; } = new();
public SpeakerIdentityPyannoteValidationOptions PyannoteValidation { get; set; } = new();
}
public sealed class SpeakerIdentityPyannoteValidationOptions
{
public bool Enabled { get; set; }
public double MinimumSingleSpeakerCoverage { get; set; } = 0.90;
public double MinimumMatchingKnownSnippetRatio { get; set; } = 0.50;
public PyannoteDiarizationOptions Diarization { get; set; } = new()
{
Enabled = true,
CommandTimeout = TimeSpan.FromHours(1),
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
};
}
public sealed class AgentOptions
+2
View File
@@ -50,6 +50,7 @@ builder.Services.AddDbContextFactory<SpeakerIdentityDbContext>((services, dbOpti
});
builder.Services.AddSingleton<ISpeakerSnippetExtractor, WavSpeakerSnippetExtractor>();
builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpeakerIdentityDiarizationClient>();
builder.Services.AddSingleton<ISpeakerIdentityMatchValidator, PyannoteSpeakerIdentityMatchValidator>();
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
@@ -88,6 +89,7 @@ builder.Services.AddSingleton<MeetingRecordingCoordinator>();
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
builder.Services.AddHostedService<PyannoteDiarizationWarmupHostedService>();
#if WINDOWS
builder.Services.AddHostedService<GlobalHotkeyService>();
builder.Services.AddHostedService<UnoTaskbarIconService>();
@@ -1334,7 +1334,11 @@ public sealed class MeetingRecordingCoordinator
Options = options;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
speakerSampleCollector = new SpeakerAudioSampleCollector(
liveSampleBufferDuration,
maxSpeakerSamples,
options.SpeakerIdentification.MinimumSampleSpeechDuration,
options.SpeakerIdentification.MaximumSampleSegmentGap);
}
public CancellationTokenSource CaptureCancellationSource { get; }
@@ -9,11 +9,33 @@ internal sealed class SpeakerAudioSampleCollector
private readonly RollingAudioBuffer audioBuffer;
private readonly Dictionary<string, List<SpeakerAudioSample>> samplesBySpeaker = new(StringComparer.OrdinalIgnoreCase);
private readonly int maxSamplesPerSpeaker;
private readonly TimeSpan minimumUninterruptedSpeechDuration;
private readonly TimeSpan maximumSegmentGap;
private PendingSpeakerSpan? pendingSpan;
public SpeakerAudioSampleCollector(TimeSpan bufferDuration, int maxSamplesPerSpeaker)
: this(
bufferDuration,
maxSamplesPerSpeaker,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(1))
{
}
public SpeakerAudioSampleCollector(
TimeSpan bufferDuration,
int maxSamplesPerSpeaker,
TimeSpan minimumUninterruptedSpeechDuration,
TimeSpan maximumSegmentGap)
{
audioBuffer = new RollingAudioBuffer(bufferDuration);
this.maxSamplesPerSpeaker = Math.Max(1, maxSamplesPerSpeaker);
this.minimumUninterruptedSpeechDuration = minimumUninterruptedSpeechDuration > TimeSpan.Zero
? minimumUninterruptedSpeechDuration
: TimeSpan.Zero;
this.maximumSegmentGap = maximumSegmentGap >= TimeSpan.Zero
? maximumSegmentGap
: TimeSpan.Zero;
}
public void AppendAudio(AudioChunk chunk)
@@ -26,6 +48,7 @@ internal sealed class SpeakerAudioSampleCollector
lock (gate)
{
samplesBySpeaker.Clear();
pendingSpan = null;
audioBuffer.Reset();
}
}
@@ -37,25 +60,31 @@ internal sealed class SpeakerAudioSampleCollector
return;
}
var score = Score(segment);
TranscriptionSegment sampleSegment;
lock (gate)
{
sampleSegment = ExtendPendingSpan(segment);
}
var score = Score(sampleSegment, minimumUninterruptedSpeechDuration);
if (score <= 0)
{
return;
}
var wavBytes = audioBuffer.TryExtractWav(segment.Start, segment.End);
var wavBytes = audioBuffer.TryExtractWav(sampleSegment.Start, sampleSegment.End);
if (wavBytes.Length == 0)
{
return;
}
var sample = new SpeakerAudioSample(segment.Speaker, segment, wavBytes, score);
var sample = new SpeakerAudioSample(sampleSegment.Speaker, sampleSegment, wavBytes, score);
lock (gate)
{
if (!samplesBySpeaker.TryGetValue(segment.Speaker, out var samples))
if (!samplesBySpeaker.TryGetValue(sampleSegment.Speaker, out var samples))
{
samples = [];
samplesBySpeaker[segment.Speaker] = samples;
samplesBySpeaker[sampleSegment.Speaker] = samples;
}
samples.Add(sample);
@@ -86,10 +115,29 @@ internal sealed class SpeakerAudioSampleCollector
!string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase);
}
private static double Score(TranscriptionSegment segment)
private TranscriptionSegment ExtendPendingSpan(TranscriptionSegment segment)
{
if (pendingSpan is null ||
!SpeakerSampleSpanSelector.CanExtend(pendingSpan.Speaker, pendingSpan.End, segment, maximumSegmentGap))
{
pendingSpan = new PendingSpeakerSpan(
segment.Speaker,
segment.Start,
segment.End,
[segment.Text]);
return pendingSpan.ToSegment();
}
pendingSpan = pendingSpan.Extend(segment);
return pendingSpan.ToSegment();
}
private static double Score(
TranscriptionSegment segment,
TimeSpan minimumUninterruptedSpeechDuration)
{
var durationSeconds = (segment.End - segment.Start).TotalSeconds;
if (durationSeconds < 2 || durationSeconds > 30)
if (durationSeconds < minimumUninterruptedSpeechDuration.TotalSeconds)
{
return 0;
}
@@ -97,13 +145,13 @@ internal sealed class SpeakerAudioSampleCollector
var words = segment.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Length;
if (words < 3 || words > 80)
if (words < 3)
{
return 0;
}
var durationScore = 1 - Math.Min(Math.Abs(durationSeconds - 8) / 8, 1);
var wordScore = 1 - Math.Min(Math.Abs(words - 18) / 18.0, 1);
var durationScore = Math.Min(durationSeconds / Math.Max(1, minimumUninterruptedSpeechDuration.TotalSeconds), 2);
var wordScore = Math.Min(words / 60.0, 1);
var sentenceBonus = segment.Text.TrimEnd().EndsWith('.') ||
segment.Text.TrimEnd().EndsWith('?') ||
segment.Text.TrimEnd().EndsWith('!')
@@ -111,4 +159,29 @@ internal sealed class SpeakerAudioSampleCollector
: 0;
return durationScore * 70 + wordScore * 30 + sentenceBonus;
}
private sealed record PendingSpeakerSpan(
string Speaker,
TimeSpan Start,
TimeSpan End,
IReadOnlyList<string> TextParts)
{
public PendingSpeakerSpan Extend(TranscriptionSegment segment)
{
return this with
{
End = segment.End > End ? segment.End : End,
TextParts = TextParts.Append(segment.Text).ToList()
};
}
public TranscriptionSegment ToSegment()
{
return new TranscriptionSegment(
Start,
End,
Speaker,
string.Join(' ', TextParts.Where(part => !string.IsNullOrWhiteSpace(part))));
}
}
}
@@ -15,15 +15,18 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
private readonly ISpeakerIdentityDiarizationClient diarizationClient;
private readonly SpeakerIdentificationOptions options;
private readonly ILogger<AzureSpeechSpeakerIdentityMatcher> logger;
private readonly ISpeakerIdentityMatchValidator matchValidator;
public AzureSpeechSpeakerIdentityMatcher(
ISpeakerIdentityDiarizationClient diarizationClient,
Microsoft.Extensions.Options.IOptions<MeetingAssistantOptions> options,
ILogger<AzureSpeechSpeakerIdentityMatcher> logger)
ILogger<AzureSpeechSpeakerIdentityMatcher> logger,
ISpeakerIdentityMatchValidator matchValidator)
{
this.diarizationClient = diarizationClient;
this.options = options.Value.SpeakerIdentification;
this.logger = logger;
this.matchValidator = matchValidator;
}
public async Task<SpeakerIdentityMatch?> MatchAsync(
@@ -35,6 +38,14 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
return null;
}
if (!await matchValidator.ValidateSampleAsync(request.UnknownSnippet, cancellationToken))
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} skipped because secondary validation rejected the unknown sample",
request.DiarizedSpeaker);
return null;
}
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} against {CandidateCount} candidate(s)",
request.DiarizedSpeaker,
@@ -84,6 +95,20 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
var requiredMatches = known.Count() > 1 ? 2 : 1;
if (matchingSnippetCount >= requiredMatches)
{
var validationRequest = new SpeakerIdentityMatchValidationRequest(
request.DiarizedSpeaker,
known.Key,
request.UnknownSnippet,
known.Select(segment => segment.Snippet).ToList());
if (!await matchValidator.ValidateMatchAsync(validationRequest, cancellationToken))
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} rejected identity {IdentityId} after secondary validation",
request.DiarizedSpeaker,
known.Key);
continue;
}
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} matched identity {IdentityId}",
request.DiarizedSpeaker,
@@ -99,7 +124,7 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
}
finally
{
TryDelete(tempPath);
SpeakerCompositeWav.TryDelete(tempPath);
}
}
@@ -130,7 +155,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
{
using var firstReader = OpenFirstReadableWave(request);
using var firstReader = SpeakerCompositeWav.OpenFirstReadableWave(
request.Candidates.SelectMany(candidate => candidate.Snippets).Append(request.UnknownSnippet));
if (firstReader is null)
{
return new CompositeLayout(null, []);
@@ -145,118 +171,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{
foreach (var snippet in candidate.Snippets.Where(storedSnippet => storedSnippet.Length > 0))
{
var segment = AppendSnippet(writer, firstReader.WaveFormat, snippet, current);
var segment = SpeakerCompositeWav.AppendSnippet(writer, firstReader.WaveFormat, snippet, current);
if (segment is null)
{
continue;
}
knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value));
knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value, snippet));
current = segment.Value.End + silence;
WriteSilence(writer, firstReader.WaveFormat, silence);
SpeakerCompositeWav.WriteSilence(writer, firstReader.WaveFormat, silence);
}
}
var unknownSegment = AppendSnippet(writer, firstReader.WaveFormat, request.UnknownSnippet, current);
var unknownSegment = SpeakerCompositeWav.AppendSnippet(
writer,
firstReader.WaveFormat,
request.UnknownSnippet,
current);
return new CompositeLayout(unknownSegment, knownSegments);
}
private static WaveFileReader? OpenFirstReadableWave(SpeakerIdentityMatchRequest request)
{
foreach (var bytes in request.Candidates.SelectMany(candidate => candidate.Snippets).Append(request.UnknownSnippet))
{
if (bytes.Length == 0)
{
continue;
}
try
{
return new WaveFileReader(new MemoryStream(bytes));
}
catch (FormatException)
{
}
}
return null;
}
private static TimeSegment? AppendSnippet(
WaveFileWriter writer,
WaveFormat targetFormat,
byte[] snippet,
TimeSpan start)
{
if (snippet.Length == 0)
{
return null;
}
using var reader = new WaveFileReader(new MemoryStream(snippet));
if (!WaveFormatsMatch(reader.WaveFormat, targetFormat))
{
throw new InvalidDataException("Speaker identity snippets must use the same WAV format.");
}
reader.CopyTo(writer);
return new TimeSegment(start, start + reader.TotalTime);
}
private static void WriteSilence(WaveFileWriter writer, WaveFormat format, TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
var bytes = new byte[(int)(format.AverageBytesPerSecond * duration.TotalSeconds)];
writer.Write(bytes, 0, bytes.Length);
}
private static string? FindBestSpeaker(TimeSegment segment, IReadOnlyList<TranscriptionSegment> segments)
{
var bestOverlap = 0d;
string? bestSpeaker = null;
foreach (var transcriptSegment in segments)
{
var overlap = Math.Min(segment.End.TotalSeconds, transcriptSegment.End.TotalSeconds)
- Math.Max(segment.Start.TotalSeconds, transcriptSegment.Start.TotalSeconds);
if (overlap > bestOverlap)
{
bestOverlap = overlap;
bestSpeaker = transcriptSegment.Speaker;
}
}
return bestSpeaker;
}
private static bool WaveFormatsMatch(WaveFormat left, WaveFormat right)
{
return left.Encoding == right.Encoding
&& left.SampleRate == right.SampleRate
&& left.Channels == right.Channels
&& left.BitsPerSample == right.BitsPerSample;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
}
return SpeakerCompositeWav.FindBestSpeaker(segment, segments);
}
private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList<KnownCompositeSegment> KnownSegments);
private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment);
private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment, byte[] Snippet);
private readonly record struct TimeSegment(TimeSpan Start, TimeSpan End);
}
@@ -0,0 +1,18 @@
namespace MeetingAssistant.Speakers;
public sealed class NoopSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
}
@@ -0,0 +1,237 @@
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Options;
using NAudio.Wave;
namespace MeetingAssistant.Speakers;
public sealed class PyannoteSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
private readonly PyannoteTranscriptFinalizer finalizer;
private readonly SpeakerIdentityPyannoteValidationOptions options;
private readonly ILogger<PyannoteSpeakerIdentityMatchValidator> logger;
public PyannoteSpeakerIdentityMatchValidator(
PyannoteTranscriptFinalizer finalizer,
IOptions<MeetingAssistantOptions> options,
ILogger<PyannoteSpeakerIdentityMatchValidator> logger)
{
this.finalizer = finalizer;
this.options = options.Value.SpeakerIdentification.PyannoteValidation;
this.logger = logger;
}
public async Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return true;
}
var segments = await DiarizeBytesAsync(wavBytes, cancellationToken);
var result = HasSingleSpeaker(segments);
if (!result)
{
logger.LogInformation(
"pyannote rejected speaker identity sample because it did not contain one dominant speaker");
}
return result;
}
public async Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return true;
}
var tempPath = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-speaker-match-pyannote",
$"{Guid.NewGuid():N}.wav");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!);
var layout = WriteCompositeWav(
tempPath,
request.KnownSnippets,
request.UnknownSnippet,
TimeSpan.FromSeconds(1));
if (layout.UnknownSegment is null || layout.KnownSegments.Count == 0)
{
return false;
}
var segments = await DiarizePathAsync(tempPath, cancellationToken);
var unknownSpeaker = FindBestSpeaker(layout.UnknownSegment.Value, segments);
if (string.IsNullOrWhiteSpace(unknownSpeaker))
{
logger.LogInformation(
"pyannote rejected speaker identity match for {DiarizedSpeaker} because the unknown sample had no speaker",
request.DiarizedSpeaker);
return false;
}
var compared = 0;
var matching = 0;
foreach (var knownSegment in layout.KnownSegments)
{
var knownSpeaker = FindBestSpeaker(knownSegment, segments);
if (string.IsNullOrWhiteSpace(knownSpeaker))
{
continue;
}
compared++;
if (string.Equals(knownSpeaker, unknownSpeaker, StringComparison.Ordinal))
{
matching++;
}
}
var minimumRatio = Math.Clamp(options.MinimumMatchingKnownSnippetRatio, 0, 1);
var accepted = compared > 0 && (double)matching / compared >= minimumRatio;
if (!accepted)
{
logger.LogInformation(
"pyannote rejected speaker identity match for {DiarizedSpeaker} and identity {IdentityId}: {Matching}/{Compared} known sample(s) matched",
request.DiarizedSpeaker,
request.IdentityId,
matching,
compared);
}
return accepted;
}
finally
{
SpeakerCompositeWav.TryDelete(tempPath);
}
}
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizeBytesAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
var tempPath = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-speaker-sample-pyannote",
$"{Guid.NewGuid():N}.wav");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!);
await File.WriteAllBytesAsync(tempPath, wavBytes, cancellationToken);
return await DiarizePathAsync(tempPath, cancellationToken);
}
finally
{
SpeakerCompositeWav.TryDelete(tempPath);
}
}
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizePathAsync(
string wavPath,
CancellationToken cancellationToken)
{
TimeSpan duration;
try
{
using var reader = new WaveFileReader(wavPath);
duration = reader.TotalTime;
}
catch (Exception exception) when (exception is IOException or InvalidDataException or FormatException)
{
logger.LogInformation(exception, "pyannote speaker identity validation could not read WAV input");
return [];
}
if (duration <= TimeSpan.Zero)
{
return [];
}
return await finalizer.FinalizeAsync(
wavPath,
[new TranscriptionSegment(TimeSpan.Zero, duration, "Unknown", "speaker identity validation sample")],
options.Diarization,
SpeechRecognitionPipelineOptions.Default,
cancellationToken);
}
private bool HasSingleSpeaker(IReadOnlyList<TranscriptionSegment> segments)
{
var durationsBySpeaker = segments
.Where(segment => !string.IsNullOrWhiteSpace(segment.Speaker) && segment.End > segment.Start)
.GroupBy(segment => segment.Speaker)
.Select(group => new
{
Speaker = group.Key,
Duration = group.Sum(segment => (segment.End - segment.Start).TotalSeconds)
})
.Where(group => group.Duration > 0)
.ToList();
if (durationsBySpeaker.Count == 0)
{
return false;
}
if (durationsBySpeaker.Count == 1)
{
return true;
}
var total = durationsBySpeaker.Sum(group => group.Duration);
var dominant = durationsBySpeaker.Max(group => group.Duration);
return total > 0 && dominant / total >= Math.Clamp(options.MinimumSingleSpeakerCoverage, 0, 1);
}
private CompositeLayout WriteCompositeWav(
string path,
IReadOnlyList<byte[]> knownSnippets,
byte[] unknownSnippet,
TimeSpan silence)
{
using var firstReader = SpeakerCompositeWav.OpenFirstReadableWave(knownSnippets.Append(unknownSnippet));
if (firstReader is null)
{
return new CompositeLayout(null, []);
}
using var writer = new WaveFileWriter(path, firstReader.WaveFormat);
var current = TimeSpan.Zero;
var knownSegments = new List<TimeSegment>();
foreach (var snippet in knownSnippets.Where(storedSnippet => storedSnippet.Length > 0))
{
var segment = SpeakerCompositeWav.AppendSnippet(writer, firstReader.WaveFormat, snippet, current);
if (segment is null)
{
continue;
}
knownSegments.Add(segment.Value);
current = segment.Value.End + silence;
SpeakerCompositeWav.WriteSilence(writer, firstReader.WaveFormat, silence);
}
var unknownSegment = SpeakerCompositeWav.AppendSnippet(
writer,
firstReader.WaveFormat,
unknownSnippet,
current);
return new CompositeLayout(unknownSegment, knownSegments);
}
private static string? FindBestSpeaker(
TimeSegment segment,
IReadOnlyList<TranscriptionSegment> segments)
{
return SpeakerCompositeWav.FindBestSpeaker(segment, segments);
}
private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList<TimeSegment> KnownSegments);
}
@@ -0,0 +1,107 @@
using MeetingAssistant.Transcription;
using NAudio.Wave;
namespace MeetingAssistant.Speakers;
internal static class SpeakerCompositeWav
{
public static WaveFileReader? OpenFirstReadableWave(IEnumerable<byte[]> snippets)
{
foreach (var bytes in snippets)
{
if (bytes.Length == 0)
{
continue;
}
try
{
return new WaveFileReader(new MemoryStream(bytes));
}
catch (FormatException)
{
}
}
return null;
}
public static TimeSegment? AppendSnippet(
WaveFileWriter writer,
WaveFormat targetFormat,
byte[] snippet,
TimeSpan start)
{
if (snippet.Length == 0)
{
return null;
}
using var reader = new WaveFileReader(new MemoryStream(snippet));
if (!WaveFormatsMatch(reader.WaveFormat, targetFormat))
{
throw new InvalidDataException("Speaker identity snippets must use the same WAV format.");
}
reader.CopyTo(writer);
return new TimeSegment(start, start + reader.TotalTime);
}
public static void WriteSilence(
WaveFileWriter writer,
WaveFormat format,
TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
var bytes = new byte[(int)(format.AverageBytesPerSecond * duration.TotalSeconds)];
writer.Write(bytes, 0, bytes.Length);
}
public static string? FindBestSpeaker(
TimeSegment segment,
IReadOnlyList<TranscriptionSegment> segments)
{
var bestOverlap = 0d;
string? bestSpeaker = null;
foreach (var transcriptSegment in segments)
{
var overlap = Math.Min(segment.End.TotalSeconds, transcriptSegment.End.TotalSeconds)
- Math.Max(segment.Start.TotalSeconds, transcriptSegment.Start.TotalSeconds);
if (overlap > bestOverlap)
{
bestOverlap = overlap;
bestSpeaker = transcriptSegment.Speaker;
}
}
return bestSpeaker;
}
public static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
}
}
private static bool WaveFormatsMatch(WaveFormat left, WaveFormat right)
{
return left.Encoding == right.Encoding
&& left.SampleRate == right.SampleRate
&& left.Channels == right.Channels
&& left.BitsPerSample == right.BitsPerSample;
}
}
internal readonly record struct TimeSegment(TimeSpan Start, TimeSpan End);
@@ -38,6 +38,12 @@ public sealed record SpeakerIdentityMatchCandidate(
public sealed record SpeakerIdentityMatch(int IdentityId);
public sealed record SpeakerIdentityMatchValidationRequest(
string DiarizedSpeaker,
int IdentityId,
byte[] UnknownSnippet,
IReadOnlyList<byte[]> KnownSnippets);
public interface ISpeakerIdentityMatcher
{
Task<SpeakerIdentityMatch?> MatchAsync(
@@ -45,6 +51,17 @@ public interface ISpeakerIdentityMatcher
CancellationToken cancellationToken);
}
public interface ISpeakerIdentityMatchValidator
{
Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken);
Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken);
}
public interface ISpeakerSnippetExtractor
{
Task<byte[]> ExtractSnippetAsync(
+5 -2
View File
@@ -13,6 +13,8 @@ public sealed class SpeakerIdentity
public DateTimeOffset UpdatedAt { get; set; }
public int TranscriptionCount { get; set; }
public List<SpeakerCandidateName> CandidateNames { get; set; } = [];
public List<SpeakerAlias> Aliases { get; set; } = [];
@@ -154,8 +156,9 @@ public sealed class SpeakerIdentityDbContext : DbContext
{
modelBuilder.Entity<SpeakerIdentity>(entity =>
{
entity.Property<int>("TranscriptionCount")
.HasDefaultValue(0);
entity.Property(identity => identity.TranscriptionCount)
.IsRequired()
.ValueGeneratedNever();
entity.HasMany(identity => identity.CandidateNames)
.WithOne(candidate => candidate.SpeakerIdentity)
@@ -10,6 +10,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
private readonly ISpeakerSnippetExtractor snippetExtractor;
private readonly ISpeakerIdentityMatcher matcher;
private readonly ISpeakerIdentityMatchValidator matchValidator;
private readonly SpeakerIdentificationOptions options;
private readonly ILogger<SpeakerIdentityService> logger;
@@ -18,11 +19,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
ISpeakerSnippetExtractor snippetExtractor,
ISpeakerIdentityMatcher matcher,
IOptions<MeetingAssistantOptions> options,
ILogger<SpeakerIdentityService> logger)
ILogger<SpeakerIdentityService> logger,
ISpeakerIdentityMatchValidator matchValidator)
{
this.dbContextFactory = dbContextFactory;
this.snippetExtractor = snippetExtractor;
this.matcher = matcher;
this.matchValidator = matchValidator;
this.options = options.Value.SpeakerIdentification;
this.logger = logger;
}
@@ -198,7 +201,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
? suppliedSnippet
: await snippetExtractor.ExtractSnippetAsync(
request.AudioPath,
group.ToList(),
SpeakerSampleSpanSelector.SelectBestContinuousSpan(
request.Segments,
speaker,
options.MaximumSampleSegmentGap,
options.MinimumSampleSpeechDuration),
cancellationToken);
if (snippet.Length == 0)
{
@@ -544,6 +551,14 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
{
if (!await matchValidator.ValidateSampleAsync(snippet, cancellationToken))
{
logger.LogInformation(
"Skipping speaker identity candidate for {Speaker} because secondary validation rejected the sample",
speaker);
continue;
}
var now = DateTimeOffset.UtcNow;
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
var identity = new SpeakerIdentity
@@ -0,0 +1,79 @@
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Speakers;
internal static class SpeakerSampleSpanSelector
{
public static bool CanExtend(
string currentSpeaker,
TimeSpan currentEnd,
TranscriptionSegment nextSegment,
TimeSpan maximumSegmentGap)
{
return string.Equals(currentSpeaker, nextSegment.Speaker, StringComparison.OrdinalIgnoreCase) &&
nextSegment.Start - currentEnd <= maximumSegmentGap;
}
public static IReadOnlyList<TranscriptionSegment> SelectBestContinuousSpan(
IReadOnlyList<TranscriptionSegment> segments,
string speaker,
TimeSpan maximumSegmentGap,
TimeSpan minimumDuration)
{
var best = new List<TranscriptionSegment>();
var current = new List<TranscriptionSegment>();
foreach (var segment in segments.OrderBy(segment => segment.Start))
{
if (current.Count == 0)
{
if (IsSpeaker(segment, speaker))
{
current.Add(segment);
best = LongerSpan(current, best);
}
continue;
}
if (!CanExtend(speaker, current[^1].End, segment, maximumSegmentGap))
{
current.Clear();
if (!IsSpeaker(segment, speaker))
{
continue;
}
}
current.Add(segment);
best = LongerSpan(current, best);
}
return SpanDuration(best) >= minimumDuration
? best
: [];
}
public static TimeSpan SpanDuration(IReadOnlyList<TranscriptionSegment> segments)
{
return segments.Count == 0
? TimeSpan.Zero
: segments[^1].End - segments[0].Start;
}
private static bool IsSpeaker(
TranscriptionSegment segment,
string speaker)
{
return string.Equals(segment.Speaker, speaker, StringComparison.OrdinalIgnoreCase);
}
private static List<TranscriptionSegment> LongerSpan(
List<TranscriptionSegment> current,
List<TranscriptionSegment> best)
{
return SpanDuration(current) > SpanDuration(best)
? current.ToList()
: best;
}
}
@@ -13,7 +13,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important.
If the meeting note has no title, provide a concise title parameter to write_summary.
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. write_context appends by default; use replace_file=true only when you are intentionally replacing the whole assistant context body. Keep user-facing summary content in the summary note.
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
Use add_attendee and remove_attendee to sharpen the meeting attendees list from clear transcript evidence and screenshot OCR participant evidence. Treat OCR that says visible people are a partial screenshot result as incomplete evidence; do not remove attendees solely because they are absent from a partial screenshot.
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
@@ -249,12 +249,13 @@ public sealed class MeetingSummaryTools
string content,
int? @from = null,
int? to = null,
int? insert = null)
int? insert = null,
bool replace_file = false)
{
var editMode = FileLineEditMode.Create(@from, to, insert);
var editMode = FileLineEditMode.Create(@from, to, insert, replace_file);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
}
if (!File.Exists(artifacts.AssistantContextPath))
@@ -316,12 +317,13 @@ public sealed class MeetingSummaryTools
string content,
int? @from = null,
int? to = null,
int? insert = null)
int? insert = null,
bool replace_file = false)
{
var editMode = FileLineEditMode.Create(@from, to, insert);
var editMode = FileLineEditMode.Create(@from, to, insert, replace_file);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
}
var target = ResolveExistingProjectFilePath(project, path);
@@ -544,6 +546,15 @@ public sealed class MeetingSummaryTools
return;
}
if (editMode.Kind == FileLineEditKind.Append)
{
var existing = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, AppendContent(existing, content));
return;
}
var lines = File.Exists(filePath)
? (await File.ReadAllLinesAsync(filePath)).ToList()
: [];
@@ -616,6 +627,11 @@ public sealed class MeetingSummaryTools
return replacementContent;
}
if (editMode.Kind == FileLineEditKind.Append)
{
return AppendContent(existingContent, replacementContent);
}
var lines = SplitLines(existingContent);
var replacementLines = SplitLines(replacementContent);
if (editMode.Kind == FileLineEditKind.Insert)
@@ -637,6 +653,24 @@ public sealed class MeetingSummaryTools
return string.Join('\n', lines);
}
private static string AppendContent(string existingContent, string content)
{
if (string.IsNullOrEmpty(existingContent))
{
return content;
}
if (string.IsNullOrEmpty(content))
{
return existingContent;
}
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
? ""
: "\n";
return existingContent + separator + content;
}
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
{
return projectResolver.GetBoundProjectsAsync(artifacts);
@@ -778,8 +812,13 @@ public sealed class MeetingSummaryTools
int? To = null,
int? Insert = null)
{
public static FileLineEditMode? Create(int? from, int? to, int? insert)
public static FileLineEditMode? Create(int? from, int? to, int? insert, bool replaceFile)
{
if (replaceFile && (from.HasValue || to.HasValue || insert.HasValue))
{
return null;
}
if (insert.HasValue)
{
return from.HasValue || to.HasValue
@@ -794,12 +833,13 @@ public sealed class MeetingSummaryTools
: null;
}
return new FileLineEditMode(FileLineEditKind.Overwrite);
return new FileLineEditMode(replaceFile ? FileLineEditKind.Overwrite : FileLineEditKind.Append);
}
}
private enum FileLineEditKind
{
Append,
Overwrite,
Replace,
Insert
@@ -143,7 +143,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
"Write internal assistant notes to the assistant context body. With no line arguments, overwrite the body. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
"Write internal assistant notes to the assistant context body. With no line arguments, append to the body. Set replace_file=true only when intentionally replacing the whole body. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
AIFunctionFactory.Create(
tools.ReadGlossary,
"read_glossary",
@@ -187,7 +187,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.WriteProjectFile,
"write_projectfile",
"Write a file inside an existing project folder. With no line arguments, overwrite or create the file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
"Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
AIFunctionFactory.Create(
tools.Search,
"search",
@@ -0,0 +1,125 @@
using MeetingAssistant.LaunchProfiles;
namespace MeetingAssistant.Transcription;
public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
{
private readonly PyannoteTranscriptFinalizer finalizer;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly ILogger<PyannoteDiarizationWarmupHostedService> logger;
private CancellationTokenSource? startupCancellation;
private Task? startupTask;
public PyannoteDiarizationWarmupHostedService(
PyannoteTranscriptFinalizer finalizer,
ILaunchProfileOptionsProvider launchProfiles,
ILogger<PyannoteDiarizationWarmupHostedService> logger)
{
this.finalizer = finalizer;
this.launchProfiles = launchProfiles;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupCancellation = cancellation;
startupTask = Task.Run(
() => WarmUpEnabledRuntimesAsync(cancellation.Token),
CancellationToken.None);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
var cancellation = startupCancellation;
var task = startupTask;
if (cancellation is null || task is null)
{
return;
}
startupCancellation = null;
startupTask = null;
await cancellation.CancelAsync();
try
{
await task.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
finally
{
cancellation.Dispose();
}
}
private async Task WarmUpEnabledRuntimesAsync(CancellationToken cancellationToken)
{
foreach (var diarization in GetEnabledDiarizationOptions())
{
try
{
logger.LogInformation(
"Starting pyannote warm-up for image {Image} and model {Model}",
diarization.Image,
diarization.Model);
await finalizer.WarmUpAsync(diarization, cancellationToken);
logger.LogInformation(
"Finished pyannote warm-up for image {Image} and model {Model}",
diarization.Image,
diarization.Model);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation("Pyannote warm-up was cancelled during application shutdown");
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Pyannote warm-up failed for image {Image} and model {Model}; diarization can still retry on demand",
diarization.Image,
diarization.Model);
}
}
}
private IEnumerable<PyannoteDiarizationOptions> GetEnabledDiarizationOptions()
{
return launchProfiles.GetProfiles()
.SelectMany(profile => GetEnabledDiarizationOptions(profile.Options))
.DistinctBy(CreateWarmUpKey);
}
private static IEnumerable<PyannoteDiarizationOptions> GetEnabledDiarizationOptions(
MeetingAssistantOptions options)
{
if (options.Recording.TranscriptionProvider.Equals("whisper-local", StringComparison.OrdinalIgnoreCase) &&
options.WhisperLocal.Diarization.Enabled)
{
yield return options.WhisperLocal.Diarization;
}
if (options.SpeakerIdentification.PyannoteValidation.Enabled &&
options.SpeakerIdentification.PyannoteValidation.Diarization.Enabled)
{
yield return options.SpeakerIdentification.PyannoteValidation.Diarization;
}
}
private static string CreateWarmUpKey(PyannoteDiarizationOptions diarization)
{
return string.Join(
'\u001f',
diarization.DockerCommand,
diarization.Image,
diarization.Model,
VaultPath.Resolve(diarization.ModelsFolder),
diarization.Token,
diarization.TokenEnv,
diarization.BuildImage.ToString());
}
}
@@ -82,6 +82,58 @@ public sealed class PyannoteTranscriptFinalizer
return segments;
}
public async Task WarmUpAsync(
PyannoteDiarizationOptions diarization,
CancellationToken cancellationToken)
{
if (!diarization.Enabled)
{
return;
}
var token = ResolveToken(diarization);
if (string.IsNullOrWhiteSpace(token))
{
logger.LogWarning(
"Pyannote warm-up is enabled but no Hugging Face token is configured directly or via {TokenEnv}",
diarization.TokenEnv);
return;
}
var modelsFolder = VaultPath.Resolve(diarization.ModelsFolder);
Directory.CreateDirectory(modelsFolder);
using var timeoutSource = diarization.CommandTimeout > TimeSpan.Zero
? new CancellationTokenSource(diarization.CommandTimeout)
: null;
using var linkedSource = timeoutSource is null
? null
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token);
try
{
if (diarization.BuildImage)
{
await EnsureDockerImageAsync(diarization, modelsFolder, linkedSource?.Token ?? cancellationToken);
}
var result = await commandRunner.RunAsync(
diarization.DockerCommand,
BuildWarmUpDockerArguments(diarization, modelsFolder),
linkedSource?.Token ?? cancellationToken,
new Dictionary<string, string> { ["HF_TOKEN"] = token });
if (result.ExitCode != 0)
{
throw new InvalidOperationException(
$"pyannote warm-up failed with exit code {result.ExitCode}: {result.StandardError}");
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutSource?.IsCancellationRequested == true)
{
throw new TimeoutException(
$"pyannote warm-up timed out after {diarization.CommandTimeout}.");
}
}
private async Task<CommandResult> RunDiarizationAsync(
string fullAudioPath,
string token,
@@ -189,6 +241,33 @@ public sealed class PyannoteTranscriptFinalizer
];
}
private string[] BuildWarmUpDockerArguments(
PyannoteDiarizationOptions diarization,
string modelsFolder)
{
return
[
"run",
"--rm",
"-e",
"HF_TOKEN",
"-v",
$"{modelsFolder}:/workspace/cache",
"-e",
"HF_HOME=/workspace/cache/huggingface",
"-e",
"XDG_CACHE_HOME=/workspace/cache",
"-e",
"PIP_CACHE_DIR=/workspace/cache/pip",
"-e",
"TORCH_HOME=/workspace/cache/torch",
diarization.Image,
"sh",
"-lc",
BuildWarmUpPythonCommand(diarization)
];
}
private static string BuildPythonCommand(
PyannoteDiarizationOptions diarization,
SpeechRecognitionPipelineOptions pipelineOptions)
@@ -217,6 +296,21 @@ public sealed class PyannoteTranscriptFinalizer
+ "python /tmp/meeting_assistant_pyannote.py";
}
private static string BuildWarmUpPythonCommand(PyannoteDiarizationOptions diarization)
{
var model = diarization.Model;
return
"cat > /tmp/meeting_assistant_pyannote_warmup.py <<'PY'\n"
+ "import os\n"
+ "import torch\n"
+ "from pyannote.audio import Pipeline\n"
+ $"pipeline = Pipeline.from_pretrained({JsonSerializer.Serialize(model)}, token=os.environ.get('HF_TOKEN'))\n"
+ "pipeline.to(torch.device('cpu'))\n"
+ "print('pyannote warm-up complete')\n"
+ "PY\n"
+ "python /tmp/meeting_assistant_pyannote_warmup.py";
}
private static string BuildPipelineInvocation(SpeechRecognitionPipelineOptions pipelineOptions)
{
return pipelineOptions.NumSpeakers is > 0
@@ -1,27 +1,41 @@
using MeetingAssistant.LaunchProfiles;
namespace MeetingAssistant.Transcription;
public sealed class SpeechRecognitionPipelineHostedService : IHostedService
{
private readonly ISpeechRecognitionPipelineFactory pipelineFactory;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly ILogger<SpeechRecognitionPipelineHostedService> logger;
private CancellationTokenSource? startupCancellation;
private Task? startupTask;
private ISpeechRecognitionPipeline? startupPipeline;
private List<ISpeechRecognitionPipeline> startupPipelines = [];
public SpeechRecognitionPipelineHostedService(
ISpeechRecognitionPipelineFactory pipelineFactory,
ILogger<SpeechRecognitionPipelineHostedService> logger)
: this(pipelineFactory, null, logger)
{
}
public SpeechRecognitionPipelineHostedService(
ISpeechRecognitionPipelineFactory pipelineFactory,
ILaunchProfileOptionsProvider? launchProfiles,
ILogger<SpeechRecognitionPipelineHostedService> logger)
{
this.pipelineFactory = pipelineFactory;
this.launchProfiles = launchProfiles;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupPipeline = pipelineFactory.Create();
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var pipelines = CreateStartupPipelines();
startupCancellation = cancellation;
startupPipelines = pipelines;
startupTask = Task.Run(
() => WarmUpPipelineAsync(startupPipeline, startupCancellation.Token),
() => WarmUpPipelinesAsync(pipelines, cancellation.Token),
CancellationToken.None);
return Task.CompletedTask;
}
@@ -30,15 +44,15 @@ public sealed class SpeechRecognitionPipelineHostedService : IHostedService
{
var cancellation = startupCancellation;
var task = startupTask;
var pipeline = startupPipeline;
if (cancellation is null || task is null || pipeline is null)
var pipelines = startupPipelines;
if (cancellation is null || task is null)
{
return;
}
startupCancellation = null;
startupTask = null;
startupPipeline = null;
startupPipelines = [];
await cancellation.CancelAsync();
try
{
@@ -49,11 +63,43 @@ public sealed class SpeechRecognitionPipelineHostedService : IHostedService
}
finally
{
await pipeline.DisposeAsync();
foreach (var pipeline in pipelines)
{
await pipeline.DisposeAsync();
}
cancellation.Dispose();
}
}
private List<ISpeechRecognitionPipeline> CreateStartupPipelines()
{
if (launchProfiles is null)
{
return [pipelineFactory.Create()];
}
return launchProfiles.GetProfiles()
.Where(ShouldWarmUpProfile)
.Select(profile => pipelineFactory.Create(profile.Name))
.ToList();
}
private static bool ShouldWarmUpProfile(LaunchProfile profile)
{
return profile.Name.Equals(
ConfigurationLaunchProfileOptionsProvider.DefaultProfileName,
StringComparison.OrdinalIgnoreCase) ||
profile.Options.Recording.TranscriptionProvider.Equals("funasr", StringComparison.OrdinalIgnoreCase);
}
private async Task WarmUpPipelinesAsync(
IReadOnlyList<ISpeechRecognitionPipeline> pipelines,
CancellationToken cancellationToken)
{
await Task.WhenAll(pipelines.Select(pipeline => WarmUpPipelineAsync(pipeline, cancellationToken)));
}
private async Task WarmUpPipelineAsync(
ISpeechRecognitionPipeline pipeline,
CancellationToken cancellationToken)
@@ -124,7 +124,7 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
AIFunctionFactory.Create(
tools.WriteRules,
"write_rules",
"Overwrite the configured workflow rules YAML file after validating that it parses as a workflow rules document."),
"Append to the configured workflow rules YAML file by default after validating that the complete file parses as a workflow rules document. Set replace_file=true only when intentionally replacing the whole rules file."),
AIFunctionFactory.Create(
tools.Search,
"search",
@@ -15,6 +15,7 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant.
Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files.
Read the existing rules before making changes unless the user explicitly asks to replace the whole file.
write_rules appends by default. Pass replace_file=true only when you intentionally replace the complete rules file.
Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal.
When writing rules, prefer existing rule style and names.
Use search_identities and read_identity before changing identities unless the user gives an exact identity id.
@@ -51,7 +51,7 @@ public sealed class WorkflowRulesEditorTools
return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to);
}
public async Task<string> WriteRules(string yaml)
public async Task<string> WriteRules(string yaml, bool replace_file = false)
{
if (rulesPath is null)
{
@@ -63,14 +63,20 @@ public sealed class WorkflowRulesEditorTools
return "Refused: yaml must not be null.";
}
var validation = ValidateYaml(yaml);
var existing = File.Exists(rulesPath)
? await File.ReadAllTextAsync(rulesPath)
: "";
var updated = replace_file
? yaml
: AppendContent(existing, yaml);
var validation = ValidateYaml(updated);
if (validation is not null)
{
return validation;
}
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
await File.WriteAllTextAsync(rulesPath, yaml);
await File.WriteAllTextAsync(rulesPath, updated);
return rulesPath;
}
@@ -359,6 +365,24 @@ public sealed class WorkflowRulesEditorTools
}
}
private static string AppendContent(string existingContent, string content)
{
if (string.IsNullOrEmpty(existingContent))
{
return content;
}
if (string.IsNullOrEmpty(content))
{
return existingContent;
}
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
? ""
: "\n";
return existingContent + separator + content;
}
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
{
try
+22 -2
View File
@@ -89,7 +89,7 @@
"de-DE"
],
"KeyEnv": "AZURE_SPEECH_KEY",
"RecognitionStopTimeout": "00:00:10",
"RecognitionStopTimeout": "00:03:00",
"DiarizeIntermediateResults": true,
"PostProcessingOption": "",
"PhraseListWeight": 1.5
@@ -103,10 +103,30 @@
"MaxMatchCandidates": 100,
"MatchIdentityActiveAge": "365.00:00:00",
"MaxSnippetsPerSpeaker": 3,
"MinimumSampleSpeechDuration": "00:00:30",
"MaximumSampleSegmentGap": "00:00:01",
"SilenceBetweenSnippetsSeconds": 1,
"LiveSampleBufferDuration": "00:10:00",
"MergeRecentIdentityAge": "14.00:00:00",
"MatchTimeout": "00:03:00"
"MatchTimeout": "00:03:00",
"PyannoteValidation": {
"Enabled": false,
"MinimumSingleSpeakerCoverage": 0.9,
"MinimumMatchingKnownSnippetRatio": 0.5,
"Diarization": {
"Enabled": false,
"DockerCommand": "docker",
"BaseImage": "python:3.11-slim",
"Image": "meeting-assistant-pyannote:local",
"BuildImage": true,
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"AnnotationSource": "SpeakerDiarization",
"AlignmentMode": "PyannoteTurns",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "01:00:00"
}
}
},
"Automation": {
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
+27 -6
View File
@@ -165,9 +165,28 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"Region": "germanywestcentral",
"Language": "en-US",
"KeyEnv": "AZURE_SPEECH_KEY",
"RecognitionStopTimeout": "00:00:10",
"RecognitionStopTimeout": "00:03:00",
"DiarizeIntermediateResults": true
},
"SpeakerIdentification": {
"MinimumSampleSpeechDuration": "00:00:30",
"MaximumSampleSegmentGap": "00:00:01",
"PyannoteValidation": {
"Enabled": false,
"MinimumSingleSpeakerCoverage": 0.9,
"MinimumMatchingKnownSnippetRatio": 0.5,
"Diarization": {
"Enabled": false,
"DockerCommand": "docker",
"Image": "meeting-assistant-pyannote:local",
"BuildImage": true,
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "01:00:00"
}
}
},
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
},
@@ -209,13 +228,15 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
}
```
`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime: on application startup it begins a non-blocking warm-up that pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops the container when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. Docker commands are bounded by `CommandTimeout`, and backend WebSocket readiness is bounded by `StartupTimeout`. FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`.
`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime: on application startup it begins a non-blocking warm-up for the default launch profile and any named launch profile that selects `funasr`. That warm-up pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops it when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. Docker commands are bounded by `CommandTimeout`, and backend WebSocket readiness is bounded by `StartupTimeout`. FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`.
During recording, Meeting Assistant writes the mixed PCM stream to a temporary WAV under `TemporaryRecordingsFolder` only so the final diarization pass has audio to process. After capture stops and the streaming ASR provider drains the already-captured audio, FunASR Python `AutoModel` can run with VAD, punctuation, and CAMPPlus (`spk_model="cam++"`) over that temporary WAV. If sentence-level speaker labels are returned, the transcript markdown is rewritten with the final speaker-attributed segments. If final diarization is disabled or returns no segments, the live streaming transcript is kept. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts.
`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `ModelPath` before using live transcription. When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. When `BuildImage` is true, Meeting Assistant builds the configured local pyannote Docker image if Docker does not already have it, so Python packages are installed once instead of during every diarization call. The configured `ModelsFolder` is mounted as the persistent pyannote model cache and stores Hugging Face, torch, and pip cache artifacts so model downloads are reused across runs. If pyannote is disabled, has no token, or returns no turns, the live Whisper transcript is kept.
`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `ModelPath` before using live transcription. When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. When `BuildImage` is true, Meeting Assistant builds the configured local pyannote Docker image if Docker does not already have it, so Python packages are installed once instead of during every diarization call. The configured `ModelsFolder` is mounted as the persistent pyannote model cache and stores Hugging Face, torch, and pip cache artifacts so model downloads are reused across runs. Active pyannote runtimes are also warmed up on application start so image setup and model download do not wait for the first diarization request. If pyannote is disabled, has no token, or returns no turns, the live Whisper transcript is kept.
`azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed, which keeps short diagnostic runs from waiting indefinitely for final service events. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote.
`azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed; the default is 3 minutes so Azure has time to finalize longer speaker-recognition samples without letting diagnostic runs wait indefinitely. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote.
Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization, reads its Hugging Face token from the nested diarization options, warms that runtime on application start, and defaults the validation command timeout to 1 hour because the local model can need substantial setup time.
ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider.
@@ -269,7 +290,7 @@ GET /meetings/summary/retry?summaryPath=...
Failure summary notes include a clickable retry link using `Api:PublicBaseUrl`, for example `http://localhost:5090/meetings/summary/retry?summaryPath=20260519-124110-summary.md`. The GET endpoint exists for Obsidian/browser clicks and performs the same overwrite behavior as the POST endpoint. Meeting note bodies stay reserved for user-authored notes.
The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, and project files. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using line-based overwrite, replace, and insert modes. Project lookup and search tools are constrained to the projects listed in the meeting note frontmatter:
The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, and project files. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using append-by-default, explicit whole-file replacement, replace, and insert modes. Project lookup and search tools are constrained to the projects listed in the meeting note frontmatter:
When assistant context contains cropped screenshot links produced by OCR, the summary instructions tell the agent to include only the most relevant cropped screenshots in the summary and markdown-link them near the related summary text.
@@ -287,7 +308,7 @@ When assistant context contains cropped screenshot links produced by OCR, the su
`search` accepts ripgrep syntax and returns matches as `filename:line text`. If one project is searched, paths are relative to that project; if multiple projects are searched, paths include the project folder name.
`write_projectfile` writes inside an existing project folder. With no line arguments it overwrites or creates the file. With `from` and `to` it replaces that inclusive 1-based line range. With `insert` it inserts content at that 1-based line position.
`write_projectfile` writes inside an existing project folder. With no line arguments it appends or creates the file. With `replace_file: true` it replaces the whole file. With `from` and `to` it replaces that inclusive 1-based line range. With `insert` it inserts content at that 1-based line position.
## Spec Workflow
+2 -2
View File
@@ -22,7 +22,7 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
The tray menu includes `Edit rules`, which opens a small MewUI chat editor for this configured rules file. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
The tray menu includes `Edit rules and identities`, which opens a small MewUI chat editor for this configured rules file and the local speaker identity database. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
```json
{
@@ -48,7 +48,7 @@ Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor
The editor agent prompt includes this document and is restricted to these tools:
- `read_rules`: read the configured rules file, optionally by inclusive 1-based line range.
- `write_rules`: overwrite the configured rules file after validating that the YAML parses as a workflow rules document.
- `write_rules`: append to the configured rules file by default after validating that the complete YAML document parses as a workflow rules document. Pass `replace_file: true` only when intentionally replacing the whole rules file.
- `search`: search only the configured rules file with ripgrep-style syntax.
`POST /diagnostics/workflow/reload` reloads application configuration without restarting the process. Use it after changing workflow automation configuration such as `MeetingAssistant:Automation:RulesPath`. The endpoint returns the currently bound rules path:
@@ -0,0 +1,14 @@
# Change: Append file-write tools by default
## Why
Agent-facing file write tools currently replace whole files when no line arguments are supplied. This is risky because agents often intend to add context and can accidentally overwrite project knowledge, assistant context, or local workflow rules.
## What Changes
- Make summary-agent assistant context and project file writes append by default.
- Add an optional `replace_file` boolean to explicitly request whole-file replacement.
- Make workflow rules editor writes append by default and require `replace_file` for whole-file replacement.
- Keep line-range replacement and insertion behavior unchanged.
## Impact
- Reduces accidental destructive writes from summarizer and rules editor agents.
- Existing callers that intentionally replace whole files must pass `replace_file: true`.
@@ -0,0 +1,40 @@
## MODIFIED Requirements
### Requirement: Agents can write files in existing projects
Meeting Assistant SHALL expose a `write_projectfile` tool that can create or update files inside an existing project folder.
The target project SHALL be an existing direct subfolder of the configured projects folder. The target file path SHALL stay inside that project folder.
When no line edit arguments are supplied and `replace_file` is not true, `write_projectfile` SHALL append the supplied content to the file and SHALL create the file when it does not exist.
When `replace_file` is true, `write_projectfile` SHALL replace the whole file with the supplied content.
When both `from` and `to` are supplied, `write_projectfile` SHALL replace the inclusive 1-based line range with the supplied content.
When `insert` is supplied, `write_projectfile` SHALL insert the supplied content at that 1-based line position.
Meeting Assistant SHALL refuse ambiguous writes that combine `replace_file` with line edit arguments.
#### Scenario: Project file is appended or created
- **WHEN** the agent writes a project file without line edit arguments
- **THEN** Meeting Assistant appends the supplied content to an existing file
- **AND** creates the file with the supplied content when it does not exist
#### Scenario: Project file is explicitly replaced
- **WHEN** the agent writes a project file with `replace_file` set to true
- **THEN** Meeting Assistant writes the supplied content as the complete file content
#### Scenario: Project file line range is replaced
- **WHEN** the agent writes a project file with `from` and `to` line numbers
- **THEN** Meeting Assistant replaces the inclusive line range with the supplied content
#### Scenario: Project file content is inserted
- **WHEN** the agent writes a project file with an `insert` line number
- **THEN** Meeting Assistant inserts the supplied content at that line position
#### Scenario: Project file write target is invalid
- **WHEN** the target project is missing or the target path escapes the project folder
- **THEN** Meeting Assistant refuses the project file write
#### Scenario: Project file write mode is ambiguous
- **WHEN** the agent combines `replace_file` with `from`, `to`, or `insert`
- **THEN** Meeting Assistant refuses the project file write
@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
The rules editor `write_rules` tool SHALL append to the configured workflow rules file by default and SHALL validate the complete resulting YAML document before writing it.
The rules editor `write_rules` tool SHALL replace the whole configured workflow rules file only when `replace_file` is true.
The rules editor `write_rules` tool SHALL refuse invalid YAML without changing the configured workflow rules file.
#### Scenario: Rules editor is scoped to the configured rules file
- **GIVEN** a configured workflow rules file
- **WHEN** the rules editor agent runs
- **THEN** its read, write, and search tools can access only that configured workflow rules file
- **AND** its system prompt includes the workflow engine documentation
#### Scenario: Rules editor appends by default
- **GIVEN** a configured workflow rules file with existing valid rules
- **WHEN** the rules editor writes an additional valid rule without `replace_file`
- **THEN** Meeting Assistant appends the new rule
- **AND** preserves the existing rules
#### Scenario: Rules editor replaces only when requested
- **GIVEN** a configured workflow rules file with existing rules
- **WHEN** the rules editor writes valid YAML with `replace_file` set to true
- **THEN** Meeting Assistant replaces the complete rules file with the supplied YAML
#### Scenario: Rules editor refuses invalid YAML
- **GIVEN** a configured workflow rules file with valid existing rules
- **WHEN** the rules editor writes YAML that would make the file invalid
- **THEN** Meeting Assistant refuses the write
- **AND** keeps the existing rules file unchanged
@@ -0,0 +1,77 @@
## MODIFIED Requirements
### Requirement: Meeting Assistant generates meeting outputs
Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context.
Meeting Assistant SHALL use a Microsoft Agent Framework pipeline for the first summary implementation. The pipeline SHALL use a configurable OpenAI-compatible endpoint, model, and API key source, including support for a direct key or an environment variable name.
The summary pipeline SHALL retry transient model endpoint failures according to configurable reconnection attempts and delay settings.
The summary pipeline SHALL expose tools scoped to the current meeting:
- `read_meetingnote`
- `read_transcript`
- `read_context`
- `read_usernotes`
- `read_glossary`
- `add_dictation_word`
- `add_attendee`
- `remove_attendee`
- `override_speaker`
- `delete_identity`
- `write_summary`
- `write_context`
- `list_projects`
- `list_projectfiles`
- `read_projectfile`
- `write_projectfile`
- `search`
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context.
The summary agent SHALL be able to read the meeting note, transcript, assistant context, glossary, and bound project files through tools.
The summary agent SHALL be able to write the summary and assistant context files as its owned artifacts.
The summary agent SHALL be able to read and write the assistant context body as its own notebook using append-by-default, explicit whole-body replacement, replace, and insert line modes.
When the summary agent calls `write_context` without line edit arguments and without `replace_file`, Meeting Assistant SHALL append the supplied content to the assistant context body while preserving assistant context frontmatter.
When the summary agent calls `write_context` with `replace_file` true, Meeting Assistant SHALL replace the assistant context body while preserving assistant context frontmatter.
Meeting Assistant SHALL refuse ambiguous `write_context` calls that combine `replace_file` with line edit arguments.
For summary-agent writes to any file other than the summary file and assistant context file, Meeting Assistant SHALL record an in-memory diff containing removed and added lines.
Meeting Assistant SHALL use a diff implementation that can reduce whole-file rewrites to changed lines instead of treating every rewrite as a full replacement.
When the summary agent finishes, Meeting Assistant SHALL append the collected external write diffs to the assistant context file.
#### Scenario: Assistant context note is initialized
- **WHEN** Meeting Assistant starts a meeting session
- **THEN** it creates the assistant context note with frontmatter links to the meeting note, transcript note, and summary note
#### Scenario: Assistant context body appends by default
- **WHEN** the summary agent writes assistant context content without line arguments
- **THEN** Meeting Assistant appends the content to the existing assistant context body
- **AND** preserves the existing assistant context frontmatter
#### Scenario: Assistant context body can be explicitly replaced
- **WHEN** the summary agent writes assistant context content with `replace_file` set to true
- **THEN** Meeting Assistant replaces the assistant context body
- **AND** preserves the existing assistant context frontmatter
#### Scenario: Assistant context body writes do not duplicate frontmatter
- **WHEN** the summary agent writes assistant context content that accidentally includes a markdown frontmatter block
- **THEN** Meeting Assistant preserves the existing assistant context frontmatter
- **AND** writes only the replacement body content after that frontmatter
#### Scenario: Assistant context write mode is ambiguous
- **WHEN** the summary agent combines `replace_file` with `from`, `to`, or `insert`
- **THEN** Meeting Assistant refuses the assistant context write
#### Scenario: External project write is audited
- **WHEN** the summary agent writes a bound project file
- **THEN** Meeting Assistant records the changed removed and added lines for that project file
- **AND** appends that diff to the assistant context after the agent finishes
@@ -0,0 +1,8 @@
## Implementation
- [x] Repair the accidentally overwritten project note to the state it would have had after an append.
- [x] Add behavior tests for append-by-default and explicit replacement.
- [x] Implement `replace_file` on summary file-write tools and workflow rules writes.
- [x] Update agent tool descriptions and instructions.
- [x] Run focused tests.
- [x] Run `dotnet test MeetingAssistant.slnx`.
- [x] Run `openspec validate append-file-write-tools-by-default --strict`.
@@ -0,0 +1,20 @@
# Harden speaker samples and pyannote validation
## Why
Azure Speech speaker identity matching is too brittle when Meeting Assistant learns from short samples. Speaker identity samples should represent enough uninterrupted speech for the diarization backend to distinguish voices reliably.
Meeting Assistant can also use pyannote as a secondary, optional confidence layer: first to reject samples that contain multiple speakers, and then to reject Azure identity matches when pyannote cannot confirm that the unknown and known snippets are the same speaker.
## What Changes
- Require speaker identity samples to meet a configurable minimum uninterrupted speech duration, defaulting to 30 seconds.
- Accumulate adjacent same-speaker transcript segments into a single sample span when no other speaker interrupts the span.
- Add optional pyannote-backed sample and match validation for speaker identity matching.
- Reuse the existing pyannote Docker/runtime configuration shape where practical.
## Impact
- Speaker identity matching starts later for speakers who do not talk long enough uninterrupted.
- Live matching tests that depended on very short samples need to configure a lower minimum sample duration.
- Pyannote validation is opt-in and should not affect existing installations unless enabled.
@@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Speaker identity samples require uninterrupted speech
Meeting Assistant SHALL only retain speaker identity samples after a diarized speaker has produced at least the configured minimum duration of uninterrupted speech.
The default minimum uninterrupted speech duration SHALL be 30 seconds.
Meeting Assistant MAY combine adjacent transcript segments for the same diarized speaker into one sample span when no different diarized speaker interrupts the span.
When a different diarized speaker appears before the configured minimum duration is reached, Meeting Assistant SHALL discard the pending sample span for the previous speaker.
#### Scenario: Short speaker span is not retained
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces only 20 seconds of uninterrupted speech
- **THEN** Meeting Assistant does not retain a speaker identity sample for that span
#### Scenario: Adjacent same-speaker segments form a sample
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces adjacent segments totaling at least 30 seconds without another speaker interrupting
- **THEN** Meeting Assistant retains one speaker identity sample covering the continuous span
#### Scenario: Different speaker interrupts pending span
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** `Guest01` speaks for 20 seconds and then `Guest02` speaks
- **THEN** Meeting Assistant discards the pending `Guest01` span instead of retaining or later extending it
### Requirement: Speaker identity matching can use pyannote secondary validation
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected.
When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match.
If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match.
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
- **THEN** Meeting Assistant does not retain that sample for identity matching
#### Scenario: Pyannote rejects primary match
- **GIVEN** pyannote secondary validation is enabled
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers
- **THEN** Meeting Assistant rejects the match
#### Scenario: Disabled pyannote validation preserves primary match
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation
@@ -0,0 +1,24 @@
## 1. Specification
- [x] Add requirements for minimum uninterrupted speaker samples.
- [x] Add requirements for optional pyannote secondary validation.
## 2. Tests
- [x] Cover sample collection waiting for the configured minimum uninterrupted speech duration.
- [x] Cover sample collection resetting when another speaker interrupts.
- [x] Cover pyannote sample validation rejecting multi-speaker samples.
- [x] Cover pyannote match validation rejecting an Azure-confirmed match.
- [x] Update existing live matching tests to configure short samples where they intentionally exercise fast matching.
## 3. Implementation
- [x] Add speaker identification configuration for minimum sample duration and pyannote validation.
- [x] Refactor live sample collection to accumulate continuous same-speaker spans.
- [x] Add pyannote diarization reuse for speaker identity validation.
- [x] Add secondary validation into the identity matching pipeline.
## 4. Verification
- [x] Run focused speaker/transcription tests.
- [x] Run `openspec validate harden-speaker-samples-and-pyannote-validation --strict`.
@@ -0,0 +1,13 @@
# Change: Warm up pyannote on startup
## Why
Local pyannote validation can spend significant time building the Docker image and downloading Hugging Face model artifacts. If that work happens on the first speaker-validation request, the request can time out before validation starts.
## What Changes
- Increase the local pyannote validation command timeout default.
- Add a startup warm-up path that prepares enabled pyannote diarization runtimes and downloads the configured model into the persistent cache.
- Keep startup non-blocking and keep diarization requests bounded by configured timeouts.
## Impact
- First speaker validation is less likely to fail due to one-time model setup.
- Startup logs can surface pyannote setup failures before the first meeting needs validation.
@@ -0,0 +1,94 @@
## MODIFIED Requirements
### Requirement: Speech recognition pipelines are streamable and configurable
Meeting Assistant SHALL route audio through a provider-independent speech recognition pipeline contract.
The configured pipeline SHALL be selected through DI from the ASR backend configuration and SHALL expose operations to initialize, wait for readiness, write captured audio chunks, read live transcript segments, and read the finished transcript after capture completes.
Reading the finished transcript SHALL accept optional pipeline options, including a requested speaker count hint.
For real meeting recordings, Meeting Assistant SHALL derive the requested speaker count hint from the meeting note `attendees` frontmatter when at least two attendees are listed.
The configured pipeline SHALL receive audio chunks as they are captured rather than requiring the recording coordinator to finish a complete meeting audio file first.
On application startup, Meeting Assistant SHALL start non-blocking warm-up for the default launch profile's speech recognition pipeline and for any named launch profile that selects the managed FunASR provider.
#### Scenario: Streaming provider receives live audio
- **WHEN** recording mode is active and the combined audio source emits a chunk
- **THEN** Meeting Assistant can pass that chunk to the configured speech recognition pipeline before the audio source completes
#### Scenario: Pipeline readiness is separated from recording capture
- **WHEN** recording mode starts before the configured speech recognition backend is ready
- **THEN** Meeting Assistant starts audio capture immediately and lets the pipeline finish readiness before it emits live transcript output
#### Scenario: Pipeline is changed by configuration
- **WHEN** the configured ASR backend setting is changed
- **THEN** Meeting Assistant selects the configured speech recognition pipeline without changing recording control or transcript persistence code
#### Scenario: ASR backend is tested with a WAV file
- **WHEN** the user submits a local PCM WAV file to the ASR diagnostic endpoint
- **THEN** Meeting Assistant streams that file through the configured speech recognition pipeline and returns the emitted live transcript segments
#### Scenario: ASR diagnostic backend is unavailable
- **WHEN** the configured speech recognition pipeline fails while processing the diagnostic WAV file
- **THEN** Meeting Assistant returns a diagnostic backend failure response instead of hiding the pipeline error
#### Scenario: Final diarization backend is tested with a WAV file
- **WHEN** the user submits a local PCM WAV file to the final diarization diagnostic endpoint
- **THEN** Meeting Assistant streams the file through the configured speech recognition pipeline and returns the finished transcript segments
#### Scenario: Final diarization receives a speaker count hint
- **WHEN** the user submits a local PCM WAV file and `numSpeakers` to the final diarization diagnostic endpoint
- **THEN** Meeting Assistant passes the requested speaker count to the configured speech recognition pipeline as a finished-transcript option
#### Scenario: Meeting attendee count provides final speaker hint
- **WHEN** a meeting note has two or more entries in `attendees`
- **THEN** Meeting Assistant passes that attendee count to the configured speech recognition pipeline as the finished-transcript speaker count hint
#### Scenario: Empty or single-attendee meeting note has no final speaker hint
- **WHEN** a meeting note has zero or one entry in `attendees`
- **THEN** Meeting Assistant leaves the finished-transcript speaker count hint unset
#### Scenario: Launch profile FunASR pipeline warms on startup
- **GIVEN** a named launch profile selects `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it begins warming that profile's speech recognition pipeline without blocking startup
#### Scenario: Non-default non-FunASR profile is not warmed
- **GIVEN** a named launch profile selects a provider other than `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it does not create a startup warm-up pipeline for that named profile
### Requirement: Speaker identity matching can use pyannote secondary validation
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected.
When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match.
If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match.
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
When pyannote secondary validation is enabled, Meeting Assistant SHALL start a non-blocking startup warm-up that builds or verifies the configured pyannote runtime image and downloads the configured model into the persistent model cache before the first validation request when possible.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
- **THEN** Meeting Assistant does not retain that sample for identity matching
#### Scenario: Pyannote rejects primary match
- **GIVEN** pyannote secondary validation is enabled
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers
- **THEN** Meeting Assistant rejects the match
#### Scenario: Disabled pyannote validation preserves primary match
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation
#### Scenario: Pyannote validation warms up on startup
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** Meeting Assistant starts
- **THEN** it begins preparing the configured pyannote runtime image and model cache without waiting for the first validation request
- **AND** application startup is not blocked by the warm-up task
@@ -0,0 +1,9 @@
## Implementation
- [x] Add behavior tests for pyannote warm-up command generation.
- [x] Add behavior tests for non-blocking startup warm-up of enabled pyannote validation.
- [x] Implement pyannote warm-up in the finalizer/runtime.
- [x] Register a startup hosted service for enabled pyannote runtime warm-up.
- [x] Warm named launch-profile FunASR pipelines on startup.
- [x] Increase local pyannote validation timeout in code/config/docs.
- [x] Run focused tests.
- [x] Validate OpenSpec change.
+15 -2
View File
@@ -37,14 +37,23 @@ Meeting Assistant SHALL expose a `write_projectfile` tool that can create or upd
The target project SHALL be an existing direct subfolder of the configured projects folder. The target file path SHALL stay inside that project folder.
When no line edit arguments are supplied, `write_projectfile` SHALL overwrite the whole file and SHALL create the file when it does not exist.
When no line edit arguments are supplied and `replace_file` is not true, `write_projectfile` SHALL append the supplied content to the file and SHALL create the file when it does not exist.
When `replace_file` is true, `write_projectfile` SHALL replace the whole file with the supplied content.
When both `from` and `to` are supplied, `write_projectfile` SHALL replace the inclusive 1-based line range with the supplied content.
When `insert` is supplied, `write_projectfile` SHALL insert the supplied content at that 1-based line position.
#### Scenario: Project file is overwritten or created
Meeting Assistant SHALL refuse ambiguous writes that combine `replace_file` with line edit arguments.
#### Scenario: Project file is appended or created
- **WHEN** the agent writes a project file without line edit arguments
- **THEN** Meeting Assistant appends the supplied content to an existing file
- **AND** creates the file with the supplied content when it does not exist
#### Scenario: Project file is explicitly replaced
- **WHEN** the agent writes a project file with `replace_file` set to true
- **THEN** Meeting Assistant writes the supplied content as the complete file content
#### Scenario: Project file line range is replaced
@@ -59,3 +68,7 @@ When `insert` is supplied, `write_projectfile` SHALL insert the supplied content
- **WHEN** the target project is missing or the target path escapes the project folder
- **THEN** Meeting Assistant refuses the project file write
#### Scenario: Project file write mode is ambiguous
- **WHEN** the agent combines `replace_file` with `from`, `to`, or `insert`
- **THEN** Meeting Assistant refuses the project file write
+23
View File
@@ -202,6 +202,12 @@ The rules editor agent SHALL be configured from the summarizer agent settings by
The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
The rules editor `write_rules` tool SHALL append to the configured workflow rules file by default and SHALL validate the complete resulting YAML document before writing it.
The rules editor `write_rules` tool SHALL replace the whole configured workflow rules file only when `replace_file` is true.
The rules editor `write_rules` tool SHALL refuse invalid YAML without changing the configured workflow rules file.
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
@@ -233,6 +239,23 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru
- **THEN** its read, write, and search tools can access only that configured workflow rules file
- **AND** its system prompt includes the workflow engine documentation
#### Scenario: Rules editor appends by default
- **GIVEN** a configured workflow rules file with existing valid rules
- **WHEN** the rules editor writes an additional valid rule without `replace_file`
- **THEN** Meeting Assistant appends the new rule
- **AND** preserves the existing rules
#### Scenario: Rules editor replaces only when requested
- **GIVEN** a configured workflow rules file with existing rules
- **WHEN** the rules editor writes valid YAML with `replace_file` set to true
- **THEN** Meeting Assistant replaces the complete rules file with the supplied YAML
#### Scenario: Rules editor refuses invalid YAML
- **GIVEN** a configured workflow rules file with valid existing rules
- **WHEN** the rules editor writes YAML that would make the file invalid
- **THEN** Meeting Assistant refuses the write
- **AND** keeps the existing rules file unchanged
#### Scenario: Rules editor can manage speaker identities
- **GIVEN** the speaker identity database contains speaker identities and samples
- **WHEN** the rules editor agent runs
+21 -1
View File
@@ -68,7 +68,13 @@ The summary note SHALL keep frontmatter links to meeting artifacts and SHALL cop
When the summary note already has an `attendees` frontmatter property, Meeting Assistant SHALL preserve the existing summary attendees instead of overwriting them from the meeting note.
The summary agent SHALL be able to read and write the assistant context body as its own notebook using overwrite, replace, and insert line modes.
The summary agent SHALL be able to read and write the assistant context body as its own notebook using append-by-default, explicit whole-body replacement, replace, and insert line modes.
When the summary agent calls `write_context` without line edit arguments and without `replace_file`, Meeting Assistant SHALL append the supplied content to the assistant context body while preserving assistant context frontmatter.
When the summary agent calls `write_context` with `replace_file` true, Meeting Assistant SHALL replace the assistant context body while preserving assistant context frontmatter.
Meeting Assistant SHALL refuse ambiguous `write_context` calls that combine `replace_file` with line edit arguments.
The summary instructions SHALL tell the summary agent to use transcript evidence and screenshot OCR participant evidence to refine the meeting note attendee list conservatively, adding or removing attendees only when the evidence is clear.
@@ -133,11 +139,25 @@ When `delete_identity` is called with an existing transcript speaker identity, M
- **WHEN** the summary agent writes the summary or assistant context file
- **THEN** Meeting Assistant does not include those writes in the external write diff audit
#### Scenario: Assistant context body appends by default
- **WHEN** the summary agent writes assistant context content without line arguments
- **THEN** Meeting Assistant appends the content to the existing assistant context body
- **AND** preserves the existing assistant context frontmatter
#### Scenario: Assistant context body can be explicitly replaced
- **WHEN** the summary agent writes assistant context content with `replace_file` set to true
- **THEN** Meeting Assistant replaces the assistant context body
- **AND** preserves the existing assistant context frontmatter
#### Scenario: Assistant context body writes do not duplicate frontmatter
- **WHEN** the summary agent writes assistant context content that accidentally includes a markdown frontmatter block
- **THEN** Meeting Assistant preserves the existing assistant context frontmatter
- **AND** writes only the replacement body content after that frontmatter
#### Scenario: Assistant context write mode is ambiguous
- **WHEN** the summary agent combines `replace_file` with `from`, `to`, or `insert`
- **THEN** Meeting Assistant refuses the assistant context write
#### Scenario: Summary agent refines attendees from clear participant evidence
- **GIVEN** assistant context OCR states that visible meeting participants are complete
- **AND** the OCR result names a participant missing from the meeting note
@@ -27,6 +27,8 @@ For real meeting recordings, Meeting Assistant SHALL derive the requested speake
The configured pipeline SHALL receive audio chunks as they are captured rather than requiring the recording coordinator to finish a complete meeting audio file first.
On application startup, Meeting Assistant SHALL start non-blocking warm-up for the default launch profile's speech recognition pipeline and for any named launch profile that selects the managed FunASR provider.
#### Scenario: Streaming provider receives live audio
- **WHEN** recording mode is active and the combined audio source emits a chunk
- **THEN** Meeting Assistant can pass that chunk to the configured speech recognition pipeline before the audio source completes
@@ -63,6 +65,16 @@ The configured pipeline SHALL receive audio chunks as they are captured rather t
- **WHEN** a meeting note has zero or one entry in `attendees`
- **THEN** Meeting Assistant leaves the finished-transcript speaker count hint unset
#### Scenario: Launch profile FunASR pipeline warms on startup
- **GIVEN** a named launch profile selects `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it begins warming that profile's speech recognition pipeline without blocking startup
#### Scenario: Non-default non-FunASR profile is not warmed
- **GIVEN** a named launch profile selects a provider other than `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it does not create a startup warm-up pipeline for that named profile
### Requirement: Version 1 keeps local Whisper transcription available
Meeting Assistant SHALL provide a local Whisper speech recognition pipeline as a fallback ASR backend for version 1.
@@ -435,3 +447,62 @@ The replacement speech recognition pipeline SHALL use the target launch profile'
- **WHEN** the active transcription profile is switched to another launch profile
- **THEN** the new speech recognition pipeline is created from the target profile options
- **AND** it receives audio captured after the switch request and audio buffered during the switch
### Requirement: Speaker identity samples require uninterrupted speech
Meeting Assistant SHALL only retain speaker identity samples after a diarized speaker has produced at least the configured minimum duration of uninterrupted speech.
The default minimum uninterrupted speech duration SHALL be 30 seconds.
Meeting Assistant MAY combine adjacent transcript segments for the same diarized speaker into one sample span when no different diarized speaker interrupts the span.
When a different diarized speaker appears before the configured minimum duration is reached, Meeting Assistant SHALL discard the pending sample span for the previous speaker.
#### Scenario: Short speaker span is not retained
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces only 20 seconds of uninterrupted speech
- **THEN** Meeting Assistant does not retain a speaker identity sample for that span
#### Scenario: Adjacent same-speaker segments form a sample
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces adjacent segments totaling at least 30 seconds without another speaker interrupting
- **THEN** Meeting Assistant retains one speaker identity sample covering the continuous span
#### Scenario: Different speaker interrupts pending span
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** `Guest01` speaks for 20 seconds and then `Guest02` speaks
- **THEN** Meeting Assistant discards the pending `Guest01` span instead of retaining or later extending it
### Requirement: Speaker identity matching can use pyannote secondary validation
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected.
When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match.
If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match.
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
When pyannote secondary validation is enabled, Meeting Assistant SHALL start a non-blocking startup warm-up that builds or verifies the configured pyannote runtime image and downloads the configured model into the persistent model cache before the first validation request when possible.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
- **THEN** Meeting Assistant does not retain that sample for identity matching
#### Scenario: Pyannote rejects primary match
- **GIVEN** pyannote secondary validation is enabled
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers
- **THEN** Meeting Assistant rejects the match
#### Scenario: Disabled pyannote validation preserves primary match
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation
#### Scenario: Pyannote validation warms up on startup
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** Meeting Assistant starts
- **THEN** it begins preparing the configured pyannote runtime image and model cache without waiting for the first validation request
- **AND** application startup is not blocked by the warm-up task