Author SHA1 Message Date
renovate-bot 49d7c840a5 Update MicrosoftSpeechVersion to 1.51.1
PR and Push Build/Test / build-and-test (pull_request) Failing after 10m24s
PR and Push Build/Test / build-and-test (push) Successful in 10m16s
2026-07-31 02:39:43 +00:00
codex 75250f6041 Merge pull request 'Update dependency SQLitePCLRaw.bundle_e_sqlite3 to 3.0.5' (#35) from renovate/sqlitepclraw.bundle_e_sqlite3-3.x into main
PR and Push Build/Test / build-and-test (push) Successful in 10m19s
2026-07-30 15:42:00 +02:00
renovate-bot cd2446f620 Update dependency SQLitePCLRaw.bundle_e_sqlite3 to 3.0.5
PR and Push Build/Test / build-and-test (pull_request) Successful in 10m58s
PR and Push Build/Test / build-and-test (push) Successful in 13m32s
2026-07-28 02:38:03 +00:00
codex e192ae7cd8 fix: support LiteLLM Responses streaming
PR and Push Build/Test / build-and-test (push) Successful in 14m17s
2026-07-27 14:44:32 +02:00
codex 3cadf08fa2 feat: use assistant context as meeting memory
PR and Push Build/Test / build-and-test (push) Successful in 9m34s
2026-07-17 11:26:49 +02:00
Manuel 407db80413 Merge pull request 'Update dependency Microsoft.NET.Test.Sdk to 18.8.1' (#33) from renovate/microsoft.net.test.sdk-18.x into main
PR and Push Build/Test / build-and-test (push) Successful in 13m56s
2026-07-16 18:50:55 +02:00
renovate-bot f0aac40dfb Update dependency Microsoft.NET.Test.Sdk to 18.8.1
PR and Push Build/Test / build-and-test (push) Successful in 9m38s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m28s
2026-07-16 02:36:52 +00:00
codex 0e0feedad2 Fix YAML escaping for meeting note frontmatter
PR and Push Build/Test / build-and-test (push) Successful in 10m21s
2026-07-15 14:55:37 +02:00
codex 89d81fa4c6 fix: encode spaces in summary image links
PR and Push Build/Test / build-and-test (push) Successful in 11m2s
2026-07-15 14:55:25 +02:00
Manuel dc49bc3330 Merge pull request 'Update dependency System.Drawing.Common to 10.0.10' (#32) from renovate/system.drawing.common-10.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m38s
2026-07-15 13:29:06 +02:00
Manuel aff3528406 Merge pull request 'Update dependency Microsoft.EntityFrameworkCore.Sqlite to 10.0.10' (#31) from renovate/microsoft.entityframeworkcore.sqlite-10.x into main
PR and Push Build/Test / build-and-test (push) Failing after 9m50s
2026-07-15 13:27:51 +02:00
Manuel d92da18d08 Merge pull request 'Update dependency Microsoft.AspNetCore.Mvc.Testing to 10.0.10' (#30) from renovate/microsoft.aspnetcore.mvc.testing-10.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m41s
2026-07-15 13:27:20 +02:00
renovate-bot ec59464340 Update dependency System.Drawing.Common to 10.0.10
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m19s
PR and Push Build/Test / build-and-test (push) Successful in 9m33s
2026-07-15 09:58:37 +00:00
renovate-bot 5667102272 Update dependency Microsoft.EntityFrameworkCore.Sqlite to 10.0.10
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m33s
PR and Push Build/Test / build-and-test (push) Successful in 9m9s
2026-07-15 02:35:11 +00:00
renovate-bot 2f62ed467a Update dependency Microsoft.AspNetCore.Mvc.Testing to 10.0.10
PR and Push Build/Test / build-and-test (push) Successful in 9m8s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m15s
2026-07-15 02:35:02 +00:00
Manuel 0439a819b2 Merge pull request 'Update actions/checkout action to v7' (#22) from renovate/actions-checkout-7.x into main
PR and Push Build/Test / build-and-test (push) Successful in 11m32s
Reviewed-on: #22
2026-07-05 01:43:27 +02:00
Manuel 2cfe4e4ef0 Merge pull request 'Update dependency Microsoft.Agents.AI.OpenAI to 1.13.0' (#28) from renovate/microsoft.agents.ai.openai-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 11m11s
Reviewed-on: #28
2026-07-05 01:39:59 +02:00
renovate-bot b5ccf2125c Update dependency Microsoft.Agents.AI.OpenAI to 1.13.0
PR and Push Build/Test / build-and-test (push) Successful in 10m18s
PR and Push Build/Test / build-and-test (pull_request) Successful in 10m8s
2026-07-04 02:33:36 +00:00
codex 0a7fc240d9 Merge pull request 'Update dependency NCalcSync to 6.4.0' (#29) from renovate/ncalcsync-6.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m38s
2026-07-03 13:26:22 +02:00
renovate-bot d81c2731da Update actions/checkout action to v7
PR and Push Build/Test / build-and-test (push) Failing after 9m39s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m50s
2026-07-03 02:33:23 +00:00
renovate-bot aa8ac26bbb Update dependency NCalcSync to 6.4.0
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m46s
PR and Push Build/Test / build-and-test (push) Successful in 9m42s
2026-07-03 02:33:17 +00:00
codex 6ee2d0ea08 Merge pull request 'Update dependency NCalcSync to 6.3.3' (#27) from renovate/ncalcsync-6.x into main
PR and Push Build/Test / build-and-test (push) Successful in 14m17s
2026-07-02 10:10:32 +02:00
codex e8742f544d Merge pull request 'Update dependency YamlDotNet to 18.1.0' (#26) from renovate/yamldotnet-18.x into main
PR and Push Build/Test / build-and-test (push) Successful in 10m12s
2026-07-02 10:10:07 +02:00
renovate-bot 18bed0532a Update dependency NCalcSync to 6.3.3
PR and Push Build/Test / build-and-test (push) Successful in 9m54s
PR and Push Build/Test / build-and-test (pull_request) Successful in 10m1s
2026-07-02 02:32:16 +00:00
codex 8e2f266e66 Close stale transcript workflow follow-up
PR and Push Build/Test / build-and-test (push) Successful in 9m35s
2026-07-01 11:44:07 +02:00
codex 5c67738939 Add attendee transformation workflows 2026-07-01 11:10:36 +02:00
codex 92e359646b Update meeting summary agent UI 2026-07-01 10:30:28 +02:00
renovate-bot c6b2add4dd Update dependency YamlDotNet to 18.1.0
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m21s
2026-06-27 02:30:48 +00:00
codex 4787bf8cec Upgrade NuGet dependencies
PR and Push Build/Test / build-and-test (push) Successful in 9m11s
2026-06-26 13:31:53 +02:00
codex aecef30627 Archive completed meeting assistant changes
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
2026-06-26 13:15:47 +02:00
codex 7d16b0cad7 Merge pull request 'Update dependency Microsoft.Agents.AI.OpenAI to 1.11.1' (#25) from renovate/microsoft.agents.ai.openai-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m57s
2026-06-26 11:17:28 +02:00
codex 91ce5f5aa0 Merge pull request 'Update dependency Microsoft.AspNetCore.Mvc.Testing to 10.0.9' (#19) from renovate/microsoft.aspnetcore.mvc.testing-10.x into main
PR and Push Build/Test / build-and-test (push) Failing after 9m25s
2026-06-26 11:17:21 +02:00
renovate-bot 19d7c28dcc Update dependency Microsoft.AspNetCore.Mvc.Testing to 10.0.9
PR and Push Build/Test / build-and-test (push) Successful in 9m5s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m13s
2026-06-26 02:31:45 +00:00
renovate-bot 9729e644af Update dependency Microsoft.Agents.AI.OpenAI to 1.11.1
PR and Push Build/Test / build-and-test (push) Successful in 9m4s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m15s
2026-06-26 02:31:35 +00:00
codex c8519796f1 Merge pull request 'Update dependency System.Drawing.Common to 10.0.9' (#21) from renovate/system.drawing.common-10.x into main
PR and Push Build/Test / build-and-test (push) Successful in 10m6s
2026-06-25 10:37:57 +02:00
codex 60a162ea1e Merge pull request 'Update dependency Microsoft.NET.Test.Sdk to 18.7.0' (#24) from renovate/microsoft.net.test.sdk-18.x into main
PR and Push Build/Test / build-and-test (push) Successful in 11m6s
2026-06-25 10:33:24 +02:00
codex 0986619712 Merge pull request 'Update dependency Microsoft.Agents.AI.OpenAI to 1.11.0' (#23) from renovate/microsoft.agents.ai.openai-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 10m18s
2026-06-25 10:32:32 +02:00
renovate-bot eae4db62e8 Update dependency Microsoft.NET.Test.Sdk to 18.7.0
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m0s
PR and Push Build/Test / build-and-test (push) Successful in 9m20s
2026-06-24 02:33:26 +00:00
renovate-bot 077c8ef4cf Update dependency Microsoft.Agents.AI.OpenAI to 1.11.0
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m21s
2026-06-24 02:33:21 +00:00
codex e0d5b96ca1 Add project sync guidance for corrected notes
PR and Push Build/Test / build-and-test (push) Failing after 9m54s
2026-06-22 10:46:28 +02:00
renovate-bot 144ae57204 Update dependency System.Drawing.Common to 10.0.9
PR and Push Build/Test / build-and-test (push) Successful in 8m26s
PR and Push Build/Test / build-and-test (pull_request) Successful in 8m19s
2026-06-19 02:32:21 +00:00
codex c64823ef46 Merge pull request 'Update dependency Whisper.net.Runtime to 1.9.1' (#15) from renovate/whisper.net.runtime-1.x into main
PR and Push Build/Test / build-and-test (push) Failing after 9m36s
2026-06-18 11:08:20 +02:00
codex b774ccc375 Add resilient Azure offline transcription backlog
PR and Push Build/Test / build-and-test (push) Successful in 18m41s
2026-06-15 17:26:34 +02:00
renovate-bot 65507a85ce Update dependency Whisper.net.Runtime to 1.9.1
PR and Push Build/Test / build-and-test (push) Successful in 9m8s
PR and Push Build/Test / build-and-test (pull_request) Successful in 9m22s
2026-06-12 02:31:26 +00:00
codex b22754ce5d Normalize scalar frontmatter lists
PR and Push Build/Test / build-and-test (push) Successful in 8m49s
2026-06-11 16:00:05 +02:00
codex bd786426bf Stabilize abort coordinator test
PR and Push Build/Test / build-and-test (push) Successful in 9m20s
2026-06-11 15:58:50 +02:00
codex 86e3efa3f6 Stabilize recording coordinator timing tests
PR and Push Build/Test / build-and-test (push) Failing after 9m40s
2026-06-11 15:42:05 +02:00
codex 1f57cb98b7 Merge pull request 'Update dependency Whisper.net to 1.9.1' (#14) from renovate/whisper.net-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m8s
2026-06-11 09:33:39 +02:00
codex 0f5692c173 Merge pull request 'Update dependency Microsoft.EntityFrameworkCore.Sqlite to 10.0.9' (#20) from renovate/microsoft.entityframeworkcore.sqlite-10.x into main
PR and Push Build/Test / build-and-test (push) Failing after 8m54s
2026-06-11 09:27:05 +02:00
codex 5233db80a8 Merge pull request 'Update dependency Microsoft.Agents.AI.OpenAI to 1.10.0' (#16) from renovate/microsoft.agents.ai.openai-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m9s
2026-06-11 09:26:58 +02:00
renovate-bot b7c01f1dfd Update dependency Microsoft.Agents.AI.OpenAI to 1.10.0
PR and Push Build/Test / build-and-test (push) Successful in 8m46s
PR and Push Build/Test / build-and-test (pull_request) Successful in 8m48s
2026-06-11 02:31:00 +00:00
codex e54b19d3a9 Use consistent meeting artifact filenames
PR and Push Build/Test / build-and-test (push) Successful in 10m14s
2026-06-10 16:56:10 +02:00
codex 5bbfd7b3c3 docs: refresh meeting assistant README
PR and Push Build/Test / build-and-test (push) Successful in 15m14s
2026-06-10 09:10:35 +02:00
renovate-bot bb82093980 Update dependency Microsoft.EntityFrameworkCore.Sqlite to 10.0.9
PR and Push Build/Test / build-and-test (push) Successful in 13m17s
PR and Push Build/Test / build-and-test (pull_request) Successful in 10m1s
2026-06-10 02:33:55 +00:00
codex ecdd23bde7 Handle settings logs agent failures
PR and Push Build/Test / build-and-test (push) Successful in 9m8s
2026-06-09 17:10:06 +02:00
renovate-bot 6b3b5ba7f3 Update dependency Whisper.net to 1.9.1
PR and Push Build/Test / build-and-test (push) Successful in 8m51s
PR and Push Build/Test / build-and-test (pull_request) Successful in 8m49s
2026-06-06 02:31:03 +00:00
codex 8af89a622d Fix taskbar glyph bounds assertion
PR and Push Build/Test / build-and-test (push) Successful in 9m23s
2026-06-05 11:28:04 +02:00
codex ed2eb36d07 Harden CI-sensitive recording and taskbar tests
PR and Push Build/Test / build-and-test (push) Failing after 9m57s
2026-06-05 11:15:46 +02:00
codex 1a89382f02 Improve toast duration and speaker override samples
PR and Push Build/Test / build-and-test (push) Failing after 9m48s
2026-06-05 10:52:08 +02:00
codex 351ed2eb50 Fix workflow rule validation logging
PR and Push Build/Test / build-and-test (push) Failing after 9m19s
2026-06-03 17:33:12 +02:00
codex 40853115c5 Add transcript line workflow rules
PR and Push Build/Test / build-and-test (push) Failing after 13m27s
2026-06-03 11:24:11 +02:00
codex 50daa88389 Add Outlook Teams recording prompts 2026-06-03 10:45:20 +02:00
codex edade8ab62 Add meeting inactivity safeguards
PR and Push Build/Test / build-and-test (push) Failing after 14m53s
2026-06-03 08:30:15 +02:00
codex efe7c4ba0a Change English profile hotkey 2026-06-03 07:17:00 +02:00
DEEUSEW\DESCMANU 8a0b302f1d Keep recording startup independent of dictation words
PR and Push Build/Test / build-and-test (push) Failing after 8m52s
2026-06-02 13:27:10 +02:00
codex 0e9d525b63 Add inactivity safeguard and speaker diagnostics
PR and Push Build/Test / build-and-test (push) Failing after 8m31s
2026-06-02 13:16:02 +02:00
codex c56ecb6ab3 Archive settings and logs agent change 2026-06-02 11:01:40 +02:00
codex 3aed1c8f92 Use bundled project agents template 2026-06-02 10:59:16 +02:00
codex e3f4e87319 Prevent sampleless speaker identities
PR and Push Build/Test / build-and-test (push) Successful in 8m38s
2026-06-02 10:39:00 +02:00
codex a3bad1bdd4 Validate workflow rule writes 2026-06-02 10:22:15 +02:00
codex e9825f5bf5 Improve settings logs agent and renderer
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
2026-06-02 00:09:03 +02:00
codex 11c65ce669 Expand settings and logs agent tools
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
2026-06-01 21:07:08 +02:00
codex abac46c691 Add repo TDD skill
PR and Push Build/Test / build-and-test (push) Successful in 15m19s
2026-05-30 22:02:05 +02:00
codex fd8c33f1ea Fix settings log tail reads
PR and Push Build/Test / build-and-test (push) Failing after 12m50s
2026-05-30 14:25:35 +02:00
DEEUSEW\DESCMANU 0342cff64f Fix dictionary path to use vault-relative config
PR and Push Build/Test / build-and-test (push) Failing after 15m40s
2026-05-30 13:23:30 +02:00
codex 250d3b7a1e Generalize settings and logs assistant
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
2026-05-30 12:57:51 +02:00
codex 740f93f185 Improve meeting audio mixing pipeline
PR and Push Build/Test / build-and-test (push) Successful in 11m22s
2026-05-29 13:47:21 +02:00
codex 9d0f6a1295 Clarify subagent availability fallback
PR and Push Build/Test / build-and-test (push) Failing after 10m59s
2026-05-29 10:55:20 +02:00
codex 689df3dbcb Fix past meeting summary tool edge cases
PR and Push Build/Test / build-and-test (push) Successful in 10m54s
2026-05-29 10:38:31 +02:00
codex 626640e26b Add past project meeting summary tools
PR and Push Build/Test / build-and-test (push) Failing after 10m56s
2026-05-29 10:30:48 +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
codex c84f8a984a Merge pull request 'Update dependency YamlDotNet to v18' (#5) from renovate/yamldotnet-18.x into main
PR and Push Build/Test / build-and-test (push) Successful in 7m12s
2026-05-28 09:43:21 +02:00
codex ed803ee92f Merge pull request 'Update dependency System.Drawing.Common to 10.0.8' (#9) from renovate/system.drawing.common-10.x into main
PR and Push Build/Test / build-and-test (push) Successful in 7m5s
2026-05-28 09:43:14 +02:00
codex 69d8d04fbe Merge pull request 'Update dependency Microsoft.NET.Test.Sdk to 18.6.0' (#8) from renovate/microsoft.net.test.sdk-18.x into main 2026-05-28 09:36:33 +02:00
codex eaee21318d Merge pull request 'Update actions/setup-dotnet action to v5' (#11) from renovate/actions-setup-dotnet-5.x into main
PR and Push Build/Test / build-and-test (push) Successful in 7m30s
2026-05-28 09:28:34 +02:00
codex 1e2c56b588 Merge pull request 'Update actions/checkout action to v6' (#10) from renovate/actions-checkout-6.x into main
PR and Push Build/Test / build-and-test (push) Successful in 8m48s
2026-05-28 09:28:24 +02:00
renovate-bot 7a5cf7972d Update dependency YamlDotNet to v18
PR and Push Build/Test / build-and-test (push) Successful in 7m26s
PR and Push Build/Test / build-and-test (pull_request) Successful in 7m32s
2026-05-28 02:24:44 +00:00
renovate-bot ce3c7a0c49 Update dependency System.Drawing.Common to 10.0.8
PR and Push Build/Test / build-and-test (push) Successful in 7m3s
PR and Push Build/Test / build-and-test (pull_request) Successful in 6m54s
2026-05-28 02:24:32 +00:00
codex 693f52afee Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
2026-05-28 01:28:16 +02:00
codex 71f1e6a0b8 Add taskbar controls and summary oneliner 2026-05-27 23:31:40 +02:00
renovate-bot ecc7d09d88 Update actions/setup-dotnet action to v5
PR and Push Build/Test / build-and-test (pull_request) Successful in 6m59s
PR and Push Build/Test / build-and-test (push) Successful in 6m57s
2026-05-27 21:26:57 +00:00
codex 7777b349a5 Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
2026-05-27 23:11:21 +02:00
renovate-bot 1e98b2384d Update actions/checkout action to v6
PR and Push Build/Test / build-and-test (push) Successful in 6m56s
PR and Push Build/Test / build-and-test (pull_request) Successful in 6m49s
2026-05-27 19:25:48 +00:00
renovate-bot f5faa2858e Update dependency Microsoft.NET.Test.Sdk to 18.6.0
PR and Push Build/Test / build-and-test (push) Successful in 5m48s
PR and Push Build/Test / build-and-test (pull_request) Successful in 5m40s
2026-05-27 02:25:37 +00:00
279 changed files with 30262 additions and 2388 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"
+107
View File
@@ -0,0 +1,107 @@
---
name: tdd
description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.
---
# Test-Driven Development
## Philosophy
**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.
**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.
**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior.
See [tests.md](references/tests.md) for examples and [mocking.md](references/mocking.md) for mocking guidelines.
## Anti-Pattern: Horizontal Slices
**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code."
This produces **crap tests**:
- Tests written in bulk test _imagined_ behavior, not _actual_ behavior
- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
- You outrun your headlights, committing to test structure before understanding the implementation
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
```
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
...
```
## Workflow
### 1. Planning
Before writing any code:
- [ ] Confirm with user what interface changes are needed
- [ ] Confirm with user which behaviors to test (prioritize)
- [ ] Identify opportunities for [deep modules](references/deep-modules.md) (small interface, deep implementation)
- [ ] Design interfaces for [testability](references/interface-design.md)
- [ ] List the behaviors to test (not implementation steps)
- [ ] Get user approval on the plan
Ask: "What should the public interface look like? Which behaviors are most important to test?"
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
### 2. Tracer Bullet
Write ONE test that confirms ONE thing about the system:
```
RED: Write test for first behavior → test fails
GREEN: Write minimal code to pass → test passes
```
This is your tracer bullet - proves the path works end-to-end.
### 3. Incremental Loop
For each remaining behavior:
```
RED: Write next test → fails
GREEN: Minimal code to pass → passes
```
Rules:
- One test at a time
- Only enough code to pass current test
- Don't anticipate future tests
- Keep tests focused on observable behavior
### 4. Refactor
After all tests pass, look for [refactor candidates](references/refactoring.md):
- [ ] Extract duplication
- [ ] Deepen modules (move complexity behind simple interfaces)
- [ ] Apply SOLID principles where natural
- [ ] Consider what new code reveals about existing code
- [ ] Run tests after each refactor step
**Never refactor while RED.** Get to GREEN first.
## Checklist Per Cycle
```
[ ] Test describes behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added
```
@@ -0,0 +1,33 @@
# Deep Modules
From "A Philosophy of Software Design":
**Deep module** = small interface + lots of implementation
```
┌─────────────────────┐
│ Small Interface │ ← Few methods, simple params
├─────────────────────┤
│ │
│ │
│ Deep Implementation│ ← Complex logic hidden
│ │
│ │
└─────────────────────┘
```
**Shallow module** = large interface + little implementation (avoid)
```
┌─────────────────────────────────┐
│ Large Interface │ ← Many methods, complex params
├─────────────────────────────────┤
│ Thin Implementation │ ← Just passes through
└─────────────────────────────────┘
```
When designing interfaces, ask:
- Can I reduce the number of methods?
- Can I simplify the parameters?
- Can I hide more complexity inside?
@@ -0,0 +1,31 @@
# Interface Design for Testability
Good interfaces make testing natural:
1. **Accept dependencies, don't create them**
```typescript
// Testable
function processOrder(order, paymentGateway) {}
// Hard to test
function processOrder(order) {
const gateway = new StripeGateway();
}
```
2. **Return results, don't produce side effects**
```typescript
// Testable
function calculateDiscount(cart): Discount {}
// Hard to test
function applyDiscount(cart): void {
cart.total -= discount;
}
```
3. **Small surface area**
- Fewer methods = fewer tests needed
- Fewer params = simpler test setup
+59
View File
@@ -0,0 +1,59 @@
# When to Mock
Mock at **system boundaries** only:
- External APIs (payment, email, etc.)
- Databases (sometimes - prefer test DB)
- Time/randomness
- File system (sometimes)
Don't mock:
- Your own classes/modules
- Internal collaborators
- Anything you control
## Designing for Mockability
At system boundaries, design interfaces that are easy to mock:
**1. Use dependency injection**
Pass external dependencies in rather than creating them internally:
```typescript
// Easy to mock
function processPayment(order, paymentClient) {
return paymentClient.charge(order.total);
}
// Hard to mock
function processPayment(order) {
const client = new StripeClient(process.env.STRIPE_KEY);
return client.charge(order.total);
}
```
**2. Prefer SDK-style interfaces over generic fetchers**
Create specific functions for each external operation instead of one generic function with conditional logic:
```typescript
// GOOD: Each function is independently mockable
const api = {
getUser: (id) => fetch(`/users/${id}`),
getOrders: (userId) => fetch(`/users/${userId}/orders`),
createOrder: (data) => fetch('/orders', { method: 'POST', body: data }),
};
// BAD: Mocking requires conditional logic inside the mock
const api = {
fetch: (endpoint, options) => fetch(endpoint, options),
};
```
The SDK approach means:
- Each mock returns one specific shape
- No conditional logic in test setup
- Easier to see which endpoints a test exercises
- Type safety per endpoint
@@ -0,0 +1,10 @@
# Refactor Candidates
After TDD cycle, look for:
- **Duplication** → Extract function/class
- **Long methods** → Break into private helpers (keep tests on public interface)
- **Shallow modules** → Combine or deepen
- **Feature envy** → Move logic to where data lives
- **Primitive obsession** → Introduce value objects
- **Existing code** the new code reveals as problematic
+61
View File
@@ -0,0 +1,61 @@
# Good and Bad Tests
## Good Tests
**Integration-style**: Test through real interfaces, not mocks of internal parts.
```typescript
// GOOD: Tests observable behavior
test("user can checkout with valid cart", async () => {
const cart = createCart();
cart.add(product);
const result = await checkout(cart, paymentMethod);
expect(result.status).toBe("confirmed");
});
```
Characteristics:
- Tests behavior users/callers care about
- Uses public API only
- Survives internal refactors
- Describes WHAT, not HOW
- One logical assertion per test
## Bad Tests
**Implementation-detail tests**: Coupled to internal structure.
```typescript
// BAD: Tests implementation details
test("checkout calls paymentService.process", async () => {
const mockPayment = jest.mock(paymentService);
await checkout(cart, payment);
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
});
```
Red flags:
- Mocking internal collaborators
- Testing private methods
- Asserting on call counts/order
- Test breaks when refactoring without behavior change
- Test name describes HOW not WHAT
- Verifying through external means instead of interface
```typescript
// BAD: Bypasses interface to verify
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
// GOOD: Verifies through interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
```
+2 -2
View File
@@ -15,10 +15,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
+8 -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.
@@ -56,12 +57,14 @@ A spec/change is not considered done merely because code is merged, tests pass,
Before archiving any OpenSpec change, perform a refactoring pass over the code and specs touched by that change. The pass must inspect both the current diff and the surrounding implementation context, because a small diff may reveal repeated patterns or structural problems that only become obvious when compared with nearby code.
Do the refactoring pass in these distinct areas, first start a subagent for each area to identify potential improvements in parallel, then implement them:
Do the refactoring pass in these distinct areas in sequence so they are less likely to converge on the same issues, first start a subagent (`Explorer`) to identify potential improvements, then implement them, then start the repeat for the next area:
1. Check for DRYness. Look for duplication introduced by the change and for existing nearby duplication that the change now makes worth consolidating. A change may be small on its own, but if it is the fifth copy of the same idea, it is a refactoring target.
2. Check for SOLID violations. Look for responsibilities that are mixed together, abstractions that are hard to replace or test, interface shapes that force unrelated dependencies, and code paths that require modifying stable code for each new variant.
3. Check whether the implementation can be made simpler under KISS. Remove accidental abstractions, reduce branching, clarify names, and prefer the smallest structure that still supports the tested behavior and current spec.
Treat these instructions as user instructions, and do not skip or shortcut them. If you find that you cannot follow these instructions, state exactly which part you are having trouble with and why.
Preserve behavior during this pass and run the relevant tests again afterward.
For Meeting Assistant, prefer verification through:
@@ -77,6 +80,10 @@ If production or end-to-end verification is not possible, state exactly why and
## Implementation Notes
Meeting Assistant is Manuel's local meeting capture and knowledge system. It runs as a background .NET service with a Windows tray icon, captures microphone plus system audio, transcribes meetings, maintains Obsidian meeting artifacts, enriches meetings from Outlook metadata when available, applies local workflow rules, identifies speakers, captures screenshots, and runs agentic summarization against a LiteLLM/OpenAI-compatible endpoint.
The app is used during real meetings. Treat active recording, transcription finalization, OCR, and summarization as live user work. Prefer local endpoints, generated note files, and application logs for verification, and avoid restart or cleanup actions that could interrupt an active run unless the user explicitly requested them.
Keep source and deployment ownership separate:
- `Manuel/meeting-assistant` owns application source, tests, build configuration, container image publishing, and OpenSpec source changes.
@@ -42,7 +42,7 @@ public sealed class AsrDiagnosticEndpointTests
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+L",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
});
});
+227 -15
View File
@@ -6,40 +6,129 @@ namespace MeetingAssistant.Tests;
public sealed class AudioMixingTests
{
[Fact]
public async Task CompositeAudioSourceMixesMicrophoneAndSystemAudioIntoOnePcmStream()
public async Task CompositeAudioSourceCleansMicrophoneBeforeAddingSystemAudio()
{
var microphone = new FixedAudioSource(Pcm16(10_000));
var system = new FixedAudioSource(Pcm16(20_000));
var source = new CompositeMeetingAudioSource(
microphone,
system,
NullLogger<CompositeMeetingAudioSource>.Instance);
var microphone = new FixedAudioSource(Pcm16(12_000));
var system = new FixedAudioSource(Pcm16(10_000));
var source = CreateSource(microphone, system, new FixedEchoCanceller(Pcm16(2_000)));
var chunks = await ReadChunks(source);
Assert.Single(chunks);
Assert.Equal(30_000, BitConverter.ToInt16(chunks[0].Pcm));
Assert.Equal(12_000, BitConverter.ToInt16(chunks[0].Pcm));
}
[Fact]
public async Task CompositeAudioSourceClampsMixedSamples()
public async Task CompositeAudioSourceClampsAfterConfiguredGain()
{
var microphone = new FixedAudioSource(Pcm16(30_000));
var system = new FixedAudioSource(Pcm16(10_000));
var source = new CompositeMeetingAudioSource(
microphone,
system,
NullLogger<CompositeMeetingAudioSource>.Instance);
var source = CreateSource(microphone, system);
var chunks = await ReadChunks(source);
Assert.Equal(short.MaxValue, BitConverter.ToInt16(chunks[0].Pcm));
}
private static async Task<IReadOnlyList<AudioChunk>> ReadChunks(IMeetingAudioSource source)
[Fact]
public async Task CompositeAudioSourceUsesRunOptionsForChildSourcesAndGains()
{
var microphone = new OptionsCapturingAudioSource(Pcm16(10_000));
var system = new OptionsCapturingAudioSource(Pcm16(10_000));
var source = CreateSource(microphone, system);
var options = new MeetingAssistantOptions
{
Recording =
{
SampleRate = 24000,
Channels = 1,
MicrophoneMixGain = 0.5,
SystemAudioMixGain = 0.25
}
};
var chunks = await ReadChunks(source, options);
var chunk = Assert.Single(chunks);
Assert.Equal(7_500, BitConverter.ToInt16(chunk.Pcm));
Assert.Same(options, microphone.CapturedOptions);
Assert.Same(options, system.CapturedOptions);
}
[Fact]
public async Task CompositeAudioSourceWaitsForMatchingStreamsBeforeMixing()
{
var microphone = new DelayedAudioSource(TimeSpan.Zero, Pcm16(2_000));
var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(100), Pcm16(10_000));
var source = CreateSource(microphone, system);
var chunks = await ReadChunks(source);
var chunk = Assert.Single(chunks);
Assert.Equal(12_000, BitConverter.ToInt16(chunk.Pcm));
}
[Fact]
public async Task CompositeAudioSourceEmitsMicrophoneWithSilentSystemWhenSystemAudioIsQuiet()
{
var microphone = new DelayedAudioSource(TimeSpan.Zero, Pcm16(2_000));
var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(500), Pcm16(10_000));
var source = CreateSource(microphone, system);
var chunks = await ReadChunks(source);
Assert.Collection(
chunks,
first =>
{
Assert.Equal(2_000, BitConverter.ToInt16(first.Pcm));
},
second =>
{
Assert.Equal(10_000, BitConverter.ToInt16(second.Pcm));
});
}
[Fact]
public async Task CompositeAudioSourceFlushesContinuouslyArrivingMicrophoneBeforeLateSystemAudio()
{
var microphone = new RepeatedAudioSource(50, TimeSpan.FromMilliseconds(10), Pcm16(2_000));
var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(750), Pcm16(10_000));
var source = CreateSource(microphone, system);
var chunks = await ReadChunks(source);
Assert.NotEmpty(chunks);
Assert.Equal(2_000, BitConverter.ToInt16(chunks[0].Pcm));
}
[Fact]
public void AdaptiveEchoCancellerReducesEchoFromMicrophoneSignal()
{
var canceller = new AdaptiveFilterAcousticEchoCanceller();
var system = Enumerable.Repeat(Pcm16(8_000), 80).SelectMany(bytes => bytes).ToArray();
var microphone = Enumerable.Repeat(Pcm16(8_000), 80).SelectMany(bytes => bytes).ToArray();
AudioChunk cleaned = new(microphone, 16000, 1);
for (var i = 0; i < 40; i++)
{
cleaned = canceller.CleanMicrophone(
new AudioChunk(microphone, 16000, 1),
new AudioChunk(system, 16000, 1));
}
var average = Enumerable.Range(0, cleaned.Pcm.Length / sizeof(short))
.Select(index => (double)Math.Abs(BitConverter.ToInt16(cleaned.Pcm, index * sizeof(short))))
.Average();
Assert.True(average < 1_000);
}
private static async Task<IReadOnlyList<AudioChunk>> ReadChunks(
IMeetingAudioSource source,
MeetingAssistantOptions? options = null)
{
var chunks = new List<AudioChunk>();
await foreach (var chunk in source.CaptureAsync(CancellationToken.None))
await foreach (var chunk in source.CaptureAsync(options ?? new MeetingAssistantOptions(), CancellationToken.None))
{
chunks.Add(chunk);
}
@@ -52,6 +141,18 @@ public sealed class AudioMixingTests
return BitConverter.GetBytes(sample);
}
private static CompositeMeetingAudioSource CreateSource(
IMeetingAudioSource microphone,
IMeetingAudioSource system,
IAcousticEchoCanceller? echoCanceller = null)
{
return new CompositeMeetingAudioSource(
microphone,
system,
new FixedEchoCancellerFactory(echoCanceller ?? new NoopAcousticEchoCanceller()),
NullLogger<CompositeMeetingAudioSource>.Instance);
}
private sealed class FixedAudioSource : IMeetingAudioSource
{
private readonly AudioChunk chunk;
@@ -68,4 +169,115 @@ public sealed class AudioMixingTests
yield return chunk;
}
}
private sealed class DelayedAudioSource : IMeetingAudioSource
{
private readonly TimeSpan delay;
private readonly AudioChunk chunk;
public DelayedAudioSource(TimeSpan delay, byte[] pcm)
{
this.delay = delay;
chunk = new AudioChunk(pcm, 16000, 1);
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken);
}
yield return chunk;
}
}
private sealed class RepeatedAudioSource : IMeetingAudioSource
{
private readonly int count;
private readonly TimeSpan interval;
private readonly AudioChunk chunk;
public RepeatedAudioSource(int count, TimeSpan interval, byte[] pcm)
{
this.count = count;
this.interval = interval;
chunk = new AudioChunk(pcm, 16000, 1);
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
for (var index = 0; index < count; index++)
{
if (interval > TimeSpan.Zero)
{
await Task.Delay(interval, cancellationToken);
}
yield return chunk;
}
}
}
private sealed class OptionsCapturingAudioSource : IMeetingAudioSource
{
private readonly AudioChunk chunk;
public OptionsCapturingAudioSource(byte[] pcm)
{
chunk = new AudioChunk(pcm, 16000, 1);
}
public MeetingAssistantOptions? CapturedOptions { get; private set; }
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
{
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
MeetingAssistantOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
CapturedOptions = options;
yield return chunk;
}
}
private sealed class FixedEchoCanceller : IAcousticEchoCanceller
{
private readonly byte[] cleanedPcm;
public FixedEchoCanceller(byte[] cleanedPcm)
{
this.cleanedPcm = cleanedPcm;
}
public void Reset()
{
}
public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio)
{
return microphone with { Pcm = cleanedPcm };
}
}
private sealed class FixedEchoCancellerFactory : IAcousticEchoCancellerFactory
{
private readonly IAcousticEchoCanceller echoCanceller;
public FixedEchoCancellerFactory(IAcousticEchoCanceller echoCanceller)
{
this.echoCanceller = echoCanceller;
}
public IAcousticEchoCanceller Create()
{
return echoCanceller;
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,405 @@
using MeetingAssistant.Calendar;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class CalendarRecordingPromptSchedulerTests
{
[Fact]
public async Task DueTeamsMeetingPromptStartsRecordingWhenAccepted()
{
var harness = CreateHarness(isRecording: false);
var meeting = CreateMeeting(harness.Clock);
harness.Provider.Meetings = [meeting];
await SyncThenPromptAsync(harness, meeting);
Assert.Equal(1, harness.Provider.QueryCount);
Assert.Collection(harness.PromptService.PromptedMeetings, prompted => Assert.Same(meeting, prompted));
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Equal(0, harness.Recorder.StopCount);
}
[Fact]
public async Task AcceptedPromptStartsRecordingWithPromptedMeetingMetadata()
{
var harness = CreateHarness(isRecording: false);
var selectedMetadata = new MeetingMetadata(
"Selected planning",
["Ada"],
"Selected agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var otherMetadata = new MeetingMetadata(
"Other planning",
["Grace"],
"Other agenda",
DateTimeOffset.Parse("2026-06-03T10:40:00+00:00"));
var selectedMeeting = CreateMeeting(
harness.Clock,
id: "teams-selected",
subject: "Selected planning",
metadata: selectedMetadata);
var otherMeeting = CreateMeeting(
harness.Clock,
id: "teams-other",
subject: "Other planning",
startOffset: TimeSpan.FromMinutes(40),
metadata: otherMetadata);
harness.Provider.Meetings = [selectedMeeting, otherMeeting];
await SyncThenPromptAsync(harness, selectedMeeting);
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Same(selectedMetadata, harness.Recorder.StartMetadata.Single());
Assert.Equal(1, harness.Recorder.PromptStartCount);
}
[Fact]
public async Task AcceptedPromptWithoutMetadataStillUsesPromptStartPath()
{
var harness = CreateHarness(isRecording: false);
var meeting = CreateMeeting(
harness.Clock,
id: "teams-without-metadata",
subject: "No metadata",
metadata: null);
harness.Provider.Meetings = [meeting];
await SyncThenPromptAsync(harness, meeting);
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Null(harness.Recorder.StartMetadata.Single());
Assert.Equal(1, harness.Recorder.PromptStartCount);
}
[Fact]
public async Task AcceptingDisplayedPromptsOutOfOrderUsesAcceptedPromptMetadata()
{
var harness = CreateHarness(isRecording: false, autoAcceptPrompts: false);
var firstMetadata = new MeetingMetadata(
"First planning",
["Ada"],
"First agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var secondMetadata = new MeetingMetadata(
"Second planning",
["Grace"],
"Second agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var firstMeeting = CreateMeeting(
harness.Clock,
id: "teams-first",
subject: "First planning",
metadata: firstMetadata);
var secondMeeting = CreateMeeting(
harness.Clock,
id: "teams-second",
subject: "Second planning",
metadata: secondMetadata);
harness.Provider.Meetings = [firstMeeting, secondMeeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = firstMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
await harness.PromptService.RespondAsync(secondMeeting, MeetingStartPromptResponse.Record);
Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings);
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Same(secondMetadata, harness.Recorder.StartMetadata.Single());
}
[Fact]
public async Task AcceptedPromptStopsActiveRecordingBeforeStartingNewRecording()
{
var harness = CreateHarness(isRecording: true);
var meeting = CreateMeeting(harness.Clock);
harness.Provider.Meetings = [meeting];
await SyncThenPromptAsync(harness, meeting);
Assert.Equal(["stop", "start"], harness.Recorder.Commands);
}
[Fact]
public async Task CanceledCachedMeetingDoesNotPromptRecording()
{
var harness = CreateHarness(isRecording: false);
var canceledMeeting = CreateMeeting(
harness.Clock,
id: "teams-canceled",
subject: "Canceled project sync",
isCanceled: true);
harness.Provider.Meetings = [canceledMeeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = canceledMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
Assert.Empty(harness.PromptService.PromptedMeetings);
Assert.Equal(0, harness.Recorder.StartCount);
}
[Fact]
public async Task DisabledCalendarPromptsDoNotQueryCalendar()
{
var harness = CreateHarness(enabled: false);
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
Assert.Equal(0, harness.Provider.QueryCount);
}
[Fact]
public async Task DueMeetingPromptsOnlyOncePerDay()
{
var harness = CreateHarness();
var meeting = CreateMeeting(harness.Clock);
harness.Provider.Meetings = [meeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = meeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
Assert.Single(harness.PromptService.PromptedMeetings);
}
[Fact]
public async Task CachedMeetingPromptDoesNotQueryOutlookAgainAtMeetingStart()
{
var harness = CreateHarness();
var meeting = CreateMeeting(harness.Clock);
harness.Provider.Meetings = [meeting];
await SyncThenPromptAsync(harness, meeting);
Assert.Equal(1, harness.Provider.QueryCount);
Assert.Single(harness.PromptService.PromptedMeetings);
}
[Fact]
public async Task CachedDayMeetingsPromptBackToBackMeetingsWithoutInterveningOutlookSync()
{
var harness = CreateHarness(isRecording: false);
var firstMeeting = CreateMeeting(
harness.Clock,
id: "teams-1",
subject: "First sync",
startOffset: TimeSpan.FromMinutes(30),
duration: TimeSpan.FromMinutes(10));
var secondMeeting = CreateMeeting(
harness.Clock,
id: "teams-2",
subject: "Second sync",
startOffset: TimeSpan.FromMinutes(40),
duration: TimeSpan.FromMinutes(10));
harness.Provider.Meetings = [firstMeeting, secondMeeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = firstMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
harness.Clock.Now = secondMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
Assert.Equal(1, harness.Provider.QueryCount);
Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings);
Assert.Equal(["start", "stop", "start"], harness.Recorder.Commands);
}
private static async Task SyncThenPromptAsync(
SchedulerHarness harness,
CalendarMeeting meeting)
{
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = meeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
}
private static CalendarMeeting CreateMeeting(
ManualCalendarPromptClock clock,
string id = "teams-1",
string subject = "Project sync",
TimeSpan? startOffset = null,
TimeSpan? duration = null,
MeetingMetadata? metadata = null,
bool isCanceled = false)
{
var start = clock.Now.Add(startOffset ?? TimeSpan.FromMinutes(30));
return new CalendarMeeting(
id,
subject,
start,
start.Add(duration ?? TimeSpan.FromMinutes(30)),
metadata,
isCanceled);
}
private static SchedulerHarness CreateHarness(
bool isRecording = false,
bool enabled = true,
bool autoAcceptPrompts = true)
{
var clock = new ManualCalendarPromptClock(new DateTimeOffset(2026, 6, 3, 9, 30, 0, TimeSpan.Zero));
var provider = new RecordingCalendarMeetingProvider([]);
var promptService = new CapturingMeetingStartPromptService(autoAcceptPrompts);
var recorder = new RecordingPromptRecorder(isRecording);
var scheduler = new CalendarRecordingPromptScheduler(
Options.Create(new MeetingAssistantOptions
{
CalendarRecordingPrompts =
{
Enabled = enabled,
SyncInterval = TimeSpan.FromMinutes(30),
PromptWindow = TimeSpan.FromMinutes(5)
}
}),
provider,
promptService,
recorder,
clock,
NullLogger<CalendarRecordingPromptScheduler>.Instance);
return new SchedulerHarness(scheduler, provider, promptService, recorder, clock);
}
private sealed record SchedulerHarness(
CalendarRecordingPromptScheduler Scheduler,
RecordingCalendarMeetingProvider Provider,
CapturingMeetingStartPromptService PromptService,
RecordingPromptRecorder Recorder,
ManualCalendarPromptClock Clock);
private sealed class RecordingCalendarMeetingProvider : ICalendarMeetingProvider
{
public RecordingCalendarMeetingProvider(IReadOnlyList<CalendarMeeting> meetings)
{
Meetings = meetings;
}
public IReadOnlyList<CalendarMeeting> Meetings { get; set; }
public int QueryCount { get; private set; }
public Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken)
{
QueryCount++;
return Task.FromResult(Meetings);
}
}
private sealed class CapturingMeetingStartPromptService : IMeetingStartPromptService
{
private readonly bool autoAccept;
private readonly List<PendingPrompt> pendingPrompts = [];
public CapturingMeetingStartPromptService(bool autoAccept)
{
this.autoAccept = autoAccept;
}
public List<CalendarMeeting> PromptedMeetings { get; } = [];
public async Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
PromptedMeetings.Add(request.Meeting);
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
if (autoAccept)
{
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
}
}
public Task RespondAsync(
CalendarMeeting meeting,
MeetingStartPromptResponse response)
{
var prompt = pendingPrompts.Single(pending => ReferenceEquals(pending.Meeting, meeting));
return prompt.HandleResponseAsync(response, CancellationToken.None);
}
private sealed record PendingPrompt(
CalendarMeeting Meeting,
Func<MeetingStartPromptResponse, CancellationToken, Task> HandleResponseAsync);
}
private sealed class RecordingPromptRecorder : IMeetingPromptRecordingController
{
public RecordingPromptRecorder(bool isRecording)
{
CurrentStatus = Status(isRecording);
}
public RecordingStatus CurrentStatus { get; private set; }
public int StartCount { get; private set; }
public int StopCount { get; private set; }
public int PromptStartCount { get; private set; }
public List<string> Commands { get; } = [];
public List<MeetingMetadata?> StartMetadata { get; } = [];
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
return StartRecordingAsync(null);
}
private Task<RecordingStatus> StartRecordingAsync(
MeetingMetadata? metadata)
{
StartCount++;
Commands.Add("start");
StartMetadata.Add(metadata);
CurrentStatus = Status(isRecording: true);
return Task.FromResult(CurrentStatus);
}
public Task<RecordingStatus> StartFromPromptAsync(
MeetingMetadata? metadata,
CancellationToken cancellationToken)
{
PromptStartCount++;
return StartRecordingAsync(metadata);
}
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
StopCount++;
Commands.Add("stop");
CurrentStatus = Status(isRecording: false);
return Task.FromResult(CurrentStatus);
}
private static RecordingStatus Status(bool isRecording)
{
return new RecordingStatus(
isRecording,
isRecording ? "transcript.md" : null,
isRecording ? "meeting.md" : null,
isRecording ? "context.md" : null,
isRecording ? "summary.md" : null,
isRecording ? RecordingProcessState.Recording : RecordingProcessState.Idle,
isRecording ? "default" : null);
}
}
private sealed class ManualCalendarPromptClock : ICalendarPromptClock
{
public ManualCalendarPromptClock(DateTimeOffset now)
{
Now = now;
}
public DateTimeOffset Now { get; set; }
}
}
@@ -46,7 +46,7 @@ public sealed class ConfiguredSpeechRecognitionPipelineFactoryTests
{
["MeetingAssistant:Recording:TranscriptionProvider"] = "capturing",
["MeetingAssistant:Agent:Model"] = "default-model",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+L",
["MeetingAssistant:LaunchProfiles:english:Recording:TranscriptionProvider"] = "capturing",
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-model"
})
@@ -33,7 +33,7 @@ public sealed class LaunchProfileOptionsProviderTests
["MeetingAssistant:AzureSpeech:Region"] = "germanywestcentral",
["MeetingAssistant:AzureSpeech:Language"] = "de-DE",
["MeetingAssistant:AzureSpeech:AutoDetectLanguages:0"] = "de-DE",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+L",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:AutoDetectLanguages:0"] = "en-US"
});
@@ -41,13 +41,23 @@ public sealed class LaunchProfileOptionsProviderTests
var profile = provider.GetRequiredProfile("english");
Assert.Equal("english", profile.Name);
Assert.Equal("Ctrl+Alt+E", profile.Options.Hotkey.Toggle);
Assert.Equal("Ctrl+Alt+L", profile.Options.Hotkey.Toggle);
Assert.Equal("azure-speech", profile.Options.Recording.TranscriptionProvider);
Assert.Equal("germanywestcentral", profile.Options.AzureSpeech.Region);
Assert.Equal("en-US", profile.Options.AzureSpeech.Language);
Assert.Equal(["en-US"], profile.Options.AzureSpeech.AutoDetectLanguages);
}
[Fact]
public void CheckedInEnglishProfileUsesLanguageHotkey()
{
var provider = CreateProviderFromAppsettings();
var profile = provider.GetRequiredProfile("english");
Assert.Equal("Ctrl+Alt+L", profile.Options.Hotkey.Toggle);
}
[Fact]
public void DuplicateProfileHotkeysAreRejected()
{
@@ -67,9 +77,9 @@ public sealed class LaunchProfileOptionsProviderTests
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+Z",
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+L",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D",
["MeetingAssistant:LaunchProfiles:english:Screenshots:Hotkey"] = "Ctrl+Alt+Shift+S"
});
@@ -86,7 +96,7 @@ public sealed class LaunchProfileOptionsProviderTests
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "default" &&
hotkey.Hotkey == "Ctrl+Alt+D" &&
hotkey.Hotkey == "Ctrl+Alt+Z" &&
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "english" &&
@@ -100,7 +110,7 @@ public sealed class LaunchProfileOptionsProviderTests
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+Z",
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
});
@@ -111,6 +121,23 @@ public sealed class LaunchProfileOptionsProviderTests
Assert.DoesNotContain(hotkeys, hotkey => hotkey.ProfileName == "english");
}
[Fact]
public void GetProfilesReturnsDefaultAndConfiguredNamedProfiles()
{
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
});
var profiles = provider.GetProfiles();
Assert.Equal(["default", "english"], profiles.Select(profile => profile.Name));
Assert.Equal("azure-speech", profiles.Single(profile => profile.Name == "english").Options.Recording.TranscriptionProvider);
Assert.Equal("en-US", profiles.Single(profile => profile.Name == "english").Options.AzureSpeech.Language);
}
private static ConfigurationLaunchProfileOptionsProvider CreateProvider(
IReadOnlyDictionary<string, string?> values)
{
@@ -119,4 +146,28 @@ public sealed class LaunchProfileOptionsProviderTests
.Build();
return new ConfigurationLaunchProfileOptionsProvider(configuration);
}
private static ConfigurationLaunchProfileOptionsProvider CreateProviderFromAppsettings()
{
var root = FindRepositoryRoot();
var configuration = new ConfigurationBuilder()
.AddJsonFile(Path.Combine(root, "MeetingAssistant", "appsettings.json"), optional: false)
.Build();
return new ConfigurationLaunchProfileOptionsProvider(configuration);
}
private static string FindRepositoryRoot()
{
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
directory is not null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "MeetingAssistant.slnx")))
{
return directory.FullName;
}
}
throw new InvalidOperationException("Could not locate MeetingAssistant.slnx from the test output folder.");
}
}
@@ -1,103 +1,253 @@
using MeetingAssistant.Summary;
using Microsoft.Extensions.AI;
using System.Net;
using System.Text;
using System.Text.Json;
namespace MeetingAssistant.Tests;
public sealed class LiteLlmResponsesChatClientTests
{
[Fact]
public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText()
public async Task ClientAssemblesStreamedTextResponseWithMetadataAndUsage()
{
const string json = """
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
"id": "resp_test",
"created_at": 1779147100,
"model": "gpt-5.5-2026-04-23",
"output": [
{
"type": "reasoning",
"summary": [],
"status": null
},
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "OK"
}
]
}
]
}
""";
Content = new StringContent(
"""
data: {"type":"response.created","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"in_progress","store":false},"sequence_number":0}
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_stream","type":"message","status":"in_progress","content":[],"role":"assistant"},"sequence_number":1}
Assert.Equal("OK", response.Text);
Assert.Equal("resp_test", response.ResponseId);
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
data: {"type":"response.content_part.added","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""},"sequence_number":2}
data: {"type":"response.output_text.delta","item_id":"msg_stream","output_index":0,"content_index":0,"delta":"Streamed OK","sequence_number":3}
data: {"type":"response.output_text.done","item_id":"msg_stream","output_index":0,"content_index":0,"text":"Streamed OK","sequence_number":4}
data: {"type":"response.content_part.done","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"Streamed OK"},"sequence_number":5}
data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_stream","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Streamed OK"}],"role":"assistant"},"sequence_number":6}
data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}},"sequence_number":7}
data: [DONE]
""",
Encoding.UTF8,
"text/event-stream")
});
using var client = CreateClient(handler, reconnectionAttempts: 0);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
Assert.Equal("Streamed OK", response.Text);
Assert.Equal("resp_stream", response.ResponseId);
Assert.Equal("gpt-5.5", response.ModelId);
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1779147100), response.CreatedAt);
Assert.NotNull(response.Usage);
Assert.Equal(12, response.Usage.InputTokenCount);
Assert.Equal(3, response.Usage.OutputTokenCount);
Assert.Equal(15, response.Usage.TotalTokenCount);
}
[Fact]
public void ParserReadsFunctionCalls()
public async Task ClientAssemblesStreamedFunctionCall()
{
const string json = """
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
"output": [
{
"type": "function_call",
"call_id": "call_1",
"name": "write_summary",
"arguments": "{\"markdown\":\"# Summary\\nDone\"}",
"status": "completed"
}
]
}
""";
Content = new StringContent(
"""
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_stream","name":"write_summary","arguments":"{\"markdown\":\"# Summary\\nDone\"}","status":"completed"}}
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
data: {"type":"response.completed","response":{"id":"resp_stream","model":"gpt-5.5","output":[]}}
data: [DONE]
""",
Encoding.UTF8,
"text/event-stream")
});
using var client = CreateClient(handler, reconnectionAttempts: 0);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
var call = Assert.IsType<FunctionCallContent>(Assert.Single(response.Messages[0].Contents));
Assert.Equal("call_1", call.CallId);
Assert.Equal("call_stream", call.CallId);
Assert.Equal("write_summary", call.Name);
Assert.NotNull(call.Arguments);
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]);
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]?.ToString());
}
[Fact]
public void ParserReadsUsage()
public async Task ClientUsesNonStreamingResponsesWhenConfigured()
{
const string json = """
{
"usage": {
"input_tokens": 123,
"output_tokens": 45,
"total_tokens": 168
},
"output": [
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{
"type": "message",
"content": [
"id": "resp_nonstream",
"created_at": 1779147100,
"model": "gpt-5.5",
"object": "response",
"output": [
{
"type": "output_text",
"text": "OK"
"id": "msg_nonstream",
"type": "message",
"status": "completed",
"content": [
{
"type": "output_text",
"annotations": [],
"text": "Non-streamed OK"
}
],
"role": "assistant"
}
]
],
"parallel_tool_calls": true,
"status": "completed",
"store": false,
"usage": {
"input_tokens": 12,
"output_tokens": 4,
"total_tokens": 16
}
}
]
}
""";
""",
Encoding.UTF8,
"application/json")
});
using var client = CreateClient(handler, reconnectionAttempts: 0, useStreaming: false);
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
Assert.NotNull(response.Usage);
Assert.Equal(123, response.Usage.InputTokenCount);
Assert.Equal(45, response.Usage.OutputTokenCount);
Assert.Equal(168, response.Usage.TotalTokenCount);
Assert.Equal("Non-streamed OK", response.Text);
Assert.Contains("\"stream\":false", Assert.Single(handler.RequestBodies));
}
[Fact]
public async Task ClientSendsChatMessagesAsResponsesMessageItems()
{
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
using var client = CreateClient(handler, reconnectionAttempts: 0);
await client.GetResponseAsync(
[
new ChatMessage(ChatRole.User, "write summary"),
new ChatMessage(ChatRole.Assistant, "I will inspect the meeting.")
]);
using var request = JsonDocument.Parse(Assert.Single(handler.RequestBodies));
var inputItems = request.RootElement.GetProperty("input").EnumerateArray().ToArray();
Assert.Equal(2, inputItems.Length);
Assert.All(inputItems, item => Assert.Equal("message", item.GetProperty("type").GetString()));
Assert.Equal("user", inputItems[0].GetProperty("role").GetString());
Assert.Equal(
"input_text",
Assert.Single(inputItems[0].GetProperty("content").EnumerateArray())
.GetProperty("type")
.GetString());
Assert.Equal("assistant", inputItems[1].GetProperty("role").GetString());
Assert.Equal(
"output_text",
Assert.Single(inputItems[1].GetProperty("content").EnumerateArray())
.GetProperty("type")
.GetString());
Assert.DoesNotContain("\"type\":\"unknown\"", handler.RequestBodies[0]);
}
[Fact]
public async Task AgentLoopReturnsMalformedFunctionArgumentsWithoutInvokingTool()
{
var responses = new Queue<HttpResponseMessage>(
[
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_invalid","name":"write_summary","arguments":"{not-json","status":"completed"}}
data: {"type":"response.completed","response":{"id":"resp_invalid","model":"gpt-5.5","output":[]}}
data: [DONE]
""",
Encoding.UTF8,
"text/event-stream")
},
CreateStreamedTextResponse("Recovered.")
]);
var handler = new RecordingHttpMessageHandler(_ => responses.Dequeue());
var toolInvoked = false;
var tool = AIFunctionFactory.Create(
(string markdown) =>
{
toolInvoked = true;
return markdown;
},
"write_summary",
"Writes a summary.");
using var innerClient = CreateClient(handler, reconnectionAttempts: 0);
using var functionClient = innerClient
.AsBuilder()
.UseFunctionInvocation(
loggerFactory: null,
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
.Build();
var response = await functionClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, "write summary")],
new ChatOptions { Tools = [tool] });
Assert.False(toolInvoked);
Assert.Equal("Recovered.", response.Text);
Assert.Equal(2, handler.RequestBodies.Count);
Assert.Contains("invalid_tool_arguments", handler.RequestBodies[1]);
Assert.Contains("call_invalid", handler.RequestBodies[1]);
}
[Fact]
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
{
var handler = new SequencedHttpMessageHandler(
CreateStreamedTextResponse(
"Done.",
"Checked the configured rules file.",
"Prepared a targeted update."));
var reasoningSummaries = new List<string>();
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
reasoningSummaryChanged: reasoningSummaries.Add);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(
[
"Checked the configured rules file.",
"Prepared a targeted update."
],
reasoningSummaries);
}
[Fact]
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
{
var handler = new SequencedHttpMessageHandler(
CreateStreamedTextResponse("Done.", "Checked the configured rules file."));
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
reasoningSummaryChanged: _ => throw new InvalidOperationException("UI failed"));
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
Assert.Equal("Done.", response.Text);
}
[Fact]
@@ -108,30 +258,15 @@ public sealed class LiteLlmResponsesChatClientTests
{
Content = new StringContent("Internal Server Error")
},
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
});
using var client = CreateClient(handler, reconnectionAttempts: 1);
CreateStreamedTextResponse("Done."));
var retryCount = 0;
using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(2, handler.RequestCount);
Assert.Equal(1, retryCount);
}
[Fact]
@@ -151,6 +286,19 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal(1, handler.RequestCount);
}
[Fact]
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
{
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
using var client = CreateClient(handler, reconnectionAttempts: 0);
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.Tool, "tool result")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.Assistant, "continue")]);
Assert.Equal(["user", "agent", "agent"], handler.RequestHeaders["X-Initiator"]);
}
[Fact]
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
{
@@ -173,24 +321,7 @@ public sealed class LiteLlmResponsesChatClientTests
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
};
return CreateStreamedTextResponse("Done.");
});
using var client = CreateClient(
handler,
@@ -209,6 +340,7 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
Assert.Equal(["agent", "user"], handler.RequestHeaders["X-Initiator"]);
}
[Fact]
@@ -224,24 +356,7 @@ public sealed class LiteLlmResponsesChatClientTests
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
};
return CreateStreamedTextResponse("Done.");
});
using var client = CreateClient(
handler,
@@ -272,7 +387,10 @@ public sealed class LiteLlmResponsesChatClientTests
private static LiteLlmResponsesChatClient CreateClient(
HttpMessageHandler handler,
int reconnectionAttempts,
LiteLlmResponsesCompactionOptions? compactionOptions = null)
LiteLlmResponsesCompactionOptions? compactionOptions = null,
Action? retrying = null,
Action<string>? reasoningSummaryChanged = null,
bool useStreaming = true)
{
return new LiteLlmResponsesChatClient(
new HttpClient(handler)
@@ -285,7 +403,51 @@ public sealed class LiteLlmResponsesChatClientTests
reasoningEffort: "none",
reconnectionAttempts,
TimeSpan.Zero,
compactionOptions);
compactionOptions,
retrying: retrying,
reasoningSummaryChanged: reasoningSummaryChanged,
useStreaming: useStreaming);
}
private static HttpResponseMessage CreateStreamedTextResponse(
string text,
params string[] reasoningSummaries)
{
var events = new List<string>();
for (var index = 0; index < reasoningSummaries.Length; index++)
{
events.Add("data: " + JsonSerializer.Serialize(new
{
type = "response.reasoning_summary_text.delta",
item_id = "reasoning_stream",
output_index = 0,
summary_index = index,
delta = reasoningSummaries[index]
}));
}
var outputIndex = reasoningSummaries.Length > 0 ? 1 : 0;
events.Add("data: " + JsonSerializer.Serialize(new
{
type = "response.output_text.delta",
item_id = "msg_stream",
output_index = outputIndex,
content_index = 0,
delta = text
}));
events.Add(
"""data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}}}""");
events.Add("data: [DONE]");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
string.Join($"{Environment.NewLine}{Environment.NewLine}", events)
+ Environment.NewLine
+ Environment.NewLine,
Encoding.UTF8,
"text/event-stream")
};
}
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
@@ -321,6 +483,8 @@ public sealed class LiteLlmResponsesChatClientTests
public List<string> RequestBodies { get; } = [];
public Dictionary<string, List<string>> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
@@ -329,6 +493,17 @@ public sealed class LiteLlmResponsesChatClientTests
RequestBodies.Add(request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken));
foreach (var header in request.Headers)
{
if (!RequestHeaders.TryGetValue(header.Key, out var values))
{
values = [];
RequestHeaders[header.Key] = values;
}
values.AddRange(header.Value);
}
return handler(request);
}
}
@@ -16,9 +16,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
[Fact]
public async Task ExtractUsesAgentEndpointAndModelWhenOcrEndpointAndModelAreBlank()
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, [1, 2, 3]);
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
var handler = new RecordingHandler("""
{
"output": [
@@ -75,9 +73,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
[Fact]
public async Task ExtractUsesScreenshotOcrEndpointAndModelWhenConfigured()
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, [4, 5, 6]);
var screenshotPath = await CreateScreenshotAsync([4, 5, 6]);
var handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
@@ -116,9 +112,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
[Fact]
public async Task ExtractParsesCropMetadataAndOmitsMetadataFromReturnedText()
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, CreatePngBytes(8, 6));
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
var handler = new RecordingHandler("""
{
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 } }\n```"
@@ -150,6 +144,67 @@ public sealed class LiteLlmScreenshotOcrClientTests
StringComparison.Ordinal);
}
[Fact]
public async Task ExtractParsesAttendeeMetadataAndOmitsMetadataFromReturnedText()
{
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
var handler = new RecordingHandler("""
{
"output_text": "Visible participant tiles: Ada and Grace.\n\n```json\n{ \"crop\": null, \"attendees\": [\"Ada Lovelace\", \"Grace Hopper\"] }\n```"
}
""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
var options = new MeetingAssistantOptions
{
Agent =
{
Key = "agent-key"
}
};
var result = await client.ExtractAsync(
screenshotPath,
"Extract screenshot.",
options,
CancellationToken.None);
Assert.Equal("Visible participant tiles: Ada and Grace.", result.Text);
Assert.Equal(["Ada Lovelace", "Grace Hopper"], result.Attendees);
}
[Fact]
public async Task ExtractIgnoresMalformedAttendeesMetadataAndStillParsesCrop()
{
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
var handler = new RecordingHandler("""
{
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 }, \"attendees\": \"Ada\" }\n```"
}
""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
var options = new MeetingAssistantOptions
{
Agent =
{
Key = "agent-key"
}
};
var result = await client.ExtractAsync(
screenshotPath,
"Extract screenshot.",
options,
CancellationToken.None);
Assert.Equal("Slide text", result.Text);
Assert.Equal(new ScreenshotCropCoordinates(1, 2, 3, 4), result.Crop);
Assert.Empty(result.Attendees);
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly string responseBody;
@@ -193,6 +248,14 @@ public sealed class LiteLlmScreenshotOcrClientTests
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
private static async Task<string> CreateScreenshotAsync(byte[] bytes)
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, bytes);
return screenshotPath;
}
}
#pragma warning restore CA1416
@@ -0,0 +1,85 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Tests;
public sealed class MeetingArtifactContentDetectorTests
{
[Fact]
public async Task IsDefaultOnlyReturnsFalseWhenTranscriptContainsSegment()
{
using var fixture = await DefaultArtifactFixture.CreateAsync();
await File.AppendAllTextAsync(
fixture.Artifacts.TranscriptPath,
"[00:00:01] Guest-1: hello" + Environment.NewLine);
var result = await MeetingArtifactContentDetector.IsDefaultOnlyAsync(
fixture.Artifacts,
CancellationToken.None);
Assert.False(result);
}
[Fact]
public async Task IsDefaultOnlyReturnsFalseWhenMeetingNoteContainsUserNotes()
{
using var fixture = await DefaultArtifactFixture.CreateAsync();
await File.AppendAllTextAsync(
fixture.Artifacts.MeetingNotePath,
"Manual note" + Environment.NewLine);
var result = await MeetingArtifactContentDetector.IsDefaultOnlyAsync(
fixture.Artifacts,
CancellationToken.None);
Assert.False(result);
}
private sealed class DefaultArtifactFixture : IDisposable
{
private DefaultArtifactFixture(string root, MeetingSessionArtifacts artifacts)
{
Root = root;
Artifacts = artifacts;
}
public string Root { get; }
public MeetingSessionArtifacts Artifacts { get; }
public static async Task<DefaultArtifactFixture> CreateAsync()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "meeting.md"),
Path.Combine(root, "transcript.md"),
Path.Combine(root, "assistant-context.md"),
Path.Combine(root, "summary.md"));
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Test
---
""");
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"# Meeting Transcript" + Environment.NewLine + Environment.NewLine);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
return new DefaultArtifactFixture(root, artifacts);
}
public void Dispose()
{
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
}
}
@@ -9,8 +9,8 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
@@ -57,6 +57,19 @@ public sealed class MeetingNoteStoreTests
Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary);
}
[Fact]
public async Task StoreNamesGeneratedMeetingNoteWithMinutePrecision()
{
var (_, store) = CreateStore();
var saved = await store.SaveAsync(
MeetingNoteTemplate.Create(
title: "Leadership Sync",
startTime: DateTimeOffset.Parse("2026-05-19T10:03:42+02:00")),
CancellationToken.None);
Assert.Equal("20260519-1003-note.md", Path.GetFileName(saved.Path));
}
[Fact]
public async Task FrontmatterUpdatePreservesExistingUserNotesBody()
{
@@ -92,6 +105,102 @@ public sealed class MeetingNoteStoreTests
Assert.Equal(userNotes, reloaded.UserNotes);
}
[Fact]
public async Task ReadConvertsScalarListFrontmatterToSingleItemLists()
{
var (vaultRoot, store) = CreateStore();
var notePath = Path.Combine(vaultRoot, "Meetings", "Notes", "manual.md");
Directory.CreateDirectory(Path.GetDirectoryName(notePath)!);
await File.WriteAllTextAsync(
notePath,
"""
---
title: Manual frontmatter
start_time: "2026-05-19T10:00:00.0000000+02:00"
end_time: ""
attendees: Ada
projects: Meeting Assistant
transcript: "[[../Transcripts/manual-transcript|Transcript]]"
assistant_context: "[[../Assistant Context/manual-context|Assistant Context]]"
summary: "[[../Summaries/manual-summary|Summary]]"
---
User notes.
""");
var loaded = await store.ReadAsync(notePath, CancellationToken.None);
Assert.Equal(["Ada"], loaded.Frontmatter.Attendees);
Assert.Equal(["Meeting Assistant"], loaded.Frontmatter.Projects);
Assert.Equal("User notes.", loaded.UserNotes);
}
[Fact]
public async Task StoreEscapesApostrophePrefixedAttendeesBeforeWritingFrontmatter()
{
var (store, saved) = await SaveNoteAsync(
title: "Escaped Attendees",
attendees: ["'Ada Lovelace"],
projects: [],
userNotes: "Discuss attendee import.");
var content = await File.ReadAllTextAsync(saved.Path);
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
Assert.Contains("- \"'Ada Lovelace\"", content);
Assert.Equal(["'Ada Lovelace"], loaded.Frontmatter.Attendees);
Assert.Equal("Discuss attendee import.", loaded.UserNotes);
}
[Theory]
[InlineData("Ada # platform lead")]
[InlineData("- Ada Lovelace")]
[InlineData("? Ada Lovelace")]
[InlineData("{Ada: Platform}")]
[InlineData("*Ada")]
[InlineData("&Ada")]
[InlineData("!Ada")]
[InlineData("| Ada")]
[InlineData("> Ada")]
[InlineData("@Ada")]
[InlineData("`Ada")]
[InlineData("true")]
[InlineData("null")]
[InlineData("2026-07-08")]
[InlineData("Ada \"The Architect\" Lovelace")]
[InlineData("C:\\People\\Ada")]
public async Task StoreEscapesYamlSensitiveAttendeesBeforeWritingFrontmatter(string attendee)
{
var (store, saved) = await SaveNoteAsync(
title: "Escaped Attendees",
attendees: [attendee],
projects: [],
userNotes: "Discuss attendee import.");
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
Assert.Equal([attendee], loaded.Frontmatter.Attendees);
Assert.Equal("Discuss attendee import.", loaded.UserNotes);
}
[Theory]
[InlineData("Planning # Q3")]
[InlineData("- Planning")]
[InlineData("{Planning: Q3}")]
[InlineData("true")]
[InlineData("2026-07-08")]
public async Task StoreEscapesYamlSensitiveScalarAndProjectFrontmatterValues(string value)
{
var (store, saved) = await SaveNoteAsync(
title: value,
attendees: [],
projects: [value],
userNotes: "Discuss YAML escaping.");
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
Assert.Equal(value, loaded.Frontmatter.Title);
Assert.Equal([value], loaded.Frontmatter.Projects);
Assert.Equal("Discuss YAML escaping.", loaded.UserNotes);
}
[Fact]
public void ActionLinkEscapesSummaryFileName()
{
@@ -104,6 +213,26 @@ public sealed class MeetingNoteStoreTests
link);
}
private static async Task<(MarkdownMeetingNoteStore Store, MeetingNote Saved)> SaveNoteAsync(
string title,
IReadOnlyList<string> attendees,
IReadOnlyList<string> projects,
string userNotes)
{
var (vaultRoot, store) = CreateStore();
var note = MeetingNoteTemplate.Create(
title: title,
attendees: attendees,
projects: projects,
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
userNotes: userNotes);
var saved = await store.SaveAsync(note, CancellationToken.None);
return (store, saved);
}
private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore()
{
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
@@ -1,6 +1,9 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Speakers;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Drawing;
using System.Drawing.Imaging;
@@ -130,6 +133,194 @@ public sealed class MeetingScreenshotServiceTests
Assert.Contains("Shared screen text", context);
}
[Fact]
public async Task CaptureAddsOcrAttendeesToMeetingNoteThroughCanonicalizer()
{
var fixture = await ScreenshotFixture.CreateAsync(
options =>
{
options.Screenshots.Ocr.Enabled = true;
},
attendees: ["Ada Lovelace"]);
var ocr = new CapturingScreenshotOcrClient(
"Visible participant tiles: Ada, Grace, and Ada again.",
attendees: ["Ada L.", "Grace Hopper", "Ada Lovelace"]);
var canonicalizer = new MappingAttendeeCanonicalizer(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Ada L."] = "Ada Lovelace"
});
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr,
canonicalizer);
await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Ada Lovelace", "Grace Hopper"], meeting.Frontmatter.Attendees);
Assert.Contains(canonicalizer.Requests, request =>
request.SequenceEqual(["Ada Lovelace", "Ada L.", "Grace Hopper", "Ada Lovelace"]));
}
[Fact]
public async Task CaptureTransformsOcrAttendeesBeforeWritingMeetingNote()
{
var fixture = await ScreenshotFixture.CreateAsync(
options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace");
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
new CapturingScreenshotOcrClient(
"Visible participant tile: Ada.",
attendees: ["Ada Lovelace (Contoso)"]),
meetingWorkflowEngine: workflowEngine);
await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests);
}
[Fact]
public async Task CaptureWritesRetryLinkWhenOcrFails()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Api.PublicBaseUrl = "http://localhost:5090";
options.Screenshots.Ocr.Enabled = true;
});
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
new ThrowingScreenshotOcrClient("vision offline"));
var result = await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotId = ExtractScreenshotOcrId(context);
Assert.Contains("_OCR failed: vision offline_", context);
Assert.Contains("<!-- screenshot-ocr:", context);
Assert.Contains("<!-- /screenshot-ocr:", context);
Assert.Contains("[Retry screenshot OCR](http://localhost:5090/meetings/screenshot-ocr/retry?", context);
Assert.Contains($"screenshotId={screenshotId}", context);
Assert.Contains($"screenshotPath={Uri.EscapeDataString(result.ScreenshotPath)}", context);
Assert.Contains($"assistantContextPath={Uri.EscapeDataString(fixture.Artifacts.AssistantContextPath)}", context);
}
[Fact]
public async Task RetryOcrReplacesFailureForSameScreenshot()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new SequencedScreenshotOcrClient(
new InvalidOperationException("vision offline"),
new ScreenshotOcrResult("Retried OCR text", null, ["Grace Hopper"]));
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
var result = await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var failedContext = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotId = ExtractScreenshotOcrId(failedContext);
var retry = await service.TriggerOcrRetryAsync(
fixture.Artifacts,
result.ScreenshotPath,
screenshotId,
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.NotNull(retry);
Assert.Equal(result.ScreenshotPath, retry.ScreenshotPath);
Assert.Equal(screenshotId, retry.ScreenshotId);
Assert.Equal([result.ScreenshotPath, result.ScreenshotPath], ocr.ScreenshotPaths);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("### OCR", context);
Assert.Contains("Retried OCR text", context);
Assert.DoesNotContain("vision offline", context);
Assert.DoesNotContain("Retry screenshot OCR", context);
Assert.DoesNotContain("<!-- screenshot-ocr:", context);
}
[Fact]
public async Task ProcessMeetingNoteImageEmbedsAppendsContextAndRunsOcrWithoutCropOrAttendees()
{
var fixture = await ScreenshotFixture.CreateAsync(
options =>
{
options.Screenshots.Ocr.Enabled = true;
},
attendees: ["Ada Lovelace"],
userNotes: "Discussed ![[whiteboard.png]] and ![Diagram](attachments/diagram.png).");
var noteFolder = Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!;
var attachmentsFolder = Path.Combine(noteFolder, "attachments");
Directory.CreateDirectory(attachmentsFolder);
var whiteboardPath = Path.Combine(noteFolder, "whiteboard.png");
var diagramPath = Path.Combine(attachmentsFolder, "diagram.png");
await File.WriteAllBytesAsync(whiteboardPath, [1, 2, 3]);
await File.WriteAllBytesAsync(diagramPath, [4, 5, 6]);
var ocr = new CapturingScreenshotOcrClient(
"Manual image OCR",
new ScreenshotCropCoordinates(1, 1, 2, 2),
["Grace Hopper"]);
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
var originalMeetingNote = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
var result = await service.ProcessMeetingNoteImageEmbedsAsync(
fixture.Artifacts,
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.Equal(2, result.QueuedCount);
Assert.Equal(2, ocr.CallCount);
Assert.Equal([whiteboardPath, diagramPath], ocr.ScreenshotPaths);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("## Meeting Note Image", context);
Assert.Contains("Image from meeting note.", context);
Assert.Contains("Original embed: `![[whiteboard.png]]`", context);
Assert.Contains("Original embed: `![Diagram](attachments/diagram.png)`", context);
Assert.Contains("whiteboard.png", context);
Assert.Contains("diagram.png", context);
Assert.Contains("Manual image OCR", context);
Assert.DoesNotContain("Cropped screenshot", context);
Assert.Equal(originalMeetingNote, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task WaitForPendingOcrWaitsForRunningScreenshotOcr()
{
@@ -192,11 +383,13 @@ public sealed class MeetingScreenshotServiceTests
private ScreenshotFixture(
MeetingAssistantOptions options,
MeetingSessionArtifacts artifacts,
MarkdownMeetingArtifactStore artifactStore)
MarkdownMeetingArtifactStore artifactStore,
MarkdownMeetingNoteStore noteStore)
{
Options = options;
Artifacts = artifacts;
ArtifactStore = artifactStore;
NoteStore = noteStore;
}
public MeetingAssistantOptions Options { get; }
@@ -205,8 +398,12 @@ public sealed class MeetingScreenshotServiceTests
public MarkdownMeetingArtifactStore ArtifactStore { get; }
public MarkdownMeetingNoteStore NoteStore { get; }
public static async Task<ScreenshotFixture> CreateAsync(
Action<MeetingAssistantOptions>? configure = null)
Action<MeetingAssistantOptions>? configure = null,
IReadOnlyList<string>? attendees = null,
string userNotes = "")
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
@@ -218,6 +415,9 @@ public sealed class MeetingScreenshotServiceTests
}
};
configure?.Invoke(options);
var noteStore = new MarkdownMeetingNoteStore(
Microsoft.Extensions.Options.Options.Create(options),
NullLogger<MarkdownMeetingNoteStore>.Instance);
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "Notes", "meeting.md"),
Path.Combine(root, "Transcripts", "transcript.md"),
@@ -225,6 +425,7 @@ public sealed class MeetingScreenshotServiceTests
Path.Combine(root, "Summaries", "summary.md"));
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.Instance);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
var meeting = MeetingNoteTemplate.Create(
"Planning",
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
@@ -232,26 +433,37 @@ public sealed class MeetingScreenshotServiceTests
assistantContextPath: artifacts.AssistantContextPath,
summaryPath: artifacts.SummaryPath) with
{
Path = artifacts.MeetingNotePath
Path = artifacts.MeetingNotePath,
UserNotes = userNotes
};
meeting.Frontmatter.Attendees = attendees?.ToList() ?? [];
var savedMeeting = await noteStore.SaveAsync(
meeting,
options,
CancellationToken.None);
await artifactStore.CreateAssistantContextAsync(
artifacts,
meeting,
savedMeeting,
"",
null,
CancellationToken.None);
return new ScreenshotFixture(options, artifacts, artifactStore);
return new ScreenshotFixture(options, artifacts, artifactStore, noteStore);
}
public MeetingScreenshotService CreateService(
IActiveWindowScreenshotCapture capture,
IScreenshotOcrClient ocrClient)
IScreenshotOcrClient ocrClient,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
{
return new MeetingScreenshotService(
capture,
ArtifactStore,
NoteStore,
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
ocrClient,
NullLogger<MeetingScreenshotService>.Instance);
NullLogger<MeetingScreenshotService>.Instance,
meetingWorkflowEngine);
}
}
@@ -276,15 +488,18 @@ public sealed class MeetingScreenshotServiceTests
public CapturingScreenshotOcrClient(
string text = "",
ScreenshotCropCoordinates? crop = null)
ScreenshotCropCoordinates? crop = null,
IReadOnlyList<string>? attendees = null)
{
result = new ScreenshotOcrResult(text, crop);
result = new ScreenshotOcrResult(text, crop, attendees ?? []);
}
public int CallCount { get; private set; }
public string? Prompt { get; private set; }
public List<string> ScreenshotPaths { get; } = [];
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
@@ -293,6 +508,7 @@ public sealed class MeetingScreenshotServiceTests
{
CallCount++;
Prompt = prompt;
ScreenshotPaths.Add(screenshotPath);
return Task.FromResult(result);
}
}
@@ -320,7 +536,79 @@ public sealed class MeetingScreenshotServiceTests
public void Release(string value)
{
result.TrySetResult(new ScreenshotOcrResult(value, null));
result.TrySetResult(new ScreenshotOcrResult(value, null, []));
}
}
private sealed class ThrowingScreenshotOcrClient : IScreenshotOcrClient
{
private readonly string message;
public ThrowingScreenshotOcrClient(string message)
{
this.message = message;
}
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
throw new InvalidOperationException(message);
}
}
private sealed class SequencedScreenshotOcrClient : IScreenshotOcrClient
{
private readonly Queue<object> results;
public SequencedScreenshotOcrClient(params object[] results)
{
this.results = new Queue<object>(results);
}
public List<string> ScreenshotPaths { get; } = [];
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
ScreenshotPaths.Add(screenshotPath);
var result = results.Dequeue();
if (result is Exception exception)
{
throw exception;
}
return Task.FromResult((ScreenshotOcrResult)result);
}
}
private sealed class MappingAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
{
private readonly IReadOnlyDictionary<string, string> aliases;
public MappingAttendeeCanonicalizer(IReadOnlyDictionary<string, string> aliases)
{
this.aliases = aliases;
}
public List<IReadOnlyList<string>> Requests { get; } = [];
public Task<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> attendees,
CancellationToken cancellationToken)
{
Requests.Add(attendees.ToList());
var result = attendees
.Select(attendee => aliases.TryGetValue(attendee, out var canonical) ? canonical : attendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(result);
}
}
@@ -336,6 +624,17 @@ public sealed class MeetingScreenshotServiceTests
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
private static string ExtractScreenshotOcrId(string context)
{
const string prefix = "<!-- screenshot-ocr:";
var start = context.IndexOf(prefix, StringComparison.Ordinal);
Assert.True(start >= 0);
start += prefix.Length;
var end = context.IndexOf(" -->", start, StringComparison.Ordinal);
Assert.True(end > start);
return context[start..end];
}
}
#pragma warning restore CA1416
@@ -16,6 +16,9 @@ public sealed class MeetingSummaryArtifactResolverTests
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
var notePath = Path.Combine(notes, "meeting.md");
var assistantContextPath = Path.Combine(root, "Meetings", "Assistant Context", "context.md");
Directory.CreateDirectory(Path.GetDirectoryName(assistantContextPath)!);
await File.WriteAllTextAsync(assistantContextPath, "# Assistant Context");
await File.WriteAllTextAsync(
notePath,
"""
@@ -47,10 +50,98 @@ public sealed class MeetingSummaryArtifactResolverTests
Assert.NotNull(artifacts);
Assert.Equal(notePath, artifacts.MeetingNotePath);
Assert.Equal(Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), artifacts.TranscriptPath);
Assert.Equal(Path.Combine(root, "Meetings", "Assistant Context", "context.md"), artifacts.AssistantContextPath);
Assert.Equal(assistantContextPath, artifacts.AssistantContextPath);
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
}
[Fact]
public async Task ResolverPrefersArtifactLinksFromRequestedSummaryFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var notes = Path.Combine(root, "Meetings", "Notes");
var summaries = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
var notePath = Path.Combine(notes, "meeting.md");
var transcriptPath = Path.Combine(root, "Meetings", "Transcripts", "transcript.md");
var summaryContextPath = Path.Combine(root, "Meetings", "Assistant Context", "summary-context.md");
Directory.CreateDirectory(Path.GetDirectoryName(summaryContextPath)!);
await File.WriteAllTextAsync(summaryContextPath, "# Summary-linked Assistant Context");
await File.WriteAllTextAsync(
notePath,
"""
---
title: Meeting
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/stale-context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
""");
await File.WriteAllTextAsync(
Path.Combine(summaries, "summary.md"),
"""
---
meeting: "[[../Notes/meeting|Meeting Note]]"
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/summary-context|Assistant Context]]"
---
""");
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = notes,
SummariesFolder = summaries
}
});
var resolver = new MeetingSummaryArtifactResolver(
options,
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
Assert.NotNull(artifacts);
Assert.Equal(notePath, artifacts.MeetingNotePath);
Assert.Equal(transcriptPath, artifacts.TranscriptPath);
Assert.Equal(summaryContextPath, artifacts.AssistantContextPath);
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
}
[Fact]
public async Task ResolverRejectsSummaryWhenLinkedAssistantContextIsMissing()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var notes = Path.Combine(root, "Meetings", "Notes");
var summaries = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
await File.WriteAllTextAsync(
Path.Combine(notes, "meeting.md"),
"""
---
title: Meeting
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/missing-context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
""");
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = notes,
SummariesFolder = summaries
}
});
var resolver = new MeetingSummaryArtifactResolver(
options,
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
Assert.Null(artifacts);
}
[Fact]
public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder()
{
@@ -85,6 +176,9 @@ public sealed class MeetingSummaryArtifactResolverTests
Directory.CreateDirectory(englishNotes);
Directory.CreateDirectory(englishSummaries);
var notePath = Path.Combine(englishNotes, "meeting.md");
var assistantContextPath = Path.Combine(englishRoot, "Assistant Context", "context.md");
Directory.CreateDirectory(Path.GetDirectoryName(assistantContextPath)!);
await File.WriteAllTextAsync(assistantContextPath, "# Assistant Context");
await File.WriteAllTextAsync(
notePath,
"""
@@ -24,6 +24,41 @@ public sealed class MeetingSummaryInstructionBuilderTests
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
Assert.Contains("include only the most relevant cropped screenshots", instructions);
Assert.Contains("Do not include every cropped screenshot", instructions);
Assert.Contains("encode spaces in image-link targets as `%20`", instructions);
Assert.Contains("Use add_attendee and remove_attendee", instructions);
Assert.Contains("partial screenshot", instructions);
Assert.Contains("override_speaker", instructions);
Assert.Contains("very certain", instructions);
Assert.Contains("merge=true", instructions);
Assert.Contains("delete_identity", instructions);
Assert.Contains("wrongfully matched", instructions);
Assert.Contains("oneliner", instructions);
Assert.Contains("very short", instructions);
Assert.Contains("conciseness is more important", instructions);
Assert.Contains("generated default title", instructions);
Assert.Contains("purpose of the meeting is clear", instructions);
Assert.Contains("title parameter", instructions);
}
[Fact]
public async Task DefaultPromptTreatsAssistantContextAsMeetingMemoryForUncertainty()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = CreateArtifacts(root);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, []);
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
{
Agent = new AgentOptions { InitialPrompt = " " },
Vault = new VaultOptions { ProjectsFolder = Path.Combine(root, "Projects") }
}));
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.Contains("meeting-specific memory", instructions);
Assert.Contains("unexpected problems", instructions);
Assert.Contains("missing information", instructions);
Assert.Contains("assumptions", instructions);
Assert.Contains("write_context", instructions);
}
[Fact]
@@ -0,0 +1,198 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryRetryRunnerTests
{
[Fact]
public async Task TriggerStartsRetryInBackgroundAndTransitionsAssistantContextState()
{
var artifacts = new MeetingSessionArtifacts(
"meeting.md",
"transcript.md",
"context.md",
"summary.md");
var resolver = new FixedSummaryArtifactResolver(artifacts);
var pipeline = new BlockingSummaryPipeline();
var artifactStore = new CapturingArtifactStore();
var runner = new MeetingSummaryRetryRunner(
resolver,
pipeline,
artifactStore,
NullLogger<MeetingSummaryRetryRunner>.Instance);
var result = await runner.TriggerAsync(
"summary.md",
new MeetingAssistantOptions(),
CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("summary.md", result.SummaryPath);
Assert.False(pipeline.Completed);
await pipeline.WaitUntilStartedAsync();
Assert.Equal([AssistantContextState.Summarizing], artifactStore.States);
pipeline.CompleteSuccessfully();
await artifactStore.WaitForStateCountAsync(2);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Finished],
artifactStore.States);
}
[Fact]
public async Task TriggerMarksAssistantContextErrorWhenRetryFails()
{
var artifacts = new MeetingSessionArtifacts(
"meeting.md",
"transcript.md",
"context.md",
"summary.md");
var resolver = new FixedSummaryArtifactResolver(artifacts);
var pipeline = new BlockingSummaryPipeline();
var artifactStore = new CapturingArtifactStore();
var runner = new MeetingSummaryRetryRunner(
resolver,
pipeline,
artifactStore,
NullLogger<MeetingSummaryRetryRunner>.Instance);
await runner.TriggerAsync(
"summary.md",
new MeetingAssistantOptions(),
CancellationToken.None);
await pipeline.WaitUntilStartedAsync();
pipeline.CompleteWithError();
await artifactStore.WaitForStateCountAsync(2);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Error],
artifactStore.States);
}
private sealed class FixedSummaryArtifactResolver : IMeetingSummaryArtifactResolver
{
private readonly MeetingSessionArtifacts? artifacts;
public FixedSummaryArtifactResolver(MeetingSessionArtifacts? artifacts)
{
this.artifacts = artifacts;
}
public Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
CancellationToken cancellationToken)
{
return Task.FromResult(artifacts);
}
public Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(artifacts);
}
}
private sealed class BlockingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly TaskCompletionSource started =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<MeetingSummaryRunResult> completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool Completed { get; private set; }
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
started.TrySetResult();
var result = await completion.Task.WaitAsync(cancellationToken);
Completed = true;
return result;
}
public Task WaitUntilStartedAsync()
{
return started.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void CompleteSuccessfully()
{
completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "ok"));
}
public void CompleteWithError()
{
completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "failed", false, "failed"));
}
}
private sealed class CapturingArtifactStore : IMeetingArtifactStore
{
private TaskCompletionSource stateChanged =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<AssistantContextState> States { get; } = [];
public Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
CancellationToken cancellationToken)
{
States.Add(state);
var observed = stateChanged;
observed.TrySetResult();
stateChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
return Task.CompletedTask;
}
public async Task WaitForStateCountAsync(int count)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (States.Count >= count)
{
return;
}
await stateChanged.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"Expected {count} state changes.");
}
}
}
@@ -47,11 +47,14 @@ public sealed class MeetingSummaryToolTests
Assert.Equal("User note line.", await tools.ReadUserNotes());
Assert.Equal("", await tools.ReadGlossary());
var result = await tools.WriteSummary("# Summary\n\n- Done.");
Assert.False(tools.SummaryWasWritten);
var result = await tools.WriteSummary("# Summary\n\n- Done.", "Done items");
Assert.Equal(artifacts.SummaryPath, result);
Assert.Equal(Path.GetFileName(artifacts.SummaryPath), result);
Assert.True(tools.SummaryWasWritten);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Meeting", summary);
Assert.Contains("oneliner: Done items", summary);
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
Assert.Contains("attendees:", summary);
@@ -65,6 +68,70 @@ public sealed class MeetingSummaryToolTests
Assert.Contains("# Summary\n\n- Done.", summary);
}
[Fact]
public async Task WriteSummaryRenamesMeetingWhenTitleIsProvided()
{
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.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting 2026-06-03 07:26
start_time: "2026-06-03T07:26:00.0000000+02:00"
attendees: []
projects: []
---
User note.
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary", "Renamed summary", "Notification Safeguard Test");
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Notification Safeguard Test", meetingNote);
Assert.Contains("title: Notification Safeguard Test", summary);
Assert.Contains("User note.", meetingNote);
}
[Fact]
public async Task WriteSummaryKeepsMeetingTitleWhenTitleIsOmittedOrBlank()
{
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.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Architecture Review
attendees: []
projects: []
---
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary", "Original title summary");
await tools.WriteSummary("# Summary", "Blank title summary", " ");
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Architecture Review", meetingNote);
Assert.Contains("title: Architecture Review", summary);
Assert.DoesNotContain("Meeting Summary", summary);
}
[Fact]
public async Task WriteSummaryPreservesExistingSummaryAttendees()
{
@@ -99,7 +166,7 @@ public sealed class MeetingSummaryToolTests
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary");
await tools.WriteSummary("# Summary", "Preserved attendee summary");
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("attendees:", summary);
@@ -142,7 +209,7 @@ public sealed class MeetingSummaryToolTests
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary");
await tools.WriteSummary("# Summary", "Current project summary");
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("projects:", summary);
@@ -151,6 +218,350 @@ public sealed class MeetingSummaryToolTests
Assert.DoesNotContain("- Stale Project", summary);
}
[Fact]
public async Task WriteSummaryCopiesOnlyDistinctNonBlankMeetingProjects()
{
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.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
projects:
- " Current Project "
- current project
- ""
- Second Project
---
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary", "Project summary");
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("projects:", summary);
Assert.Contains("- Current Project", summary);
Assert.Contains("- Second Project", summary);
Assert.DoesNotContain("- current project", summary);
Assert.DoesNotContain("- \"\"", summary);
}
[Fact]
public async Task ToolsCanAddAndRemoveMeetingAttendees()
{
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.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
attendees:
- Ada
- Grace Hopper <grace@example.com>
projects:
- Meeting Assistant
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
User note line.
""");
var tools = new MeetingSummaryTools(artifacts);
Assert.Equal("Added attendee Manuel.", await tools.AddAttendee("Manuel"));
Assert.Equal("Attendee Ada already exists.", await tools.AddAttendee("Ada"));
Assert.Equal("Removed attendee Grace Hopper.", await tools.RemoveAttendee("Grace Hopper"));
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
Assert.Contains("- Ada", meetingNote);
Assert.Contains("- Manuel", meetingNote);
Assert.DoesNotContain("Grace Hopper", meetingNote);
Assert.Contains("User note line.", meetingNote);
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", meetingNote);
}
[Fact]
public async Task ToolsTransformAddedAttendeeBeforeWritingMeetingNote()
{
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.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
attendees: []
projects: []
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
User note line.
""");
var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions(),
meetingWorkflowEngine: workflowEngine);
Assert.Equal("Added attendee Ada Lovelace.", await tools.AddAttendee("Ada Lovelace (Contoso)"));
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
Assert.Contains("- Ada Lovelace", meetingNote);
Assert.DoesNotContain("Ada Lovelace (Contoso)", meetingNote);
Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests);
}
[Fact]
public async Task ToolsOverrideSpeakerInTranscriptAndRecordOverride()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: Hello from Sabrina.
[00:00:02] Guest-02: Hello from someone else.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
Assert.Equal("Overrode speaker Guest-01 with Sabrina.", result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Sabrina: Hello from Sabrina.", transcript);
Assert.Contains("[00:00:02] Guest-02: Hello from someone else.", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("meeting-assistant:speaker-override", context);
Assert.Contains("\"from\":\"Guest-01\"", context);
Assert.Contains("\"to\":\"Sabrina\"", context);
}
[Fact]
public async Task ToolsRefuseOverrideWhenAssistantContextIsMissing()
{
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.TranscriptPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: Hello from Sabrina.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
Assert.False(File.Exists(artifacts.AssistantContextPath));
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Guest-01: Hello from Sabrina.", transcript);
}
[Fact]
public async Task ToolsRefuseOverrideWhenReplacementSpeakerAlreadyExistsWithoutMerge()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: I am probably Sabrina.
[00:00:02] Sabrina: I am already in the transcript.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
Assert.Equal(
"Refused: transcript already contains speaker Sabrina. Call override_speaker with merge=true only when you are certain both speakers are the same identity.",
result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Guest-01: I am probably Sabrina.", transcript);
Assert.Contains("[00:00:02] Sabrina: I am already in the transcript.", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.DoesNotContain("meeting-assistant:speaker-override", context);
}
[Fact]
public async Task ToolsMergeOverrideWhenReplacementSpeakerAlreadyExistsWithMerge()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: I am Sabrina.
[00:00:02] Sabrina: I am already in the transcript.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina", merge: true);
Assert.Equal("Merged speaker Guest-01 into Sabrina.", result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Sabrina: I am Sabrina.", transcript);
Assert.Contains("[00:00:02] Sabrina: I am already in the transcript.", transcript);
Assert.DoesNotContain("Guest-01:", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("meeting-assistant:speaker-override", context);
Assert.Contains("\"from\":\"Guest-01\"", context);
Assert.Contains("\"to\":\"Sabrina\"", context);
Assert.Contains("\"merge\":true", context);
}
[Fact]
public async Task ToolsDeleteIdentityInTranscriptAndRecordDeletion()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Sabrina: This was incorrectly matched.
[00:00:02] Removed-1: An earlier removed speaker.
[00:00:03] Guest-02: Hello from someone else.
[00:00:04] Sabrina: Another incorrect line.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.DeleteIdentity("Sabrina");
Assert.Equal("Deleted identity Sabrina and relabeled transcript speaker mentions to Removed-2.", result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Removed-2: This was incorrectly matched.", transcript);
Assert.Contains("[00:00:02] Removed-1: An earlier removed speaker.", transcript);
Assert.Contains("[00:00:03] Guest-02: Hello from someone else.", transcript);
Assert.Contains("[00:00:04] Removed-2: Another incorrect line.", transcript);
Assert.DoesNotContain("Sabrina:", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("meeting-assistant:speaker-identity-deletion", context);
Assert.Contains("\"identity\":\"Sabrina\"", context);
Assert.Contains("\"replacement\":\"Removed-2\"", context);
}
[Fact]
public async Task ToolsRefuseDeleteIdentityWhenAssistantContextIsMissing()
{
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.TranscriptPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Sabrina: This was incorrectly matched.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.DeleteIdentity("Sabrina");
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
Assert.False(File.Exists(artifacts.AssistantContextPath));
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Sabrina: This was incorrectly matched.", transcript);
}
[Fact]
public async Task ReadToolsSupportClampedLineRanges()
{
@@ -203,6 +614,22 @@ public sealed class MeetingSummaryToolTests
Assert.Equal("", await tools.ReadTranscript(from: 3, to: 2));
}
[Fact]
public async Task ReadToolsSupportTail()
{
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.TranscriptPath)!);
await File.WriteAllTextAsync(artifacts.TranscriptPath, "one\ntwo\nthree");
var tools = new MeetingSummaryTools(artifacts);
Assert.Equal("two\nthree", await tools.ReadTranscript(tail: 2));
}
[Fact]
public async Task ToolsWriteAssistantContextBodyWithLineModes()
{
@@ -228,12 +655,59 @@ 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]
public async Task ToolsRefuseWriteContextWhenAssistantContextIsMissing()
{
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"));
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.WriteContext("new context");
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
Assert.False(File.Exists(artifacts.AssistantContextPath));
}
[Fact]
@@ -269,7 +743,8 @@ public sealed class MeetingSummaryToolTests
# Assistant Context
## Live Context
""");
""",
replace_file: true);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
@@ -321,7 +796,7 @@ public sealed class MeetingSummaryToolTests
audit);
await tools.WriteContext("owned context");
await tools.WriteSummary("owned summary");
await tools.WriteSummary("owned summary", "Owned summary");
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO", from: 2, to: 2);
await tools.AddDictationWord("PBI");
await audit.AppendToAssistantContextAsync(CancellationToken.None);
@@ -336,4 +811,31 @@ public sealed class MeetingSummaryToolTests
Assert.DoesNotContain("owned summary", context);
Assert.DoesNotContain("- Context before.", context);
}
[Fact]
public async Task WriteSummaryRefusesOneLinerWithLineBreaks()
{
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.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
---
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.WriteSummary("# Summary", "First line\nsecond line");
Assert.Equal("Refused: oneliner must be a single line.", result);
Assert.False(tools.SummaryWasWritten);
Assert.False(File.Exists(artifacts.SummaryPath));
}
}
@@ -1,5 +1,6 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -187,6 +188,87 @@ public sealed class MeetingWorkflowEngineTests
Assert.Contains("Speaker Ada was identified in Architecture Sync.", context);
}
[Fact]
public async Task TranscriptLineRuleCanReplaceMaskedProfanityBeforeWriting()
{
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var line = await fixture.Engine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
fixture.Artifacts,
"[00:00:04] Guest-1: Azure returned ***** here.",
"Guest-1"),
fixture.Options,
CancellationToken.None);
Assert.Equal("[00:00:04] Guest-1: Azure returned [redacted] here.", line);
}
[Fact]
public async Task TranscriptLineRulePreservesUtf8CharactersWhenRenderingRazor()
{
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var line = await fixture.Engine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
fixture.Artifacts,
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie ***** müssen als nächstes richtig bleiben.",
"Guest-1"),
fixture.Options,
CancellationToken.None);
Assert.Equal(
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie [redacted] müssen als nächstes richtig bleiben.",
line);
}
[Fact]
public async Task TriggeredWorkflowRuleLogsStartAndCompletion()
{
var logger = new ListLogger<MeetingWorkflowEngine>();
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: logged-rule
on:
- created: {}
steps:
- uses: add_project
value: Diagnostics
""",
logger: logger);
await RunCreatedAsync(fixture);
Assert.Contains(logger.Messages, message =>
message.Contains("Triggered meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
Assert.Contains(logger.Messages, message =>
message.Contains("Completed meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
}
[Fact]
public async Task WorkflowRuleFailureLogsRuleNameAndEventType()
{
var logger = new ListLogger<MeetingWorkflowEngine>();
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: broken-rule
on:
- created: {}
steps:
- uses: set_property
property: unsupported
value: Diagnostics
""",
logger: logger);
await Assert.ThrowsAsync<InvalidOperationException>(() => RunCreatedAsync(fixture));
Assert.Contains(logger.Messages, message =>
message.Contains("Meeting workflow rule broken-rule failed for event Created", StringComparison.Ordinal));
}
[Fact]
public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches()
{
@@ -228,6 +310,10 @@ public sealed class MeetingWorkflowEngineTests
[InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)]
[InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)]
[InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)]
[InlineData("attendee_added:\n equals: Ada Lovelace", "attendee", null, null, "ada lovelace", true)]
[InlineData("attendee_added:\n contains: contoso", "attendee", null, null, "Ada (Contoso)", true)]
[InlineData("attendee_added:\n regex: '^Ada .+Contoso\\)$'", "attendee", null, null, "Ada (Contoso)", true)]
[InlineData("attendee_added:\n regex: '^Grace'", "attendee", null, null, "Ada (Contoso)", false)]
public async Task TriggerMatchingScenarios(
string triggerYaml,
string eventKind,
@@ -236,6 +322,16 @@ public sealed class MeetingWorkflowEngineTests
string? speaker,
bool shouldRun)
{
var stepYaml = eventKind == "attendee"
? """
- uses: set_property
property: attendee.name
value: Triggered
"""
: """
- uses: add_project
value: Triggered
""";
var fixture = await WorkflowFixture.CreateAsync(
$$"""
rules:
@@ -243,14 +339,21 @@ public sealed class MeetingWorkflowEngineTests
on:
- {{triggerYaml}}
steps:
- uses: add_project
value: Triggered
{{stepYaml}}
""");
await fixture.Engine.RunAsync(
CreateEvent(eventKind, fixture.Artifacts, from, to, speaker),
fixture.Options,
CancellationToken.None);
var workflowEvent = CreateEvent(eventKind, fixture.Artifacts, from, to, speaker);
if (eventKind == "attendee")
{
var transformed = await fixture.Engine.TransformAttendeeAsync(
workflowEvent,
fixture.Options,
CancellationToken.None);
Assert.Equal(shouldRun ? "Triggered" : speaker, transformed);
return;
}
await fixture.Engine.RunAsync(workflowEvent, fixture.Options, CancellationToken.None);
var meeting = await fixture.ReadMeetingAsync();
Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered"));
@@ -521,6 +624,34 @@ public sealed class MeetingWorkflowEngineTests
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task AttendeeAddedRuleCanTransformWorkflowAddedAttendee()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: add-raw-attendee
on:
- created: {}
steps:
- uses: add_attendee
value: 'Ada Lovelace (Contoso)'
- name: clean-contoso-attendee
on:
- attendee_added:
contains: 'Contoso'
steps:
- uses: set_property
property: attendee.name
value: '@Model.Attendee.Name.Replace(" (Contoso)", "")'
""");
await RunCreatedAsync(fixture);
var meeting = await fixture.ReadMeetingAsync();
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress()
{
@@ -710,6 +841,7 @@ public sealed class MeetingWorkflowEngineTests
{
"created" => MeetingWorkflowEvent.Created(artifacts),
"speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"),
"attendee" => MeetingWorkflowEvent.AttendeeAdded(artifacts, speaker ?? "Ada"),
"state" => MeetingWorkflowEvent.StateTransition(
artifacts,
ParseState(from ?? "collecting metadata"),
@@ -759,7 +891,8 @@ public sealed class MeetingWorkflowEngineTests
string title = "Planning Sync",
IReadOnlyList<string>? attendees = null,
IReadOnlyList<string>? projects = null,
string? rulesPath = null)
string? rulesPath = null,
ILogger<MeetingWorkflowEngine>? logger = null)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -821,7 +954,7 @@ public sealed class MeetingWorkflowEngineTests
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
noteStore,
artifactStore,
NullLogger<MeetingWorkflowEngine>.Instance);
logger ?? NullLogger<MeetingWorkflowEngine>.Instance);
return new WorkflowFixture(options, noteStore, artifacts, engine);
}
@@ -835,4 +968,39 @@ public sealed class MeetingWorkflowEngineTests
return File.ReadAllTextAsync(Artifacts.AssistantContextPath);
}
}
private sealed class ListLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
{
return NullScope.Instance;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose()
{
}
}
}
}
@@ -0,0 +1,18 @@
namespace MeetingAssistant.Tests;
internal static class MeetingWorkflowTestRules
{
public const string MaskedProfanityRedactionYaml =
"""
rules:
- name: redact-masked-profanity
on:
- transcript_line: {}
if:
- condition: contains(transcript.line, '*****')
steps:
- uses: set_property
property: transcript.line
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
""";
}
@@ -0,0 +1,54 @@
using MeetingAssistant.Recording;
namespace MeetingAssistant.Tests;
public sealed class MicrophoneSelectionTests
{
[Fact]
public void BlankConfiguredMicrophoneUsesDefaultDevice()
{
var selection = new MicrophoneDeviceSelection();
var selected = selection.Resolve(
configuredDeviceId: null,
new MicrophoneDevice("default-id", "default microphone"),
[
new MicrophoneDevice("default-id", "default microphone"),
new MicrophoneDevice("other-id", "other microphone")
]);
Assert.Equal("default-id", selected?.Id);
}
[Fact]
public void ConfiguredMicrophoneUsesMatchingDevice()
{
var selection = new MicrophoneDeviceSelection();
var selected = selection.Resolve(
"other-id",
new MicrophoneDevice("default-id", "default microphone"),
[
new MicrophoneDevice("default-id", "default microphone"),
new MicrophoneDevice("other-id", "other microphone")
]);
Assert.Equal("other-id", selected?.Id);
}
[Fact]
public void RuntimeMicrophoneSelectionOverridesConfiguredDevice()
{
var selection = new MicrophoneDeviceSelection();
selection.Select("runtime-id");
var selected = selection.Resolve(
"configured-id",
new MicrophoneDevice("default-id", "default microphone"),
[
new MicrophoneDevice("configured-id", "configured microphone"),
new MicrophoneDevice("runtime-id", "runtime microphone")
]);
Assert.Equal("runtime-id", selected?.Id);
}
}
@@ -0,0 +1,28 @@
using MeetingAssistant.Recording;
namespace MeetingAssistant.Tests;
public sealed class NotificationActivationArgumentsTests
{
[Fact]
public void ParseAcceptsSemicolonDelimitedToastActivationArguments()
{
var arguments = NotificationActivationArguments.Parse(
"source=meeting-inactivity-safeguard;promptId=abc123;response=stop");
Assert.Equal("meeting-inactivity-safeguard", arguments["source"]);
Assert.Equal("abc123", arguments["promptId"]);
Assert.Equal("stop", arguments["response"]);
}
[Fact]
public void ParseAcceptsAmpersandDelimitedActivationArguments()
{
var arguments = NotificationActivationArguments.Parse(
"source=meeting-inactivity-safeguard&promptId=abc123&response=continue");
Assert.Equal("meeting-inactivity-safeguard", arguments["source"]);
Assert.Equal("abc123", arguments["promptId"]);
Assert.Equal("continue", arguments["response"]);
}
}
@@ -0,0 +1,26 @@
using MeetingAssistant.Notifications;
namespace MeetingAssistant.Tests;
public sealed class NotificationExpirationPolicyTests
{
[Fact]
public void MeetingStopReminderExpiresAfterOneMinute()
{
var now = new DateTimeOffset(2026, 6, 5, 9, 30, 0, TimeSpan.Zero);
var expiration = MeetingToastExpirationPolicy.StopReminderExpiration(now);
Assert.Equal(now.AddMinutes(1), expiration);
}
[Fact]
public void MeetingStartPromptExpiresAfterFiveMinutes()
{
var now = new DateTimeOffset(2026, 6, 5, 9, 30, 0, TimeSpan.Zero);
var expiration = MeetingToastExpirationPolicy.StartRecordingPromptExpiration(now);
Assert.Equal(now.AddMinutes(5), expiration);
}
}
@@ -8,7 +8,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
[Fact]
public void ExtractAgendaStopsBeforeTeamsJoinInformation()
{
var agenda = OutlookClassicMeetingMetadataProvider.ExtractAgenda(
var agenda = OutlookClassicAppointmentMetadata.ExtractAgenda(
"""
Review current prototype
Decide next backend
@@ -25,7 +25,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
[Fact]
public void NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail()
{
var attendees = OutlookClassicMeetingMetadataProvider.NormalizeAttendees([
var attendees = OutlookClassicAppointmentMetadata.NormalizeAttendees([
"Marcus.Altmann@sew-eurodrive.de",
"Marcus.Altmann@sew-eurodrive.de <Marcus.Altmann@sew-eurodrive.de>",
"Schweigert, Manuel",
@@ -37,5 +37,14 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
"Schweigert, Manuel"
], attendees);
}
[Theory]
[InlineData("Canceled: Project sync")]
[InlineData("Cancelled: Project sync")]
[InlineData("Abgesagt: Project sync")]
public void SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(string subject)
{
Assert.True(OutlookClassicCom.SubjectIndicatesCancellation(subject));
}
}
#endif
@@ -0,0 +1,277 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
namespace MeetingAssistant.Tests;
public sealed class PastProjectMeetingSummaryToolTests
{
[Fact]
public async Task ListPastProjectMeetingsReturnsPagedSummariesForCurrentMeetingProjects()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y");
await WriteSummaryAsync(Path.Combine(summariesRoot, "current-summary.md"), "Current", "2026-05-29T09:00:00+02:00", "Project X");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x-new.md"), "Project X New", "2026-05-28T09:00:00+02:00", "Project X");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-y.md"), "Project Y", "2026-05-27T09:00:00+02:00", "Project Y");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-26T09:00:00+02:00", "Project Z");
await File.WriteAllTextAsync(Path.Combine(summariesRoot, "not-a-summary.md"), "No frontmatter");
artifacts = artifacts with { SummaryPath = Path.Combine(summariesRoot, "current-summary.md") };
var tools = CreateTools(artifacts, root);
var firstPage = await tools.ListPastProjectMeetings(page: 1, page_size: 1);
var secondPage = await tools.ListPastProjectMeetings(page: 2, page_size: 1);
Assert.Contains("page 1/2", firstPage);
Assert.Contains("project-x-new.md | 2026-05-28T09:00:00.0000000+02:00 | Project X New | projects: Project X", firstPage);
Assert.DoesNotContain("project-y.md", firstPage);
Assert.Contains("page 2/2", secondPage);
Assert.Contains("project-y.md", secondPage);
Assert.DoesNotContain("current-summary.md", firstPage + secondPage);
Assert.DoesNotContain("project-z.md", firstPage + secondPage);
Assert.DoesNotContain("not-a-summary.md", firstPage + secondPage);
}
[Fact]
public async Task ListPastProjectMeetingsFiltersAndRejectsOutOfScopeProjects()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-y.md"), "Project Y", "2026-05-27T09:00:00+02:00", "Project Y");
var tools = CreateTools(artifacts, root);
var projectX = await tools.ListPastProjectMeetings(["Project X"]);
var refused = await tools.ListPastProjectMeetings(["Project Z"]);
Assert.Contains("project-x.md", projectX);
Assert.DoesNotContain("project-y.md", projectX);
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
}
[Fact]
public async Task ListPastProjectMeetingsRejectsOutOfScopeProjectsWhenMeetingHasNoProjects()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(Path.Combine(root, "Meetings", "Summaries"));
await WriteMeetingNoteAsync(artifacts.MeetingNotePath);
var tools = CreateTools(artifacts, root);
var refused = await tools.ListPastProjectMeetings(["Project Z"]);
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
}
[Fact]
public async Task ReadPastProjectMeetingSummaryReadsOnlyScopedPastSummaryFiles()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
await WriteSummaryAsync(
Path.Combine(summariesRoot, "project-x.md"),
"Project X",
"2026-05-28T09:00:00+02:00",
"Project X",
"line one\nline two\nline three");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-27T09:00:00+02:00", "Project Z");
await WriteSummaryAsync(artifacts.SummaryPath, "Current", "2026-05-29T09:00:00+02:00", "Project X");
var outside = Path.Combine(root, "outside.md");
await File.WriteAllTextAsync(outside, "outside");
var tools = CreateTools(artifacts, root);
Assert.Equal("line two\nline three", await tools.ReadPastProjectMeetingSummary("project-x.md", from: 9, to: 99));
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary("project-z.md"));
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary("current-summary.md"));
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary(outside));
}
[Fact]
public async Task ReadPastProjectMeetingSummaryAcceptsSearchResultPastMeetingPrefix()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
await WriteSummaryAsync(
Path.Combine(summariesRoot, "project-x.md"),
"Project X",
"2026-05-28T09:00:00+02:00",
"Project X",
"searchable past meeting");
var tools = CreateTools(artifacts, root);
var search = await tools.Search("searchable", ["Project X"]);
var content = await tools.ReadPastProjectMeetingSummary("past-meetings/project-x.md");
Assert.Contains("past-meetings/project-x.md:", search);
Assert.Contains("searchable past meeting", search);
Assert.Contains("searchable past meeting", content);
}
[Fact]
public async Task ListPastProjectMeetingsIgnoresMalformedSummaryFrontmatter()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
await File.WriteAllTextAsync(
Path.Combine(summariesRoot, "broken.md"),
"""
---
projects: [not valid
---
broken
""");
var tools = CreateTools(artifacts, root);
var result = await tools.ListPastProjectMeetings();
Assert.Contains("project-x.md", result);
Assert.DoesNotContain("broken.md", result);
}
[Fact]
public async Task ListPastProjectMeetingsClampsRequestedPageToLastPage()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
var tools = CreateTools(artifacts, root);
var result = await tools.ListPastProjectMeetings(page: 99, page_size: 1);
Assert.Contains("page 1/1", result);
Assert.Contains("project-x.md", result);
}
[Fact]
public async Task SearchIncludesProjectFilesAndScopedPastMeetingSummaries()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
var projectsRoot = Path.Combine(root, "Projects");
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(Path.Combine(projectsRoot, "Project X"));
Directory.CreateDirectory(Path.Combine(projectsRoot, "Project Y"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(summariesRoot);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y");
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Project X", "notes.md"), "alpha project file");
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Project Y", "notes.md"), "alpha other project");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X", "alpha past meeting");
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-27T09:00:00+02:00", "Project Z", "alpha hidden meeting");
var tools = CreateTools(artifacts, root);
var projectX = await tools.Search("alpha", ["Project X"]);
var refused = await tools.Search("alpha", ["Project Z"]);
Assert.Contains("notes.md:1 alpha project file", projectX);
Assert.Contains("past-meetings/project-x.md:", projectX);
Assert.DoesNotContain("Project Y/notes.md", projectX);
Assert.DoesNotContain("project-z.md", projectX);
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
}
[Fact]
public async Task SearchReportsInvalidPatternInsteadOfNoMatches()
{
var root = CreateRoot();
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
var tools = CreateTools(artifacts, root);
var result = await tools.Search("[", ["Project X"]);
Assert.StartsWith("Search failed: invalid .NET regular expression:", result);
}
private static string CreateRoot()
{
return Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
{
return 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", "current-summary.md"));
}
private static MeetingSummaryTools CreateTools(MeetingSessionArtifacts artifacts, string root)
{
return new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = Path.Combine(root, "Projects"),
SummariesFolder = Path.Combine(root, "Meetings", "Summaries")
}
});
}
private static Task WriteMeetingNoteAsync(string path, params string[] projects)
{
var projectLines = string.Join('\n', projects.Select(project => $"- {project}"));
return File.WriteAllTextAsync(
path,
$"""
---
title: Current Meeting
projects:
{projectLines}
---
Current notes.
""");
}
private static Task WriteSummaryAsync(
string path,
string title,
string startTime,
string project,
string body = "summary body")
{
return File.WriteAllTextAsync(
path,
$$"""
---
title: {{title}}
start_time: "{{startTime}}"
projects:
- {{project}}
---
{{body}}
""");
}
}
@@ -0,0 +1,25 @@
using System.Diagnostics;
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Tests;
public sealed class ProcessCommandRunnerTests
{
[Fact]
public void CreateStartInfoRunsCliProcessesWithoutVisibleWindows()
{
var startInfo = ProcessCommandRunner.CreateStartInfo(
"docker",
["run", "--rm", "meeting-assistant-pyannote:local"],
new Dictionary<string, string> { ["HF_TOKEN"] = "token" });
Assert.False(startInfo.UseShellExecute);
Assert.True(startInfo.CreateNoWindow);
Assert.Equal(ProcessWindowStyle.Hidden, startInfo.WindowStyle);
Assert.True(startInfo.RedirectStandardOutput);
Assert.True(startInfo.RedirectStandardError);
Assert.Equal("docker", startInfo.FileName);
Assert.Equal(["run", "--rm", "meeting-assistant-pyannote:local"], startInfo.ArgumentList);
Assert.Equal("token", startInfo.Environment["HF_TOKEN"]);
}
}
@@ -57,14 +57,42 @@ 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 ListProjectsAcceptsScalarProjectFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
projects: MeetingAssistant
---
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
}
[Fact]
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 +116,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 +146,42 @@ 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));
}
[Fact]
public async Task SearchCanBeLimitedByProjectFilePattern()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant");
Directory.CreateDirectory(projectRoot);
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), "durable needle");
await File.WriteAllTextAsync(Path.Combine(projectRoot, "scratch.txt"), "scratch needle");
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
projects:
- MeetingAssistant
---
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
var search = await tools.Search("needle", file_pattern: "*.md");
Assert.Contains("PROJECT.md:1 durable needle", search);
Assert.DoesNotContain("scratch.txt", search);
}
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()
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
using MeetingAssistant;
namespace MeetingAssistant.Tests;
public sealed class ScreenshotOcrOptionsTests
{
[Fact]
public void DefaultPromptAsksForCompleteOrPartialVisibleParticipantEvidence()
{
Assert.Contains("exactly who is in the meeting", ScreenshotOcrOptions.DefaultPrompt);
Assert.Contains("partial", ScreenshotOcrOptions.DefaultPrompt);
Assert.Contains("\"attendees\"", ScreenshotOcrOptions.DefaultPrompt);
}
}
@@ -0,0 +1,124 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging;
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");
}
[Fact]
public void CollectorLogsWhenSampleIsDiscardedBecauseSpeechIsTooShort()
{
var logger = new CapturingLogger();
var collector = new SpeakerAudioSampleCollector(
TimeSpan.FromMinutes(2),
maxSamplesPerSpeaker: 3,
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
maximumSegmentGap: TimeSpan.FromSeconds(1),
logger: logger);
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35)));
collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five."));
var message = Assert.Single(
logger.Messages,
message => message.Contains("Discarding speaker identity sample for Guest01", StringComparison.Ordinal));
Assert.Contains("duration", message);
Assert.Contains("minimum duration", message);
}
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;
}
private sealed class CapturingLogger : ILogger
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}
@@ -3,6 +3,7 @@ using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -149,6 +150,28 @@ public sealed class SpeakerIdentityServiceTests
});
}
[Fact]
public async Task RejectedUnmatchedSpeakerSampleIsLoggedWithSpeakerAndCandidateContext()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
fixture.MatchValidator.SampleIsValid = false;
var logger = new CapturingLogger<SpeakerIdentityService>();
var service = fixture.CreateService(logger);
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["John", "Mike"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown speaker")]),
CancellationToken.None);
var message = Assert.Single(
logger.Messages,
message => message.Contains("Skipping speaker identity candidate for Guest01", StringComparison.Ordinal));
Assert.Contains("Skipping speaker identity candidate for Guest01", message);
Assert.Contains("candidate names John, Mike", message);
Assert.Contains("sample bytes 3", message);
}
[Fact]
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
{
@@ -199,6 +222,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()
{
@@ -220,6 +297,132 @@ public sealed class SpeakerIdentityServiceTests
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
}
[Fact]
public async Task SpeakerOverrideAttachesCurrentSampleToExistingNamedIdentity()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1);
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
var request = fixture.CreateRequest(
["Sabrina"],
[segment],
[new SpeakerAudioSample("Guest-01", segment, [9, 9, 9], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Sabrina"],
[segment with { Speaker = "Sabrina" }],
knownSpeakerMappings: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Guest-01"] = "Sabrina"
}),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(sabrina.Id);
Assert.Equal("Sabrina", saved.CanonicalName);
Assert.Equal(2, saved.References.Count);
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 9, 9, 9 }));
var allIdentities = await fixture.Context.SpeakerIdentities.ToListAsync();
Assert.Single(allIdentities);
}
[Fact]
public async Task SpeakerOverrideCreatesNamedIdentityWhenNoAcceptedNameExists()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
var request = fixture.CreateRequest(
["Sabrina"],
[segment],
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
var saved = await fixture.Context.SpeakerIdentities
.Include(identity => identity.References)
.Include(identity => identity.Snippets)
.SingleAsync();
Assert.Equal("Sabrina", saved.CanonicalName);
Assert.Single(saved.References);
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 }));
}
[Fact]
public async Task SpeakerOverrideExtractsOnlyBestContinuousSourceSpanWhenNoLiveSampleExists()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var service = fixture.CreateService();
var request = fixture.CreateRequest(
["Sabrina"],
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(10), "Guest-01", "short start"),
new TranscriptionSegment(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), "Guest-02", "someone else"),
new TranscriptionSegment(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(80), "Guest-01", "continuous first part"),
new TranscriptionSegment(TimeSpan.FromSeconds(80.5), TimeSpan.FromSeconds(92), "Guest-01", "continuous second part"),
new TranscriptionSegment(TimeSpan.FromSeconds(180), TimeSpan.FromSeconds(190), "Guest-01", "short end")
]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
Assert.Equal(1, fixture.SnippetExtractor.ExtractCalls);
Assert.Collection(
fixture.SnippetExtractor.LastSpeakerSegments,
segment => Assert.Equal(TimeSpan.FromSeconds(60), segment.Start),
segment => Assert.Equal(TimeSpan.FromSeconds(80.5), segment.Start));
}
[Fact]
public async Task SpeakerOverrideDoesNotCreateNamedIdentityWhenSecondaryValidationRejectsSample()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
fixture.MatchValidator.SampleIsValid = false;
var service = fixture.CreateService();
var request = fixture.CreateRequest(
["Sabrina"],
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(31), "Guest-01", "long enough source sample")
]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
Assert.Equal(1, fixture.MatchValidator.ValidateSampleCalls);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
[Fact]
public async Task SpeakerOverrideDoesNotCreateNamedIdentityWithoutSourceSample()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
var request = fixture.CreateRequest(
["Sabrina"],
[segment],
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-5", "Sabrina", CancellationToken.None);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
[Fact]
public async Task SpeakerIdentityDeletionRemovesAcceptedNameFromDatabase()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1, aliases: ["Sabi"]);
await fixture.AddIdentityAsync([], [4, 5, 6], "Manuel", referenceCount: 1);
var service = fixture.CreateService();
await service.DeleteSpeakerIdentityAsync("Sabi", CancellationToken.None);
var identities = await fixture.Context.SpeakerIdentities.ToListAsync();
Assert.DoesNotContain(identities, identity => identity.Id == sabrina.Id);
Assert.Contains(identities, identity => identity.CanonicalName == "Manuel");
}
[Fact]
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
{
@@ -382,6 +585,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;
@@ -404,6 +688,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(
@@ -416,7 +702,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>()
@@ -426,14 +713,15 @@ public sealed class SpeakerIdentityServiceTests
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
}
public SpeakerIdentityService CreateService()
public SpeakerIdentityService CreateService(ILogger<SpeakerIdentityService>? logger = null)
{
return new SpeakerIdentityService(
new TestSpeakerIdentityDbContextFactory(dbPath),
SnippetExtractor,
Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance);
logger ?? NullLogger<SpeakerIdentityService>.Instance,
MatchValidator);
}
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
@@ -554,12 +842,20 @@ public sealed class SpeakerIdentityServiceTests
{
public int ExtractCalls { get; private set; }
public IReadOnlyList<TranscriptionSegment> LastSpeakerSegments { get; private set; } = [];
public Task<byte[]> ExtractSnippetAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken)
{
if (speakerSegments.Count == 0)
{
return Task.FromResult<byte[]>([]);
}
ExtractCalls++;
LastSpeakerSegments = speakerSegments.ToList();
return Task.FromResult<byte[]>([7, 8, 9]);
}
}
@@ -589,4 +885,52 @@ public sealed class SpeakerIdentityServiceTests
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
}
}
private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public bool SampleIsValid { get; set; } = true;
public int ValidateSampleCalls { get; private set; }
public Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
ValidateSampleCalls++;
return Task.FromResult(SampleIsValid);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}
@@ -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 [];
}
}
+228
View File
@@ -0,0 +1,228 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
using MeetingAssistant.Taskbar;
using System.Drawing;
using System.Runtime.Versioning;
namespace MeetingAssistant.Tests;
public sealed class TaskbarIconTests
{
[Fact]
public void IdleMenuOffersStartForEachConfiguredProfile()
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(),
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+L")]);
Assert.Equal(RecordingProcessState.Idle, menu.State);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.EditRules &&
item.Text == "Open agent");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "default" &&
item.Text == "Start meeting recording (default)\tCtrl+Alt+M");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "english" &&
item.Text == "Start meeting recording (english)\tCtrl+Alt+L");
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
}
[Fact]
public void MenuOffersMicrophoneSubmenuWithEffectiveDeviceChecked()
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(),
[Profile("default")],
[
new MicrophoneDevice("integrated", "integrated microphone"),
new MicrophoneDevice("other", "other microphone")
],
"integrated");
var microphoneMenu = Assert.Single(menu.Items, item => item.Text == "Microphone");
Assert.Equal(MeetingTaskbarAction.OpenSubmenu, microphoneMenu.Action);
Assert.NotNull(microphoneMenu.Items);
Assert.Contains(microphoneMenu.Items, item =>
item.Action == MeetingTaskbarAction.SelectMicrophone &&
item.MicrophoneDeviceId == "integrated" &&
item.Text == "integrated microphone" &&
item.IsChecked);
Assert.Contains(microphoneMenu.Items, item =>
item.Action == MeetingTaskbarAction.SelectMicrophone &&
item.MicrophoneDeviceId == "other" &&
item.Text == "other microphone" &&
!item.IsChecked);
}
[Fact]
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+L"), Profile("french", "Ctrl+Alt+F")]);
Assert.Equal(RecordingProcessState.Recording, menu.State);
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.SwitchProfile &&
item.ProfileName == "english" &&
item.Text == "Switch to english\tCtrl+Alt+L");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.SwitchProfile &&
item.ProfileName == "french" &&
item.Text == "Switch to french\tCtrl+Alt+F");
Assert.DoesNotContain(menu.Items, item =>
item.Action == MeetingTaskbarAction.SwitchProfile &&
item.ProfileName == "default");
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StartRecording);
}
[Fact]
public void ProcessingMenuShowsSummarizingButAllowsStartingNewRecordings()
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(state: RecordingProcessState.Summarizing, profile: "default"),
[Profile("default"), Profile("english")]);
Assert.Equal(RecordingProcessState.Summarizing, menu.State);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "default");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "english");
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
}
[Fact]
public void RecordingStateWinsOverOlderSummarizingWork()
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "english"),
[Profile("default"), Profile("english")]);
Assert.Equal(RecordingProcessState.Recording, menu.State);
}
[Theory]
[InlineData(RecordingProcessState.Idle, false)]
[InlineData(RecordingProcessState.Recording, true)]
[InlineData(RecordingProcessState.Summarizing, false)]
public void MenuAlwaysOffersExit(RecordingProcessState state, bool isRecording)
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(isRecording: isRecording, state: state, profile: "default"),
[Profile("default"), Profile("english")]);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.Exit &&
item.Text == "Exit");
}
[Theory]
[InlineData(RecordingProcessState.Idle, false)]
[InlineData(RecordingProcessState.Recording, true)]
[InlineData(RecordingProcessState.Summarizing, true)]
public void ExitRequiresConfirmationWhileRecordingOrProcessing(
RecordingProcessState state,
bool requiresConfirmation)
{
Assert.Equal(
requiresConfirmation,
MeetingTaskbarExitPolicy.RequiresConfirmation(state));
}
[Fact]
[SupportedOSPlatform("windows")]
public void TaskbarIconGlyphsAreVisuallyCentered()
{
foreach (var state in new[]
{
RecordingProcessState.Idle,
RecordingProcessState.Recording,
RecordingProcessState.Summarizing
})
{
using var bitmap = TaskbarIconRenderer.RenderBitmap(state);
var bounds = GetVisibleGlyphBounds(bitmap);
Assert.True(
IsCentered(GetCenter(bounds.Left, bounds.Right)),
$"{state} glyph is horizontally off-center: {bounds}");
Assert.True(
IsCentered(GetCenter(bounds.Top, bounds.Bottom)),
$"{state} glyph is vertically off-center: {bounds}");
}
}
private static LaunchProfile Profile(string name, string hotkey = "")
{
return new LaunchProfile(name, new MeetingAssistantOptions
{
Hotkey =
{
Toggle = hotkey
}
});
}
private static RecordingStatus Status(
bool isRecording = false,
RecordingProcessState state = RecordingProcessState.Idle,
string? profile = null)
{
return new RecordingStatus(
isRecording,
state == RecordingProcessState.Idle ? null : "transcript.md",
state == RecordingProcessState.Idle ? null : "meeting.md",
state == RecordingProcessState.Idle ? null : "context.md",
state == RecordingProcessState.Idle ? null : "summary.md",
state,
profile);
}
[SupportedOSPlatform("windows")]
private static Rectangle GetVisibleGlyphBounds(Bitmap bitmap)
{
var left = bitmap.Width;
var top = bitmap.Height;
var right = -1;
var bottom = -1;
for (var y = 0; y < bitmap.Height; y++)
{
for (var x = 0; x < bitmap.Width; x++)
{
var pixel = bitmap.GetPixel(x, y);
if (pixel.A < 32 || pixel.GetBrightness() < 0.58f)
{
continue;
}
left = Math.Min(left, x);
top = Math.Min(top, y);
right = Math.Max(right, x);
bottom = Math.Max(bottom, y);
}
}
Assert.True(right >= left && bottom >= top, "Expected the icon to contain a visible white glyph.");
return Rectangle.FromLTRB(left, top, right + 1, bottom + 1);
}
private static double GetCenter(int start, int end)
{
return (start + end) / 2.0;
}
private static bool IsCentered(double center)
{
return center is >= 7.25 and <= 8.75;
}
}
@@ -0,0 +1,44 @@
using MeetingAssistant.Workflow;
namespace MeetingAssistant.Tests;
internal sealed class TransformingAttendeeWorkflowEngine : IMeetingWorkflowEngine
{
private readonly string source;
private readonly string replacement;
public TransformingAttendeeWorkflowEngine(string source, string replacement)
{
this.source = source;
this.replacement = replacement;
}
public List<string> AttendeeRequests { get; } = [];
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
public Task<string> TransformAttendeeAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
AttendeeRequests.Add(workflowEvent.AttendeeName ?? "");
return Task.FromResult(string.Equals(workflowEvent.AttendeeName, source, StringComparison.OrdinalIgnoreCase)
? replacement
: workflowEvent.AttendeeName ?? "");
}
}
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
using System.Text.RegularExpressions;
namespace MeetingAssistant;
internal static class AgentFileToolContent
{
public static string ReadLines(string content, int? from = null, int? to = null, int? tail = null)
{
if (!from.HasValue && !to.HasValue)
{
return tail.HasValue
? TailLines(content, Math.Clamp(tail.Value, 1, 2000))
: content;
}
var lines = SplitLines(content);
if (lines.Count == 0)
{
return "";
}
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
if (end < start)
{
return "";
}
return string.Join('\n', lines.GetRange(start, end - start + 1));
}
public static string TailLines(string content, int count)
{
var lines = SplitLines(content);
if (lines.Count > 0 && lines[^1].Length == 0)
{
lines.RemoveAt(lines.Count - 1);
}
if (lines.Count <= count)
{
return string.Join('\n', lines);
}
return string.Join('\n', lines.GetRange(lines.Count - count, count));
}
public static string ApplyLineEdit(
string existingContent,
string replacementContent,
AgentFileEditMode editMode)
{
if (editMode.Kind == AgentFileEditKind.Overwrite)
{
return replacementContent;
}
if (editMode.Kind == AgentFileEditKind.Append)
{
return AppendContent(existingContent, replacementContent);
}
var lines = SplitLines(existingContent);
var replacementLines = SplitLines(replacementContent);
if (editMode.Kind == AgentFileEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
return string.Join('\n', lines);
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
return string.Join('\n', lines);
}
public static async Task WriteFileContentAsync(
string filePath,
string content,
AgentFileEditMode editMode)
{
if (editMode.Kind == AgentFileEditKind.Overwrite)
{
await File.WriteAllTextAsync(filePath, content);
return;
}
if (editMode.Kind == AgentFileEditKind.Append)
{
var existing = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, AppendContent(existing, content));
return;
}
var existingContent = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, ApplyLineEdit(existingContent, content, editMode));
}
public 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;
}
public static List<string> SplitLines(string content)
{
if (content.Length == 0)
{
return [];
}
return content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.ToList();
}
public static bool IsWithinDirectory(string directory, string path)
{
var normalizedDirectory = Path.GetFullPath(directory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
}
public static bool MatchesGlob(string path, string? pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return true;
}
var normalizedPath = ToToolPath(path);
var normalizedPattern = ToToolPath(pattern.Trim());
var placeholder = "\u0000";
var regexPattern = Regex.Escape(normalizedPattern)
.Replace("\\*\\*", placeholder, StringComparison.Ordinal)
.Replace("\\*", "[^/]*", StringComparison.Ordinal)
.Replace("\\?", "[^/]", StringComparison.Ordinal)
.Replace(placeholder, ".*", StringComparison.Ordinal);
return Regex.IsMatch(normalizedPath, $"^{regexPattern}$", RegexOptions.IgnoreCase);
}
public static string ToToolPath(string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
public static string SearchFiles(
string keywords,
IEnumerable<AgentFileSearchSource> sources,
int maxMatches = 100,
RegexOptions regexOptions = RegexOptions.IgnoreCase)
{
if (string.IsNullOrWhiteSpace(keywords))
{
return "";
}
try
{
var regex = new Regex(keywords, regexOptions);
var matches = new List<string>();
var limit = Math.Clamp(maxMatches, 1, 1000);
foreach (var source in sources)
{
var lines = ReadAllLinesShared(source.Path);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{ToToolPath(source.DisplayPath)}:{index + 1} {lines[index]}");
}
if (matches.Count >= limit)
{
return string.Join('\n', matches);
}
}
}
return string.Join('\n', matches);
}
catch
{
return "";
}
}
public static async Task<string> ReadAllTextSharedAsync(string path)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 4096,
useAsync: true);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
private static string[] ReadAllLinesShared(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
using var reader = new StreamReader(stream);
return SplitLines(reader.ReadToEnd()).ToArray();
}
}
internal sealed record AgentFileSearchSource(string Path, string DisplayPath);
internal sealed record AgentFileEditMode(
AgentFileEditKind Kind,
int? From = null,
int? To = null,
int? Insert = null)
{
public static AgentFileEditMode? 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
? null
: new AgentFileEditMode(AgentFileEditKind.Insert, Insert: insert);
}
if (from.HasValue || to.HasValue)
{
return from.HasValue && to.HasValue && from.Value <= to.Value
? new AgentFileEditMode(AgentFileEditKind.Replace, From: from, To: to)
: null;
}
return new AgentFileEditMode(replaceFile ? AgentFileEditKind.Overwrite : AgentFileEditKind.Append);
}
}
internal enum AgentFileEditKind
{
Append,
Overwrite,
Replace,
Insert
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,326 @@
using MeetingAssistant.Recording;
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Calendar;
public sealed class CalendarRecordingPromptScheduler : BackgroundService
{
private readonly IOptions<MeetingAssistantOptions> options;
private readonly ICalendarMeetingProvider meetingProvider;
private readonly IMeetingStartPromptService promptService;
private readonly IMeetingPromptRecordingController recordingController;
private readonly ICalendarPromptClock clock;
private readonly ILogger<CalendarRecordingPromptScheduler> logger;
private readonly HashSet<string> promptedMeetingKeys = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<CalendarMeeting> cachedMeetings = [];
private DateOnly promptedMeetingDay;
private DateOnly cachedMeetingDay;
private DateTimeOffset nextSyncAt;
public CalendarRecordingPromptScheduler(
IOptions<MeetingAssistantOptions> options,
ICalendarMeetingProvider meetingProvider,
IMeetingStartPromptService promptService,
IMeetingPromptRecordingController recordingController,
ICalendarPromptClock clock,
ILogger<CalendarRecordingPromptScheduler> logger)
{
this.options = options;
this.meetingProvider = meetingProvider;
this.promptService = promptService;
this.recordingController = recordingController;
this.clock = clock;
this.logger = logger;
}
public async Task SyncOnceAsync(CancellationToken cancellationToken)
{
var promptOptions = options.Value.CalendarRecordingPrompts;
if (!promptOptions.Enabled)
{
return;
}
var now = clock.Now;
var today = DateOnly.FromDateTime(now.LocalDateTime);
ResetPromptedMeetingsIfDayChanged(today);
try
{
cachedMeetings = await meetingProvider.GetRecordingPromptCandidatesForDayAsync(today, cancellationToken);
cachedMeetingDay = today;
nextSyncAt = now.Add(GetSyncInterval(promptOptions));
logger.LogInformation(
"Synced {MeetingCount} Outlook Teams calendar meetings for {Day}; next sync at {NextSyncAt}",
cachedMeetings.Count,
today,
nextSyncAt);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogWarning(exception, "Could not read today's Outlook Teams meetings for recording prompts");
nextSyncAt = now.Add(GetSyncInterval(promptOptions));
return;
}
}
public async Task CheckDuePromptsAsync(CancellationToken cancellationToken)
{
var promptOptions = options.Value.CalendarRecordingPrompts;
if (!promptOptions.Enabled)
{
return;
}
var now = clock.Now;
var today = DateOnly.FromDateTime(now.LocalDateTime);
ResetPromptedMeetingsIfDayChanged(today);
if (cachedMeetingDay != today)
{
cachedMeetings = [];
return;
}
foreach (var meeting in cachedMeetings.OrderBy(meeting => meeting.Start))
{
var promptKey = GetPromptKey(meeting);
if (meeting.IsCanceled ||
promptedMeetingKeys.Contains(promptKey) ||
!IsInPromptWindow(meeting, now, promptOptions))
{
continue;
}
promptedMeetingKeys.Add(promptKey);
logger.LogInformation(
"Prompting to record calendar meeting {Subject} scheduled at {Start}",
meeting.Subject,
meeting.Start);
await promptService.ShowPromptAsync(
new MeetingStartPromptRequest(meeting),
(response, token) => HandlePromptResponseAsync(meeting, response, token),
cancellationToken);
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var promptOptions = options.Value.CalendarRecordingPrompts;
if (!promptOptions.Enabled)
{
await Task.Delay(GetSyncInterval(promptOptions), stoppingToken);
continue;
}
var now = clock.Now;
var today = DateOnly.FromDateTime(now.LocalDateTime);
if (cachedMeetingDay != today || now >= nextSyncAt)
{
await SyncOnceAsync(stoppingToken);
}
await CheckDuePromptsAsync(stoppingToken);
var delay = GetDelayUntilNextWork(clock.Now);
if (delay <= TimeSpan.Zero)
{
continue;
}
await Task.Delay(delay, stoppingToken);
}
}
private async Task HandlePromptResponseAsync(
CalendarMeeting meeting,
MeetingStartPromptResponse response,
CancellationToken cancellationToken)
{
if (response != MeetingStartPromptResponse.Record)
{
return;
}
if (recordingController.CurrentStatus.IsRecording)
{
await recordingController.StopAsync(cancellationToken);
}
await recordingController.StartFromPromptAsync(meeting.Metadata, cancellationToken);
}
private void ResetPromptedMeetingsIfDayChanged(DateOnly today)
{
if (promptedMeetingDay == today)
{
return;
}
promptedMeetingDay = today;
promptedMeetingKeys.Clear();
}
private static bool IsInPromptWindow(
CalendarMeeting meeting,
DateTimeOffset now,
CalendarRecordingPromptOptions options)
{
var promptWindow = options.PromptWindow > TimeSpan.Zero
? options.PromptWindow
: TimeSpan.FromMinutes(5);
return now >= meeting.Start &&
now <= meeting.Start.Add(promptWindow) &&
now < meeting.End;
}
private TimeSpan GetDelayUntilNextWork(DateTimeOffset now)
{
if (nextSyncAt <= DateTimeOffset.MinValue || now >= nextSyncAt)
{
return TimeSpan.Zero;
}
var syncDelay = nextSyncAt - now;
var nextMeetingStart = cachedMeetings
.Where(meeting => !meeting.IsCanceled)
.Where(meeting => !promptedMeetingKeys.Contains(GetPromptKey(meeting)))
.Where(meeting => meeting.End > now)
.Select(meeting => meeting.Start <= now ? now : meeting.Start)
.Order()
.FirstOrDefault();
if (nextMeetingStart == default)
{
return syncDelay;
}
var meetingDelay = nextMeetingStart - now;
return meetingDelay <= TimeSpan.Zero
? TimeSpan.Zero
: meetingDelay < syncDelay ? meetingDelay : syncDelay;
}
private static TimeSpan GetSyncInterval(CalendarRecordingPromptOptions options)
{
return options.SyncInterval > TimeSpan.Zero
? options.SyncInterval
: TimeSpan.FromMinutes(30);
}
private static string GetPromptKey(CalendarMeeting meeting)
{
if (!string.IsNullOrWhiteSpace(meeting.Id))
{
return meeting.Id;
}
return $"{meeting.Start:O}|{meeting.End:O}|{meeting.Subject}";
}
}
public interface ICalendarMeetingProvider
{
Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken);
}
public sealed record CalendarMeeting(
string Id,
string Subject,
DateTimeOffset Start,
DateTimeOffset End,
MeetingMetadata? Metadata = null,
bool IsCanceled = false);
public interface IMeetingStartPromptService
{
Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken);
}
public sealed record MeetingStartPromptRequest(CalendarMeeting Meeting);
public enum MeetingStartPromptResponse
{
Record,
Skip
}
public interface IMeetingPromptRecordingController
{
RecordingStatus CurrentStatus { get; }
Task<RecordingStatus> StartAsync(CancellationToken cancellationToken);
Task<RecordingStatus> StartFromPromptAsync(
MeetingMetadata? metadata,
CancellationToken cancellationToken);
Task<RecordingStatus> StopAsync(CancellationToken cancellationToken);
}
public sealed class MeetingPromptRecordingController : IMeetingPromptRecordingController
{
private readonly MeetingRecordingCoordinator coordinator;
public MeetingPromptRecordingController(MeetingRecordingCoordinator coordinator)
{
this.coordinator = coordinator;
}
public RecordingStatus CurrentStatus => coordinator.CurrentStatus;
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
return coordinator.StartAsync(cancellationToken);
}
public Task<RecordingStatus> StartFromPromptAsync(
MeetingMetadata? metadata,
CancellationToken cancellationToken)
{
return coordinator.StartFromPromptAsync(metadata, cancellationToken);
}
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
return coordinator.StopAsync(cancellationToken);
}
}
public interface ICalendarPromptClock
{
DateTimeOffset Now { get; }
}
public sealed class SystemCalendarPromptClock : ICalendarPromptClock
{
public DateTimeOffset Now => DateTimeOffset.Now;
}
public sealed class NoopCalendarMeetingProvider : ICalendarMeetingProvider
{
public Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<CalendarMeeting>>([]);
}
}
public sealed class NoopMeetingStartPromptService : IMeetingStartPromptService
{
public Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,105 @@
#if WINDOWS
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Calendar;
public sealed class OutlookClassicCalendarMeetingProvider : ICalendarMeetingProvider
{
private readonly ILogger<OutlookClassicCalendarMeetingProvider> logger;
public OutlookClassicCalendarMeetingProvider(ILogger<OutlookClassicCalendarMeetingProvider> logger)
{
this.logger = logger;
}
public Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return OutlookClassicCom.RunStaAsync(
() => GetTeamsMeetingsForDay(day),
cancellationToken,
"Meeting Assistant Outlook Calendar Prompt Lookup");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogDebug(exception, "Outlook Classic calendar meetings were not available");
return Task.FromResult<IReadOnlyList<CalendarMeeting>>([]);
}
}
private IReadOnlyList<CalendarMeeting> GetTeamsMeetingsForDay(DateOnly day)
{
using var calendar = OutlookClassicCom.OpenDefaultCalendar(logger, "calendar prompt lookup");
if (calendar is null)
{
return [];
}
var dayStart = day.ToDateTime(TimeOnly.MinValue);
var dayEnd = dayStart.AddDays(1);
var meetings = new List<CalendarMeeting>();
foreach (var item in OutlookClassicCom.EnumerateItems(calendar.Items))
{
try
{
if (item is null || !OutlookClassicCom.IsAppointment(item))
{
continue;
}
var start = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "Start"));
if (start >= dayEnd)
{
break;
}
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
if (end <= dayStart ||
start < dayStart ||
OutlookClassicCom.IsCanceledAppointment(item) ||
!OutlookClassicAppointmentMetadata.IsTeamsAppointment(item))
{
continue;
}
var subject = OutlookClassicAppointmentMetadata.ReadSubject(item);
var localEnd = OutlookClassicCom.ToLocalOffset(end);
meetings.Add(new CalendarMeeting(
GetAppointmentId(item, start, end, subject),
subject,
OutlookClassicCom.ToLocalOffset(start),
localEnd,
OutlookClassicAppointmentMetadata.CreateMetadata(item, end),
IsCanceled: false));
}
finally
{
OutlookClassicCom.ReleaseComObject(item);
}
}
return meetings;
}
private static string GetAppointmentId(
object appointment,
DateTime start,
DateTime end,
string subject)
{
var id = Convert.ToString(OutlookClassicCom.TryGetProperty(appointment, "GlobalAppointmentID"));
if (string.IsNullOrWhiteSpace(id))
{
id = Convert.ToString(OutlookClassicCom.TryGetProperty(appointment, "EntryID"));
}
return string.IsNullOrWhiteSpace(id)
? $"{start:O}|{end:O}|{subject}"
: $"{id}|{start:O}";
}
}
#endif
@@ -0,0 +1,22 @@
namespace MeetingAssistant.Calendar;
internal static class TeamsMeetingMarkerDetector
{
public static bool IsTeamsAppointment(
string subject,
string location,
string body)
{
return ContainsTeamsMarker(subject) ||
ContainsTeamsMarker(location) ||
ContainsTeamsMarker(body);
}
public static bool ContainsTeamsMarker(string value)
{
return value.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Microsoft Teams", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join the meeting now", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join Microsoft Teams Meeting", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,207 @@
#if WINDOWS
using System.Collections.Concurrent;
using CommunityToolkit.WinUI.Notifications;
using MeetingAssistant.Notifications;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Calendar;
public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptService, IHostedService, IDisposable
{
private const string NotificationSource = "meeting-start-prompt";
private const string NotificationGroup = "meeting-start";
private readonly ConcurrentDictionary<string, Func<MeetingStartPromptResponse, CancellationToken, Task>> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<WindowsMeetingStartPromptService> logger;
private readonly object registrationGate = new();
private bool registered;
public WindowsMeetingStartPromptService(ILogger<WindowsMeetingStartPromptService> logger)
{
this.logger = logger;
}
public Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
{
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
return Task.CompletedTask;
}
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}
try
{
EnsureRegistered();
var promptId = Guid.NewGuid().ToString("N");
pendingPrompts[promptId] = handleResponseAsync;
BuildNotification(promptId, request).Show(toast =>
{
toast.Group = NotificationGroup;
toast.Tag = promptId;
toast.ExpirationTime = MeetingToastExpirationPolicy.StartRecordingPromptExpiration(DateTimeOffset.Now);
});
logger.LogInformation(
"Displayed native Windows meeting-start notification {PromptId} for {Subject}",
promptId,
request.Meeting.Subject);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to display native Windows meeting-start notification");
}
return Task.CompletedTask;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
{
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
return Task.CompletedTask;
}
EnsureRegistered();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public void Dispose()
{
if (!registered)
{
return;
}
try
{
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to detach native Windows meeting-start notification activation handler");
}
}
private void EnsureRegistered()
{
if (registered)
{
return;
}
lock (registrationGate)
{
if (registered)
{
return;
}
ToastNotificationManagerCompat.OnActivated += OnNotificationInvoked;
registered = true;
logger.LogInformation("Registered native Windows meeting-start notification activation handler");
}
}
private static ToastContentBuilder BuildNotification(
string promptId,
MeetingStartPromptRequest request)
{
var yesButton = new ToastButton()
.SetContent("Yes")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "record")
.SetBackgroundActivation();
var noButton = new ToastButton()
.SetContent("No")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "skip")
.SetBackgroundActivation();
return new ToastContentBuilder()
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.SetToastScenario(ToastScenario.Reminder)
.SetToastDuration(ToastDuration.Long)
.AddText("Record meeting?")
.AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.")
.AddButton(yesButton)
.AddButton(noButton);
}
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
{
var arguments = NotificationActivationArguments.Parse(args.Argument);
if (!TryGetArgument(arguments, "source", out var source) ||
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryGetArgument(arguments, "promptId", out var promptId))
{
logger.LogWarning("Ignoring native Windows meeting-start notification activation without prompt id");
return;
}
if (!pendingPrompts.TryRemove(promptId, out var handleResponseAsync))
{
logger.LogWarning(
"Ignoring native Windows meeting-start notification {PromptId} activation because no pending callback exists",
promptId);
return;
}
var response = TryGetArgument(arguments, "response", out var responseValue) &&
string.Equals(responseValue, "record", StringComparison.OrdinalIgnoreCase)
? MeetingStartPromptResponse.Record
: MeetingStartPromptResponse.Skip;
_ = Task.Run(async () =>
{
try
{
logger.LogInformation(
"Native Windows meeting-start notification {PromptId} activated with response {Response}",
promptId,
response);
await handleResponseAsync(response, CancellationToken.None);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to handle native Windows meeting-start notification response {Response}",
response);
}
});
}
private static bool TryGetArgument(
IReadOnlyDictionary<string, string> arguments,
string key,
out string value)
{
if (arguments.TryGetValue(key, out value!))
{
return true;
}
value = "";
return false;
}
}
#endif
@@ -0,0 +1,74 @@
The project is split in 3 important files:
PROJECT.md
DECISIONS.md
JOURNAL.md
The idea is a hierarchical summary:
Meeting Summary -> detailed (what was discussed)
then Journal Entry -> compressed (why should I care this event happened?)
then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.)
The journal should always be appended and read using tail or searched.
Example journal entry:
```markdown
## 2026-05-30
### Team Daily
New Blocker: HLS delayed
Impact: Release likely delayed, team implements Z-Levels in the meantime.
[[20260529-093030-6187486-summary|Summary]]
### Refinement
Calendar-Feature is refined, Golem feature needs more work.
[[20260529-103030-3465634-summary|Summary]]
```
PROJECT.md should use this structure:
```markdown
# Executive Summary
<Elevator Pitch>
## Business Goals
## Priorities and High Level Constraints
## Current Phase & Status
## Next Milestones
## Open Risks
## Important Stakeholders
## Key Documents
Links to: Architecture Overview, Technical Onboarding, etc.
[[JOURNAL.md]]
[[DECISIONS.md]]
```
DECISIONS.md logs decisions in the same compressed way that JOURNAL.md logs meetings, with links to ADRs and/or meeting summaries for details.
Only include important decisions: architecture decisions that are expensive to change, and project decisions about timeline, goals, governance, team, or process.
Do not log small implementation details such as how UI elements are aligned.
Example decision entries:
```markdown
## How do we handle project delays?
Communicate to <Stakeholder> first, let them decide further actions.
[[20260529-093030-6187486-summary|Source]]
## How will authentication be handled?
Company SSO with provided UI package.
[[ADR-0027-use-sso-and-ui-package-for-auth|Source]]
```
+2
View File
@@ -0,0 +1,2 @@
global using System.IO;
global using System.Net.Http;
@@ -6,6 +6,8 @@ public interface ILaunchProfileOptionsProvider
{
LaunchProfile GetRequiredProfile(string? name);
IReadOnlyList<LaunchProfile> GetProfiles();
IReadOnlyList<LaunchProfileHotkey> GetHotkeys();
}
@@ -77,6 +79,13 @@ public sealed class ConfigurationLaunchProfileOptionsProvider : ILaunchProfileOp
return hotkeys;
}
public IReadOnlyList<LaunchProfile> GetProfiles()
{
return GetProfileNames()
.Select(GetRequiredProfile)
.ToList();
}
private IEnumerable<LaunchProfileHotkey> CreateHotkeys(string profileName)
{
var profile = GetRequiredProfile(profileName);
@@ -0,0 +1,192 @@
using System.Collections.Concurrent;
using System.Text;
namespace MeetingAssistant.Logging;
public static class MeetingAssistantLogFiles
{
public const string CurrentLogFileName = "meeting-assistant.log";
public const int RetainedRotatedLogFiles = 4;
public static string DefaultLogDirectory =>
Path.Combine(Path.GetTempPath(), "MeetingAssistant", "Logs");
public static string CurrentLogPath(string? directory = null)
{
return Path.Combine(directory ?? DefaultLogDirectory, CurrentLogFileName);
}
public static string GetRotatedLogFileName(int index)
{
return $"{CurrentLogFileName}.{index}";
}
public static IReadOnlyList<string> EnumerateLogPaths(string? directory = null)
{
var root = directory ?? DefaultLogDirectory;
return Enumerable.Range(0, RetainedRotatedLogFiles + 1)
.Select(index => index == 0
? Path.Combine(root, CurrentLogFileName)
: Path.Combine(root, GetRotatedLogFileName(index)))
.Where(File.Exists)
.ToArray();
}
public static void Rotate(string? directory = null)
{
var root = directory ?? DefaultLogDirectory;
Directory.CreateDirectory(root);
try
{
var oldest = Path.Combine(root, GetRotatedLogFileName(RetainedRotatedLogFiles));
if (File.Exists(oldest))
{
File.Delete(oldest);
}
for (var index = RetainedRotatedLogFiles - 1; index >= 1; index--)
{
var source = Path.Combine(root, GetRotatedLogFileName(index));
if (!File.Exists(source))
{
continue;
}
var target = Path.Combine(root, GetRotatedLogFileName(index + 1));
File.Move(source, target, overwrite: true);
}
var current = Path.Combine(root, CurrentLogFileName);
if (File.Exists(current))
{
File.Move(current, Path.Combine(root, GetRotatedLogFileName(1)), overwrite: true);
}
}
catch (IOException)
{
// Another app instance or test host may still have the log open.
// Logging should keep working, so append to the existing current log.
}
}
}
public sealed class MeetingAssistantFileLoggerProvider : ILoggerProvider
{
private readonly ConcurrentDictionary<string, MeetingAssistantFileLogger> loggers = new(StringComparer.Ordinal);
private readonly object sync = new();
private readonly StreamWriter writer;
private bool disposed;
public MeetingAssistantFileLoggerProvider(string? directory = null)
{
var logDirectory = directory ?? MeetingAssistantLogFiles.DefaultLogDirectory;
MeetingAssistantLogFiles.Rotate(logDirectory);
var stream = new FileStream(
MeetingAssistantLogFiles.CurrentLogPath(logDirectory),
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite);
writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true
};
}
public ILogger CreateLogger(string categoryName)
{
return loggers.GetOrAdd(categoryName, name => new MeetingAssistantFileLogger(name, Write));
}
public void Dispose()
{
lock (sync)
{
if (disposed)
{
return;
}
disposed = true;
writer.Dispose();
}
}
private void Write(
string categoryName,
LogLevel logLevel,
EventId eventId,
string message,
Exception? exception)
{
if (disposed)
{
return;
}
lock (sync)
{
if (disposed)
{
return;
}
writer.Write(DateTimeOffset.Now.ToString("O"));
writer.Write(' ');
writer.Write(logLevel);
writer.Write(" [");
writer.Write(categoryName);
writer.Write(']');
if (eventId.Id != 0 || !string.IsNullOrWhiteSpace(eventId.Name))
{
writer.Write(" (");
writer.Write(eventId.Id);
if (!string.IsNullOrWhiteSpace(eventId.Name))
{
writer.Write(':');
writer.Write(eventId.Name);
}
writer.Write(')');
}
writer.Write(' ');
writer.WriteLine(message);
if (exception is not null)
{
writer.WriteLine(exception);
}
}
}
private sealed class MeetingAssistantFileLogger(
string categoryName,
Action<string, LogLevel, EventId, string, Exception?> write) : ILogger
{
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return logLevel != LogLevel.None;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
write(categoryName, logLevel, eventId, formatter(state, exception), exception);
}
}
}
+49 -10
View File
@@ -1,10 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-windows</TargetFrameworks>
<TargetFrameworks>net10.0;net10.0-windows10.0.19041.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PreserveCompilationContext>true</PreserveCompilationContext>
<MicrosoftSpeechVersion>1.51.1</MicrosoftSpeechVersion>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<OutputType>WinExe</OutputType>
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
<PlatformTarget>x64</PlatformTarget>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
@@ -12,24 +20,55 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.7.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.13.0" />
<PackageReference Include="DiffPlex" Version="1.9.0" />
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="$(MicrosoftSpeechVersion)" />
<PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="NCalcSync" Version="5.12.0" />
<PackageReference Include="NCalcSync" Version="6.4.0" />
<PackageReference Include="RazorLight" Version="2.3.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageReference Include="YamlDotNet" Version="17.1.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
<PackageReference Include="Whisper.net" Version="1.9.1" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.1" />
<PackageReference Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0-windows'">
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="%(Filename)%(Extension)" />
<None Update="Assets\meeting-assistant.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\docs\meeting-assistant-configuration.md" Link="docs\meeting-assistant-configuration.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="Content\Project-AGENTS.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\openspec\specs\**\*.md" Link="openspec\specs\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\aec_v1.fpie" Link="runtimes\win-x64\native\MASmodels\aec_v1.fpie" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\pns_avg4.fpie" Link="runtimes\win-x64\native\MASmodels\pns_avg4.fpie" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<MeetingAssistantRootConfig Include="$(MSBuildProjectDirectory)\appsettings*.json" />
</ItemGroup>
<Target Name="CopyMeetingAssistantConfigToPublishRoot" AfterTargets="Publish">
<Copy SourceFiles="@(MeetingAssistantRootConfig)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
</Target>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
<Compile Remove="Recording\NaudioCaptureSource.cs" />
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
<Compile Remove="Workflow\WpfWorkflowRulesEditorWindowService.Windows.cs" />
</ItemGroup>
</Project>
+136 -5
View File
@@ -18,10 +18,14 @@ public sealed class MeetingAssistantOptions
public AutomationOptions Automation { get; set; } = new();
public CalendarRecordingPromptOptions CalendarRecordingPrompts { get; set; } = new();
public ScreenshotOptions Screenshots { get; set; } = new();
public AgentOptions Agent { get; set; } = new();
public WorkflowRulesEditorOptions WorkflowRulesEditor { get; set; } = new();
public ApiOptions Api { get; set; } = new();
}
@@ -30,6 +34,15 @@ public sealed class AutomationOptions
public string? RulesPath { get; set; } = "meeting-rules.local.yaml";
}
public sealed class CalendarRecordingPromptOptions
{
public bool Enabled { get; set; } = true;
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30);
public TimeSpan PromptWindow { get; set; } = TimeSpan.FromMinutes(5);
}
public sealed class ScreenshotOptions
{
public string Hotkey { get; set; } = "Ctrl+Alt+S";
@@ -45,6 +58,8 @@ public sealed class ScreenshotOcrOptions
This screenshot was captured during a meeting. Extract information that is useful for meeting notes.
Identify who appears to be talking, who appears to be presenting, and what is being presented when visible.
If people or participant tiles are visible, state whether it is clear from the screenshot exactly who is in the meeting or whether the visible people are only a partial participant result.
Include any attendee names that are clearly visible or strongly deduced from the screenshot in the meeting-assistant metadata. The attendee list may be partial.
If the screenshot contains slides, capture the visible text in clean markdown.
If the screenshot contains a diagram, convert it to Mermaid when possible and preserve labels accurately.
If it contains another kind of shared screen or scene, describe the scene and the relevant visible information.
@@ -53,9 +68,9 @@ public sealed class ScreenshotOcrOptions
Do not return crop coordinates for a person, webcam tile, whole app chrome, or content you cannot isolate confidently.
End your response with exactly one fenced json block containing meeting-assistant metadata, for example:
```json
{ "crop": { "x": 0, "y": 0, "width": 1920, "height": 1080 } }
{ "crop": { "x": 0, "y": 0, "width": 1920, "height": 1080 }, "attendees": ["Ada Lovelace"] }
```
Use `{ "crop": null }` when no confident presentation/shared-screen crop exists.
Use `{ "crop": null, "attendees": [] }` when no confident presentation/shared-screen crop exists and no attendees are visible or deducible.
Do not invent information that is not visible in the screenshot.
""";
@@ -83,7 +98,7 @@ public sealed class HotkeyOptions
{
public string Toggle { get; set; } = "Ctrl+Alt+M";
public string Abort { get; set; } = "Ctrl+Alt+D";
public string Abort { get; set; } = "Ctrl+Alt+Z";
}
public sealed class VaultOptions
@@ -111,8 +126,16 @@ public sealed class RecordingOptions
public int Channels { get; set; } = 1;
public string? MicrophoneDeviceId { get; set; }
public double MicrophoneMixGain { get; set; } = 1;
public double SystemAudioMixGain { get; set; } = 1;
public TimeSpan StopProcessingTimeout { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan MinimumCompletedMeetingDuration { get; set; } = TimeSpan.Zero;
public TimeSpan MetadataLookupTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan BackgroundMetadataLookupTimeout { get; set; } = TimeSpan.FromMinutes(1);
@@ -121,7 +144,30 @@ public sealed class RecordingOptions
public TimeSpan OpenMeetingNoteDelay { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan DictationWordsReadTimeout { get; set; } = TimeSpan.FromSeconds(2);
public string TemporaryRecordingsFolder { get; set; } = @"%LOCALAPPDATA%\MeetingAssistant\Recordings";
public RecordingInactivitySafeguardOptions InactivitySafeguard { get; set; } = new();
}
public sealed class RecordingInactivitySafeguardOptions
{
public bool Enabled { get; set; } = true;
public TimeSpan FirstPromptAfter { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan[] ReminderPromptAfter { get; set; } =
[
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(10)
];
public TimeSpan AutoStopAfter { get; set; } = TimeSpan.FromMinutes(30);
public TimeSpan InferredEndPadding { get; set; } = TimeSpan.FromMinutes(1);
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(15);
}
public sealed class WhisperLocalOptions
@@ -178,7 +224,11 @@ 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 int ReconnectAttemptsBeforeDisconnectedMarker { get; set; } = 5;
public TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
public bool DiarizeIntermediateResults { get; set; } = true;
@@ -290,6 +340,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);
@@ -299,6 +353,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
@@ -309,7 +381,9 @@ public sealed class AgentOptions
public string KeyEnv { get; set; } = "LITELLM_API_KEY";
public string Model { get; set; } = "copilot-gpt-5.5";
public string Model { get; set; } = "chatgpt/gpt-5.5";
public bool UseStreaming { get; set; } = true;
public bool EnableThinking { get; set; } = true;
@@ -332,6 +406,63 @@ public sealed class AgentOptions
public string? InitialPrompt { get; set; }
}
public sealed class WorkflowRulesEditorOptions
{
public string? Endpoint { get; set; }
public string? Key { get; set; }
public string? KeyEnv { get; set; }
public string? Model { get; set; }
public bool? UseStreaming { get; set; }
public bool? EnableThinking { get; set; }
public ReasoningEffortOption? ReasoningEffort { get; set; }
public int? ReconnectionAttempts { get; set; }
public TimeSpan? ReconnectionDelay { get; set; }
public int? ContextWindowTokens { get; set; }
public int? MaxOutputTokens { get; set; }
public bool? EnableCompaction { get; set; }
public double? CompactionRemainingRatio { get; set; }
public string? ResponsesCompactPath { get; set; }
public string? InitialPrompt { get; set; }
public AgentOptions ToEffectiveAgentOptions(AgentOptions defaults)
{
return new AgentOptions
{
Endpoint = string.IsNullOrWhiteSpace(Endpoint) ? defaults.Endpoint : Endpoint,
Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key,
KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!,
Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!,
UseStreaming = UseStreaming ?? defaults.UseStreaming,
EnableThinking = EnableThinking ?? defaults.EnableThinking,
ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort,
ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts,
ReconnectionDelay = ReconnectionDelay ?? defaults.ReconnectionDelay,
ContextWindowTokens = ContextWindowTokens ?? defaults.ContextWindowTokens,
MaxOutputTokens = MaxOutputTokens ?? defaults.MaxOutputTokens,
EnableCompaction = EnableCompaction ?? defaults.EnableCompaction,
CompactionRemainingRatio = CompactionRemainingRatio ?? defaults.CompactionRemainingRatio,
ResponsesCompactPath = string.IsNullOrWhiteSpace(ResponsesCompactPath)
? defaults.ResponsesCompactPath
: ResponsesCompactPath!,
InitialPrompt = string.IsNullOrWhiteSpace(InitialPrompt) ? null : InitialPrompt
};
}
}
public sealed class ApiOptions
{
public string PublicBaseUrl { get; set; } = "http://localhost:5090";
@@ -0,0 +1,24 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.MeetingNotes;
internal static class FrontmatterYamlDeserializer
{
public static IDeserializer Create()
{
return new DeserializerBuilder()
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
public static IDeserializer CreateWithUnderscoredNaming()
{
return new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
}
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Options;
using System.Globalization;
using System.Text;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
@@ -16,9 +18,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
{
this.options = options.Value;
this.logger = logger;
yamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
yamlDeserializer = FrontmatterYamlDeserializer.Create();
}
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
@@ -35,7 +35,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-{Slugify(note.Frontmatter.Title)}.md")
? Path.Combine(folder, MeetingArtifactFileNames.Create(note.Frontmatter.StartTime ?? DateTimeOffset.Now, MeetingArtifactFileNames.Note))
: note.Path;
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
var content = Render(frontmatter, note.UserNotes);
@@ -108,16 +108,6 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
return string.Join(Environment.NewLine, lines);
}
private static string Slugify(string value)
{
var slug = new string(value
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray());
slug = string.Join("-", slug.Split('-', StringSplitOptions.RemoveEmptyEntries));
return string.IsNullOrWhiteSpace(slug) ? "meeting" : slug;
}
private static string EscapeScalar(string value)
{
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
@@ -125,7 +115,24 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
private static string EscapeQuoted(string value)
{
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
var escaped = new StringBuilder(value.Length + 2);
escaped.Append('"');
foreach (var character in value)
{
escaped.Append(character switch
{
'\\' => "\\\\",
'"' => "\\\"",
'\r' => "\\r",
'\n' => "\\n",
'\t' => "\\t",
< ' ' => "\\x" + ((int)character).ToString("X2", CultureInfo.InvariantCulture),
_ => character.ToString()
});
}
escaped.Append('"');
return escaped.ToString();
}
private static string EscapeNullableDateTime(DateTimeOffset? value)
@@ -147,7 +154,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
return "\"\"";
}
if (value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal))
if (RequiresQuotedScalar(value))
{
return EscapeQuoted(value);
}
@@ -155,6 +162,49 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
return value;
}
private static bool RequiresQuotedScalar(string value)
{
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1]))
{
return true;
}
if (value.Contains(':', StringComparison.Ordinal) ||
value.Contains('\'', StringComparison.Ordinal) ||
value.Contains(" #", StringComparison.Ordinal) ||
value.Any(char.IsControl))
{
return true;
}
if (IsYamlIndicator(value[0]))
{
return true;
}
if (IsYamlCoreSchemaKeyword(value))
{
return true;
}
return DateTimeOffset.TryParse(value, out _) ||
double.TryParse(value, CultureInfo.InvariantCulture, out _);
}
private static bool IsYamlIndicator(char character)
{
return character is '-' or '?' or ':' or ',' or '[' or ']' or '{' or '}' or
'#' or '&' or '*' or '!' or '|' or '>' or '\'' or '"' or '%' or '@' or '`';
}
private static bool IsYamlCoreSchemaKeyword(string value)
{
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "null", StringComparison.OrdinalIgnoreCase) ||
value == "~";
}
private sealed class MeetingNoteYaml
{
[YamlMember(Alias = "title")]
@@ -0,0 +1,14 @@
namespace MeetingAssistant.MeetingNotes;
public static class MeetingArtifactFileNames
{
public const string Note = "note";
public const string Context = "context";
public const string Transcript = "transcript";
public const string Summary = "summary";
public static string Create(DateTimeOffset startedAt, string artifactType)
{
return $"{startedAt:yyyyMMdd-HHmm}-{artifactType}.md";
}
}
@@ -6,6 +6,8 @@ public sealed class MeetingArtifactFrontmatter
{
public string Title { get; set; } = "";
public string? OneLiner { get; set; }
public DateTimeOffset? StartTime { get; set; }
public DateTimeOffset? EndTime { get; set; }
@@ -38,6 +40,7 @@ public static class MeetingArtifactFrontmatterRenderer
var builder = new StringBuilder();
builder.AppendLine("---");
AppendScalar(builder, "title", frontmatter.Title);
AppendScalarIfNotEmpty(builder, "oneliner", frontmatter.OneLiner);
AppendDateTime(builder, "start_time", frontmatter.StartTime);
AppendDateTime(builder, "end_time", frontmatter.EndTime);
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
@@ -124,6 +127,16 @@ public static class MeetingArtifactFrontmatterRenderer
builder.AppendLine(string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value));
}
private static void AppendScalarIfNotEmpty(StringBuilder builder, string key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
AppendScalar(builder, key, value);
}
private static void AppendQuoted(StringBuilder builder, string key, string value)
{
builder.Append(key);
@@ -8,4 +8,13 @@ internal static class MeetingAttendeeNames
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
}
public static List<string> NormalizeDistinct(IEnumerable<string> attendees)
{
return attendees
.Select(attendee => attendee.Trim())
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
@@ -14,4 +14,24 @@ public static class MeetingNoteActionLinks
var url = $"{baseUrl}/meetings/summary/retry?summaryPath={Uri.EscapeDataString(summaryFileName)}";
return $"[{label}]({url})";
}
public static string CreateScreenshotOcrRetryLink(
string publicBaseUrl,
MeetingSessionArtifacts artifacts,
string screenshotPath,
string screenshotId,
string label = "Retry screenshot OCR")
{
var baseUrl = string.IsNullOrWhiteSpace(publicBaseUrl)
? "http://localhost:5090"
: publicBaseUrl.TrimEnd('/');
var url = $"{baseUrl}/meetings/screenshot-ocr/retry" +
$"?meetingNotePath={Uri.EscapeDataString(artifacts.MeetingNotePath)}" +
$"&transcriptPath={Uri.EscapeDataString(artifacts.TranscriptPath)}" +
$"&assistantContextPath={Uri.EscapeDataString(artifacts.AssistantContextPath)}" +
$"&summaryPath={Uri.EscapeDataString(artifacts.SummaryPath)}" +
$"&screenshotPath={Uri.EscapeDataString(screenshotPath)}" +
$"&screenshotId={Uri.EscapeDataString(screenshotId)}";
return $"[{label}]({url})";
}
}
@@ -0,0 +1,176 @@
#if WINDOWS
using MeetingAssistant.Calendar;
namespace MeetingAssistant.MeetingNotes;
internal static class OutlookClassicAppointmentMetadata
{
public static MeetingMetadata CreateMetadata(object appointment, DateTime end)
{
var title = ReadSubject(appointment);
return new MeetingMetadata(
title,
ReadAttendees(appointment),
ExtractAgenda(Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? ""),
OutlookClassicCom.ToLocalOffset(end));
}
public static string ReadSubject(object appointment)
{
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject"))?.Trim();
return string.IsNullOrWhiteSpace(subject) ? "Teams meeting" : subject;
}
public static bool IsTeamsAppointment(object appointment)
{
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
}
public static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "";
}
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
var agendaLines = new List<string>();
foreach (var line in lines)
{
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
{
break;
}
agendaLines.Add(line);
}
return string.Join(Environment.NewLine, agendaLines).Trim();
}
public static IReadOnlyList<string> ReadAttendees(object appointment)
{
var attendees = new List<string>();
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
if (!string.IsNullOrWhiteSpace(organizer))
{
attendees.Add(organizer.Trim());
}
object? recipients = null;
try
{
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
for (var index = 1; index <= count; index++)
{
object? recipient = null;
try
{
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
var formatted = FormatRecipient(recipient!);
if (!string.IsNullOrWhiteSpace(formatted))
{
attendees.Add(formatted);
}
}
finally
{
OutlookClassicCom.ReleaseComObject(recipient);
}
}
}
finally
{
OutlookClassicCom.ReleaseComObject(recipients);
}
return NormalizeAttendees(attendees);
}
public static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
.ThenBy(attendee => attendee.Length)
.First())
.ToList();
}
private static bool IsTeamsSeparator(string line)
{
var trimmed = line.Trim();
return trimmed.Length >= 8 &&
trimmed.All(character => character is '_' or '-' or '*' or ' ');
}
private static bool IsTeamsJoinLine(string line)
{
return TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
}
private static string NormalizeAttendee(string attendee)
{
var normalized = attendee.Trim();
var mailtoPrefix = "mailto:";
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
while (normalized.Contains(" ", StringComparison.Ordinal))
{
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
}
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
var emailEnd = normalized.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
var name = normalized[..emailStart].Trim();
var email = normalized[(emailStart + 1)..emailEnd].Trim();
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
{
return email;
}
return $"{name} <{email}>";
}
return normalized;
}
private static string GetAttendeeDeduplicationKey(string attendee)
{
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
var emailEnd = attendee.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
return attendee[(emailStart + 1)..emailEnd].Trim();
}
return attendee.Trim();
}
private static string FormatRecipient(object recipient)
{
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(name))
{
return email;
}
if (string.IsNullOrWhiteSpace(email) ||
email.Contains('/'))
{
return name;
}
return $"{name} <{email}>";
}
}
#endif
@@ -0,0 +1,248 @@
#if WINDOWS
using System.Collections;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace MeetingAssistant.MeetingNotes;
internal static class OutlookClassicCom
{
private const int OutlookCalendarFolder = 9;
public static OutlookCalendarSession? OpenDefaultCalendar(
ILogger logger,
string logContext)
{
EnsureOutlookRunning(logger, logContext);
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
try
{
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
if (applicationType is null)
{
return null;
}
application = Activator.CreateInstance(applicationType);
if (application is null)
{
return null;
}
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
Invoke(items!, "Sort", "[Start]");
SetProperty(items!, "IncludeRecurrences", true);
return new OutlookCalendarSession(application, session, calendar, items!);
}
catch
{
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
ReleaseComObject(application);
throw;
}
}
public static Task<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken,
string threadName)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.TrySetResult(action());
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
})
{
IsBackground = true,
Name = threadName
};
thread.SetApartmentState(ApartmentState.STA);
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
_ = completion.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
thread.Start();
return completion.Task;
}
public static IEnumerable<object?> EnumerateItems(object items)
{
if (items is IEnumerable enumerable)
{
foreach (var item in enumerable)
{
yield return item;
}
}
}
public static bool IsAppointment(object item)
{
try
{
return Convert.ToInt32(GetProperty(item, "Class")) == 26;
}
catch
{
return false;
}
}
public static bool IsCanceledAppointment(object appointment)
{
var meetingStatus = TryGetProperty(appointment, "MeetingStatus");
if (meetingStatus is not null)
{
try
{
var status = Convert.ToInt32(meetingStatus);
if (status is 5 or 7)
{
return true;
}
}
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
{
// Fall back to subject prefixes when Outlook exposes a non-numeric status value.
}
}
var subject = Convert.ToString(TryGetProperty(appointment, "Subject")) ?? "";
return SubjectIndicatesCancellation(subject);
}
internal static bool SubjectIndicatesCancellation(string subject)
{
var normalized = subject.TrimStart();
return normalized.StartsWith("Canceled:", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Cancelled:", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith("Abgesagt:", StringComparison.OrdinalIgnoreCase);
}
public static DateTimeOffset ToLocalOffset(DateTime value)
{
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
public static object? TryGetProperty(object target, string name)
{
try
{
return GetProperty(target, name);
}
catch
{
return null;
}
}
public static object? GetProperty(object target, string name)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.GetProperty,
null,
target,
null);
}
public static void SetProperty(object target, string name, object value)
{
target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.SetProperty,
null,
target,
[value]);
}
public static object? Invoke(object target, string name, params object[] arguments)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
arguments);
}
public static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
private static void EnsureOutlookRunning(ILogger logger, string logContext)
{
if (Process.GetProcessesByName("OUTLOOK").Length > 0)
{
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = "outlook.exe",
UseShellExecute = true
});
Thread.Sleep(TimeSpan.FromSeconds(5));
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not start Outlook Classic before {OutlookContext}", logContext);
}
}
}
internal sealed class OutlookCalendarSession : IDisposable
{
private readonly object? application;
private readonly object? session;
private readonly object? calendar;
public OutlookCalendarSession(
object? application,
object? session,
object? calendar,
object items)
{
this.application = application;
this.session = session;
this.calendar = calendar;
Items = items;
}
public object Items { get; }
public void Dispose()
{
OutlookClassicCom.ReleaseComObject(Items);
OutlookClassicCom.ReleaseComObject(calendar);
OutlookClassicCom.ReleaseComObject(session);
OutlookClassicCom.ReleaseComObject(application);
}
}
#endif
@@ -1,12 +1,10 @@
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Collections;
using MeetingAssistant.Calendar;
namespace MeetingAssistant.MeetingNotes;
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
{
private const int OutlookCalendarFolder = 9;
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> logger)
@@ -21,7 +19,10 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
cancellationToken.ThrowIfCancellationRequested();
try
{
return RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
return OutlookClassicCom.RunStaAsync(
() => GetCurrentMeeting(startedAt),
cancellationToken,
"Meeting Assistant Outlook COM Lookup");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -38,7 +39,10 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
var stopwatch = Stopwatch.StartNew();
try
{
var lookup = RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
var lookup = OutlookClassicCom.RunStaAsync(
() => GetCurrentMeeting(startedAt),
cancellationToken,
"Meeting Assistant Outlook COM Lookup");
var metadata = timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
@@ -84,77 +88,60 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
{
EnsureOutlookRunning();
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
try
using var calendar = OutlookClassicCom.OpenDefaultCalendar(logger, "metadata lookup");
if (calendar is null)
{
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
if (applicationType is null)
return null;
}
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddHours(-4);
var windowEnd = startedLocal.AddMinutes(5);
var candidates = new List<OutlookAppointmentCandidate>();
foreach (var item in OutlookClassicCom.EnumerateItems(calendar.Items))
{
if (item is null)
{
return null;
continue;
}
application = Activator.CreateInstance(applicationType);
if (application is null)
var keepAppointment = false;
try
{
return null;
}
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
Invoke(items!, "Sort", "[Start]");
SetProperty(items!, "IncludeRecurrences", true);
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddHours(-4);
var windowEnd = startedLocal.AddMinutes(5);
var candidates = new List<OutlookAppointmentCandidate>();
foreach (var item in EnumerateItems(items!))
{
if (item is null)
if (!OutlookClassicCom.IsAppointment(item))
{
continue;
}
var keepAppointment = false;
try
var start = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "Start"));
if (start > windowEnd)
{
if (!IsAppointment(item))
{
continue;
}
var start = Convert.ToDateTime(GetProperty(item, "Start"));
if (start > windowEnd)
{
break;
}
var end = Convert.ToDateTime(GetProperty(item, "End"));
if (end < windowStart || !IsTeamsAppointment(item))
{
continue;
}
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
keepAppointment = true;
break;
}
finally
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
if (end < windowStart ||
OutlookClassicCom.IsCanceledAppointment(item) ||
!OutlookClassicAppointmentMetadata.IsTeamsAppointment(item))
{
if (!keepAppointment)
{
ReleaseComObject(item);
}
continue;
}
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
keepAppointment = true;
}
finally
{
if (!keepAppointment)
{
OutlookClassicCom.ReleaseComObject(item);
}
}
}
try
{
var selected = OutlookMeetingCandidateSelector.Select(
candidates,
startedLocal,
@@ -162,325 +149,20 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
candidate => candidate.End);
if (selected is null)
{
foreach (var candidate in candidates)
{
ReleaseComObject(candidate.Appointment);
}
return null;
}
try
{
var appointment = selected.Appointment;
var title = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
var attendees = ReadAttendees(appointment);
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
return new MeetingMetadata(
title.Trim(),
attendees,
ExtractAgenda(body),
ToLocalOffset(selected.End));
}
finally
{
foreach (var candidate in candidates)
{
ReleaseComObject(candidate.Appointment);
}
}
return OutlookClassicAppointmentMetadata.CreateMetadata(selected.Appointment, selected.End);
}
finally
{
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
ReleaseComObject(application);
}
}
private static IEnumerable<object?> EnumerateItems(object items)
{
if (items is IEnumerable enumerable)
{
foreach (var item in enumerable)
foreach (var candidate in candidates)
{
yield return item;
OutlookClassicCom.ReleaseComObject(candidate.Appointment);
}
}
}
private static bool IsAppointment(object item)
{
try
{
return Convert.ToInt32(GetProperty(item, "Class")) == 26;
}
catch
{
return false;
}
}
private void EnsureOutlookRunning()
{
if (Process.GetProcessesByName("OUTLOOK").Length > 0)
{
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = "outlook.exe",
UseShellExecute = true
});
Thread.Sleep(TimeSpan.FromSeconds(5));
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not start Outlook Classic before metadata lookup");
}
}
private static Task<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.TrySetResult(action());
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
})
{
IsBackground = true,
Name = "Meeting Assistant Outlook COM Lookup"
};
thread.SetApartmentState(ApartmentState.STA);
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
_ = completion.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
thread.Start();
return completion.Task;
}
internal static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "";
}
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
var agendaLines = new List<string>();
foreach (var line in lines)
{
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
{
break;
}
agendaLines.Add(line);
}
return string.Join(Environment.NewLine, agendaLines).Trim();
}
private static bool IsTeamsAppointment(object appointment)
{
var subject = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
var location = Convert.ToString(GetProperty(appointment, "Location")) ?? "";
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
return ContainsTeamsMarker(subject) ||
ContainsTeamsMarker(location) ||
ContainsTeamsMarker(body);
}
private static bool ContainsTeamsMarker(string value)
{
return value.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Microsoft Teams", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join the meeting now", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join Microsoft Teams Meeting", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTeamsSeparator(string line)
{
var trimmed = line.Trim();
return trimmed.Length >= 8 &&
trimmed.All(character => character is '_' or '-' or '*' or ' ');
}
private static bool IsTeamsJoinLine(string line)
{
return ContainsTeamsMarker(line);
}
private static IReadOnlyList<string> ReadAttendees(object appointment)
{
var attendees = new List<string>();
var organizer = Convert.ToString(GetProperty(appointment, "Organizer"));
if (!string.IsNullOrWhiteSpace(organizer))
{
attendees.Add(organizer.Trim());
}
object? recipients = null;
try
{
recipients = GetProperty(appointment, "Recipients");
var count = Convert.ToInt32(GetProperty(recipients!, "Count"));
for (var index = 1; index <= count; index++)
{
object? recipient = null;
try
{
recipient = Invoke(recipients!, "Item", index);
var formatted = FormatRecipient(recipient!);
if (!string.IsNullOrWhiteSpace(formatted))
{
attendees.Add(formatted);
}
}
finally
{
ReleaseComObject(recipient);
}
}
}
finally
{
ReleaseComObject(recipients);
}
return NormalizeAttendees(attendees);
}
private static DateTimeOffset ToLocalOffset(DateTime value)
{
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
.ThenBy(attendee => attendee.Length)
.First())
.ToList();
}
private static string NormalizeAttendee(string attendee)
{
var normalized = attendee.Trim();
var mailtoPrefix = "mailto:";
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
while (normalized.Contains(" ", StringComparison.Ordinal))
{
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
}
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
var emailEnd = normalized.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
var name = normalized[..emailStart].Trim();
var email = normalized[(emailStart + 1)..emailEnd].Trim();
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
{
return email;
}
return $"{name} <{email}>";
}
return normalized;
}
private static string GetAttendeeDeduplicationKey(string attendee)
{
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
var emailEnd = attendee.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
return attendee[(emailStart + 1)..emailEnd].Trim();
}
return attendee.Trim();
}
private static string FormatRecipient(object recipient)
{
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
var email = Convert.ToString(GetProperty(recipient, "Address"))?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(name))
{
return email;
}
if (string.IsNullOrWhiteSpace(email) ||
email.Contains('/'))
{
return name;
}
return $"{name} <{email}>";
}
private static object? GetProperty(object target, string name)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.GetProperty,
null,
target,
null);
}
private static void SetProperty(object target, string name, object value)
{
target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.SetProperty,
null,
target,
[value]);
}
private static object? Invoke(object target, string name, params object[] arguments)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
arguments);
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
private sealed record OutlookAppointmentCandidate(
object Appointment,
DateTime Start,
@@ -0,0 +1,38 @@
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace MeetingAssistant.MeetingNotes;
internal sealed class ScalarStringListYamlTypeConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
{
return type == typeof(List<string>);
}
public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
{
if (parser.TryConsume<Scalar>(out var scalar))
{
return string.IsNullOrWhiteSpace(scalar.Value)
? []
: new List<string> { scalar.Value };
}
var values = new List<string>();
parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
var item = parser.Consume<Scalar>();
values.Add(item.Value);
}
return values;
}
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
{
serializer(value, typeof(IEnumerable<string>));
}
}
@@ -0,0 +1,14 @@
namespace MeetingAssistant.Notifications;
internal static class MeetingToastExpirationPolicy
{
public static DateTimeOffset StopReminderExpiration(DateTimeOffset now)
{
return now.AddMinutes(1);
}
public static DateTimeOffset StartRecordingPromptExpiration(DateTimeOffset now)
{
return now.AddMinutes(5);
}
}
+211 -22
View File
@@ -1,25 +1,33 @@
using MeetingAssistant;
using MeetingAssistant.Calendar;
using MeetingAssistant.Hotkeys;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Logging;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Speakers;
using MeetingAssistant.Summary;
using MeetingAssistant.Taskbar;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddProvider(new MeetingAssistantFileLoggerProvider());
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
#if WINDOWS
builder.Services.AddSingleton<MicrophoneDeviceSelection>();
builder.Services.AddSingleton<IMicrophoneDeviceProvider, WindowsMicrophoneDeviceProvider>();
builder.Services.AddSingleton<MicrophoneAudioSource>();
builder.Services.AddSingleton<SystemAudioSource>();
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
builder.Services.AddSingleton<IMeetingAudioSource>(services => new CompositeMeetingAudioSource(
services.GetRequiredService<MicrophoneAudioSource>(),
services.GetRequiredService<SystemAudioSource>(),
services.GetRequiredService<IAcousticEchoCancellerFactory>(),
services.GetRequiredService<ILogger<CompositeMeetingAudioSource>>()));
builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMetadataProvider>();
#else
@@ -31,15 +39,43 @@ builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
builder.Services.AddSingleton<IMeetingNoteOpener, ObsidianMeetingNoteOpener>();
builder.Services.AddSingleton<IMeetingArtifactStore, MarkdownMeetingArtifactStore>();
builder.Services.AddSingleton<IMeetingRunArtifactCleaner, MeetingRunArtifactCleaner>();
builder.Services.AddSingleton<IOfflineTranscriptionBacklog, FileOfflineTranscriptionBacklog>();
builder.Services.AddSingleton<IRecordedAudioStore, TemporaryRecordedAudioStore>();
builder.Services.AddSingleton<IDictationWordStore, MarkdownDictationWordStore>();
builder.Services.AddSingleton<IMeetingInactivityClock, SystemMeetingInactivityClock>();
#if WINDOWS
builder.Services.AddSingleton<WindowsMeetingInactivityPromptService>();
builder.Services.AddSingleton<IMeetingInactivityPromptService>(services =>
services.GetRequiredService<WindowsMeetingInactivityPromptService>());
builder.Services.AddHostedService(services =>
services.GetRequiredService<WindowsMeetingInactivityPromptService>());
#else
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
#endif
builder.Services.AddSingleton<IRecordingDictationWordProvider, RecordingDictationWordProvider>();
builder.Services.AddSingleton<ICalendarPromptClock, SystemCalendarPromptClock>();
builder.Services.AddSingleton<IMeetingPromptRecordingController, MeetingPromptRecordingController>();
#if WINDOWS
builder.Services.AddSingleton<ICalendarMeetingProvider, OutlookClassicCalendarMeetingProvider>();
builder.Services.AddSingleton<WindowsMeetingStartPromptService>();
builder.Services.AddSingleton<IMeetingStartPromptService>(services =>
services.GetRequiredService<WindowsMeetingStartPromptService>());
builder.Services.AddHostedService(services =>
services.GetRequiredService<WindowsMeetingStartPromptService>());
#else
builder.Services.AddSingleton<ICalendarMeetingProvider, NoopCalendarMeetingProvider>();
builder.Services.AddSingleton<IMeetingStartPromptService, NoopMeetingStartPromptService>();
#endif
#if WINDOWS
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
#else
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, UnavailableActiveWindowScreenshotCapture>();
#endif
builder.Services.AddSingleton<IScreenshotOcrClient, LiteLlmScreenshotOcrClient>();
builder.Services.AddSingleton<IMeetingScreenshotService, MeetingScreenshotService>();
builder.Services.AddSingleton<MeetingScreenshotService>();
builder.Services.AddSingleton<IMeetingScreenshotService>(sp => sp.GetRequiredService<MeetingScreenshotService>());
builder.Services.AddSingleton<IScreenshotOcrRetryRunner>(sp => sp.GetRequiredService<MeetingScreenshotService>());
builder.Services.AddSingleton<IMeetingNoteImageOcrService>(sp => sp.GetRequiredService<MeetingScreenshotService>());
builder.Services.AddDbContextFactory<SpeakerIdentityDbContext>((services, dbOptions) =>
{
var appOptions = services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value;
@@ -49,6 +85,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>();
@@ -57,8 +94,20 @@ builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArt
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
builder.Services.AddSingleton<IMeetingSummaryRetryRunner, MeetingSummaryRetryRunner>();
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
builder.Services.AddSingleton<IWorkflowRulesEditorInstructionBuilder, WorkflowRulesEditorInstructionBuilder>();
builder.Services.AddSingleton<IWorkflowRulesEditorSamplePlaybackQueue, WorkflowRulesEditorSamplePlaybackQueue>();
builder.Services.AddSingleton<IWorkflowRulesEditorChatPipeline, WorkflowRulesEditorChatPipeline>();
builder.Services.AddTransient<WorkflowRulesEditorChatViewModel>();
#if WINDOWS
builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolver.FromOptions(
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
#else
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
#endif
builder.Services.AddSingleton<AsrDiagnosticService>();
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
@@ -73,12 +122,17 @@ builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, AzureSpeechReco
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, FunAsrSpeechRecognitionPipelineBuilder>();
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, WhisperLocalSpeechRecognitionPipelineBuilder>();
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
builder.Services.AddSingleton<OfflineTranscriptionBacklogProcessor>();
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
builder.Services.AddHostedService<CalendarRecordingPromptScheduler>();
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
builder.Services.AddHostedService<OfflineTranscriptionBacklogHostedService>();
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
builder.Services.AddHostedService<PyannoteDiarizationWarmupHostedService>();
#if WINDOWS
builder.Services.AddHostedService<GlobalHotkeyService>();
builder.Services.AddHostedService<UnoTaskbarIconService>();
#endif
var app = builder.Build();
@@ -210,6 +264,18 @@ static async Task<IResult> MergeSpeakerIdentitiesAsync(
}
app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) =>
Results.Ok(ReloadWorkflowConfiguration(configuration)));
app.MapPost("/diagnostics/workflow/rules-editor/show", (
IWorkflowRulesEditorWindowService rulesEditorWindow) =>
{
rulesEditorWindow.Show();
return Results.Accepted();
});
app.MapPost("/diagnostics/settings-and-logs/show", (
IWorkflowRulesEditorWindowService rulesEditorWindow) =>
{
rulesEditorWindow.Show();
return Results.Accepted();
});
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
{
@@ -298,61 +364,128 @@ static async Task<IResult> RunCurrentSummaryAsync(
app.MapPost("/meetings/summary/retry", async (
SummaryRetryRequest request,
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingSummaryRetryRunner summaryRetryRunner,
IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) =>
await RetrySummaryAsync(
null,
request.SummaryPath,
artifactResolver,
summaryPipeline,
summaryRetryRunner,
options.Value,
null,
cancellationToken));
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
string launchProfile,
SummaryRetryRequest request,
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingSummaryRetryRunner summaryRetryRunner,
IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider launchProfiles,
CancellationToken cancellationToken) =>
await RetrySummaryAsync(
launchProfile,
request.SummaryPath,
artifactResolver,
summaryPipeline,
summaryRetryRunner,
options.Value,
launchProfiles,
cancellationToken));
app.MapGet("/meetings/summary/retry", async (
string summaryPath,
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingSummaryRetryRunner summaryRetryRunner,
IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) =>
await RetrySummaryAsync(
null,
summaryPath,
artifactResolver,
summaryPipeline,
summaryRetryRunner,
options.Value,
null,
cancellationToken));
app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
string launchProfile,
string summaryPath,
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingSummaryRetryRunner summaryRetryRunner,
IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider launchProfiles,
CancellationToken cancellationToken) =>
await RetrySummaryAsync(
launchProfile,
summaryPath,
artifactResolver,
summaryPipeline,
summaryRetryRunner,
options.Value,
launchProfiles,
cancellationToken));
app.MapPost("/meetings/screenshot-ocr/retry", async (
ScreenshotOcrRetryRequest request,
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) =>
await RetryScreenshotOcrAsync(
null,
request,
screenshotOcrRetryRunner,
options.Value,
null,
cancellationToken));
app.MapPost("/profiles/{launchProfile}/meetings/screenshot-ocr/retry", async (
string launchProfile,
ScreenshotOcrRetryRequest request,
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider launchProfiles,
CancellationToken cancellationToken) =>
await RetryScreenshotOcrAsync(
launchProfile,
request,
screenshotOcrRetryRunner,
options.Value,
launchProfiles,
cancellationToken));
app.MapGet("/meetings/screenshot-ocr/retry", async (
string meetingNotePath,
string transcriptPath,
string assistantContextPath,
string summaryPath,
string screenshotPath,
string screenshotId,
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) =>
await RetryScreenshotOcrAsync(
null,
new ScreenshotOcrRetryRequest(
meetingNotePath,
transcriptPath,
assistantContextPath,
summaryPath,
screenshotPath,
screenshotId),
screenshotOcrRetryRunner,
options.Value,
null,
cancellationToken));
app.MapGet("/profiles/{launchProfile}/meetings/screenshot-ocr/retry", async (
string launchProfile,
string meetingNotePath,
string transcriptPath,
string assistantContextPath,
string summaryPath,
string screenshotPath,
string screenshotId,
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider launchProfiles,
CancellationToken cancellationToken) =>
await RetryScreenshotOcrAsync(
launchProfile,
new ScreenshotOcrRetryRequest(
meetingNotePath,
transcriptPath,
assistantContextPath,
summaryPath,
screenshotPath,
screenshotId),
screenshotOcrRetryRunner,
options.Value,
launchProfiles,
cancellationToken));
@@ -360,8 +493,7 @@ app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
static async Task<IResult> RetrySummaryAsync(
string? launchProfile,
string? summaryPath,
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingSummaryRetryRunner summaryRetryRunner,
MeetingAssistantOptions defaultOptions,
ILaunchProfileOptionsProvider? launchProfiles,
CancellationToken cancellationToken)
@@ -372,13 +504,62 @@ static async Task<IResult> RetrySummaryAsync(
}
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, profileOptions, cancellationToken);
if (artifacts is null)
var trigger = await summaryRetryRunner.TriggerAsync(summaryPath, profileOptions, cancellationToken);
if (trigger is null)
{
return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." });
}
return Results.Ok(await summaryPipeline.RunAsync(artifacts, profileOptions, cancellationToken));
return Results.Accepted(value: new
{
summaryPath = trigger.SummaryPath,
assistantContextPath = trigger.AssistantContextPath,
state = "summarizing"
});
}
static async Task<IResult> RetryScreenshotOcrAsync(
string? launchProfile,
ScreenshotOcrRetryRequest request,
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
MeetingAssistantOptions defaultOptions,
ILaunchProfileOptionsProvider? launchProfiles,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.MeetingNotePath) ||
string.IsNullOrWhiteSpace(request.TranscriptPath) ||
string.IsNullOrWhiteSpace(request.AssistantContextPath) ||
string.IsNullOrWhiteSpace(request.SummaryPath) ||
string.IsNullOrWhiteSpace(request.ScreenshotPath) ||
string.IsNullOrWhiteSpace(request.ScreenshotId))
{
return Results.BadRequest(new { error = "Meeting artifact paths, screenshot path, and screenshot id are required." });
}
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
var artifacts = new MeetingSessionArtifacts(
request.MeetingNotePath,
request.TranscriptPath,
request.AssistantContextPath,
request.SummaryPath);
var trigger = await screenshotOcrRetryRunner.TriggerOcrRetryAsync(
artifacts,
request.ScreenshotPath,
request.ScreenshotId,
profileOptions,
cancellationToken);
if (trigger is null)
{
return Results.NotFound(new { error = "No retryable screenshot OCR block was found for the requested screenshot." });
}
return Results.Accepted(value: new
{
assistantContextPath = trigger.AssistantContextPath,
screenshotPath = trigger.ScreenshotPath,
screenshotId = trigger.ScreenshotId,
state = "ocr retrying"
});
}
app.MapPost("/asr/transcribe-file", async (
@@ -487,6 +668,14 @@ public sealed record AsrDiagnosticRequest(string Path, int? NumSpeakers = null);
public sealed record SummaryRetryRequest(string SummaryPath);
public sealed record ScreenshotOcrRetryRequest(
string MeetingNotePath,
string TranscriptPath,
string AssistantContextPath,
string SummaryPath,
string ScreenshotPath,
string ScreenshotId);
public sealed record SpeakerIdentityMergeDiagnosticRequest(double? RecentDays = null);
public sealed record WorkflowConfigurationReloadResponse(string? RulesPath);
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MeetingAssistant.Tests")]
@@ -0,0 +1,130 @@
namespace MeetingAssistant.Recording;
public interface IAcousticEchoCanceller
{
void Reset();
AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio);
}
public interface IAcousticEchoCancellerFactory
{
IAcousticEchoCanceller Create();
}
public sealed class AdaptiveFilterAcousticEchoCancellerFactory : IAcousticEchoCancellerFactory
{
public IAcousticEchoCanceller Create()
{
return new AdaptiveFilterAcousticEchoCanceller();
}
}
public sealed class NoopAcousticEchoCanceller : IAcousticEchoCanceller
{
public void Reset()
{
}
public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio)
{
return microphone;
}
}
public sealed class AdaptiveFilterAcousticEchoCanceller : IAcousticEchoCanceller
{
private const int DefaultFilterLength = 256;
private const double DefaultAdaptationStep = 0.25;
private const double Epsilon = 1.0;
private readonly double[] coefficients;
private readonly double[] renderHistory;
private int historyPosition;
public AdaptiveFilterAcousticEchoCanceller()
: this(DefaultFilterLength)
{
}
public AdaptiveFilterAcousticEchoCanceller(int filterLength)
{
var effectiveFilterLength = Math.Max(16, filterLength);
coefficients = new double[effectiveFilterLength];
renderHistory = new double[effectiveFilterLength];
}
public void Reset()
{
Array.Clear(coefficients);
Array.Clear(renderHistory);
historyPosition = 0;
}
public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio)
{
if (microphone.Pcm.Length == 0 ||
systemAudio.Pcm.Length == 0 ||
microphone.SampleRate != systemAudio.SampleRate ||
microphone.Channels != 1 ||
systemAudio.Channels != 1)
{
return microphone;
}
var byteCount = Math.Min(microphone.Pcm.Length, systemAudio.Pcm.Length);
byteCount -= byteCount % sizeof(short);
if (byteCount <= 0)
{
return microphone;
}
var cleaned = new byte[microphone.Pcm.Length];
for (var byteIndex = 0; byteIndex < byteCount; byteIndex += sizeof(short))
{
var render = BitConverter.ToInt16(systemAudio.Pcm, byteIndex) / 32768.0;
var capture = BitConverter.ToInt16(microphone.Pcm, byteIndex) / 32768.0;
renderHistory[historyPosition] = render;
var estimatedEcho = 0.0;
var energy = Epsilon;
for (var tap = 0; tap < coefficients.Length; tap++)
{
var sample = renderHistory[HistoryIndex(tap)];
estimatedEcho += coefficients[tap] * sample;
energy += sample * sample;
}
var error = capture - estimatedEcho;
var updateScale = DefaultAdaptationStep * error / energy;
for (var tap = 0; tap < coefficients.Length; tap++)
{
coefficients[tap] += updateScale * renderHistory[HistoryIndex(tap)];
}
WriteSample(cleaned, byteIndex, error);
historyPosition = historyPosition == 0 ? renderHistory.Length - 1 : historyPosition - 1;
}
if (byteCount < microphone.Pcm.Length)
{
Buffer.BlockCopy(microphone.Pcm, byteCount, cleaned, byteCount, microphone.Pcm.Length - byteCount);
}
return microphone with { Pcm = cleaned };
}
private int HistoryIndex(int offset)
{
var index = historyPosition + offset;
return index >= renderHistory.Length ? index - renderHistory.Length : index;
}
private static void WriteSample(byte[] target, int byteIndex, double sample)
{
var scaled = Math.Clamp(
(int)Math.Round(sample * 32768.0),
short.MinValue,
short.MaxValue);
BitConverter.GetBytes((short)scaled).CopyTo(target, byteIndex);
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
namespace MeetingAssistant.Recording;
public sealed record AudioChunk(byte[] Pcm, int SampleRate, int Channels)
public sealed record AudioChunk(
byte[] Pcm,
int SampleRate,
int Channels)
{
public TimeSpan Duration
{
@@ -5,26 +5,44 @@ namespace MeetingAssistant.Recording;
public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
{
private static readonly TimeSpan SourceSkewTimeout = TimeSpan.FromMilliseconds(250);
private readonly IMeetingAudioSource microphone;
private readonly IMeetingAudioSource systemAudio;
private readonly IAcousticEchoCancellerFactory echoCancellerFactory;
private readonly ILogger<CompositeMeetingAudioSource> logger;
public CompositeMeetingAudioSource(
IMeetingAudioSource microphone,
IMeetingAudioSource systemAudio,
IAcousticEchoCancellerFactory echoCancellerFactory,
ILogger<CompositeMeetingAudioSource> logger)
{
this.microphone = microphone;
this.systemAudio = systemAudio;
this.echoCancellerFactory = echoCancellerFactory;
this.logger = logger;
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var chunk in CaptureAsync(new MeetingAssistantOptions(), cancellationToken))
{
yield return chunk;
}
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
MeetingAssistantOptions options,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var echoCanceller = echoCancellerFactory.Create();
echoCanceller.Reset();
var microphoneGain = NormalizeGain(options.Recording.MicrophoneMixGain);
var systemAudioGain = NormalizeGain(options.Recording.SystemAudioMixGain);
var channel = Channel.CreateUnbounded<SourceChunk>();
var microphoneTask = PumpAsync(AudioSourceKind.Microphone, microphone, channel.Writer, cancellationToken);
var systemTask = PumpAsync(AudioSourceKind.System, systemAudio, channel.Writer, cancellationToken);
var microphoneTask = PumpAsync(AudioSourceKind.Microphone, microphone, options, channel.Writer, cancellationToken);
var systemTask = PumpAsync(AudioSourceKind.System, systemAudio, options, channel.Writer, cancellationToken);
_ = Task.WhenAll(microphoneTask, systemTask)
.ContinueWith(
task =>
@@ -41,8 +59,8 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
AudioChunk? pendingMicrophone = null;
AudioChunk? pendingSystem = null;
var pendingMicrophone = new PendingAudioStream();
var pendingSystem = new PendingAudioStream();
await foreach (var sourceChunk in channel.Reader.ReadAllAsync(cancellationToken))
{
@@ -52,22 +70,32 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
sourceChunk.Kind);
if (sourceChunk.Kind == AudioSourceKind.Microphone)
{
pendingMicrophone = sourceChunk.Chunk;
pendingMicrophone.Enqueue(sourceChunk.Chunk);
}
else
{
pendingSystem = sourceChunk.Chunk;
pendingSystem.Enqueue(sourceChunk.Chunk);
}
if (pendingMicrophone is not null && pendingSystem is not null)
while (TryReadAlignedPair(pendingMicrophone, pendingSystem, out var microphoneChunk, out var systemChunk))
{
yield return Mix(pendingMicrophone, pendingSystem);
pendingMicrophone = null;
pendingSystem = null;
continue;
yield return CleanAndMix(microphoneChunk, systemChunk, echoCanceller, microphoneGain, systemAudioGain);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
if (ShouldFlushTimedOutRemainder(pendingMicrophone, pendingSystem))
{
while (TryReadRemainder(pendingMicrophone, pendingSystem, out var bufferedMicrophoneChunk, out var bufferedSystemChunk))
{
yield return CleanAndMix(
bufferedMicrophoneChunk,
bufferedSystemChunk,
echoCanceller,
microphoneGain,
systemAudioGain);
}
}
using var timeout = new CancellationTokenSource(SourceSkewTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
try
{
@@ -80,39 +108,33 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
{
}
if (pendingMicrophone is not null)
while (TryReadRemainder(pendingMicrophone, pendingSystem, out var timeoutMicrophoneChunk, out var timeoutSystemChunk))
{
yield return pendingMicrophone;
pendingMicrophone = null;
}
if (pendingSystem is not null)
{
yield return pendingSystem;
pendingSystem = null;
yield return CleanAndMix(
timeoutMicrophoneChunk,
timeoutSystemChunk,
echoCanceller,
microphoneGain,
systemAudioGain);
}
}
if (pendingMicrophone is not null)
while (TryReadRemainder(pendingMicrophone, pendingSystem, out var microphoneChunk, out var systemChunk))
{
yield return pendingMicrophone;
}
if (pendingSystem is not null)
{
yield return pendingSystem;
yield return CleanAndMix(microphoneChunk, systemChunk, echoCanceller, microphoneGain, systemAudioGain);
}
}
private static async Task PumpAsync(
AudioSourceKind kind,
IMeetingAudioSource source,
MeetingAssistantOptions options,
ChannelWriter<SourceChunk> writer,
CancellationToken cancellationToken)
{
try
{
await foreach (var chunk in source.CaptureAsync(cancellationToken))
await foreach (var chunk in source.CaptureAsync(options, cancellationToken))
{
await writer.WriteAsync(new SourceChunk(kind, chunk), cancellationToken);
}
@@ -128,32 +150,49 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
}
}
private AudioChunk Mix(AudioChunk left, AudioChunk right)
private AudioChunk CleanAndMix(
AudioChunk microphoneChunk,
AudioChunk systemChunk,
IAcousticEchoCanceller echoCanceller,
double microphoneGain,
double systemAudioGain)
{
if (left.SampleRate != right.SampleRate || left.Channels != right.Channels)
if (microphoneChunk.SampleRate != systemChunk.SampleRate || microphoneChunk.Channels != systemChunk.Channels)
{
logger.LogWarning(
"Cannot mix audio chunks with different formats. Using microphone format {MicrophoneSampleRate}/{MicrophoneChannels} and dropping system chunk format {SystemSampleRate}/{SystemChannels}.",
left.SampleRate,
left.Channels,
right.SampleRate,
right.Channels);
return left;
microphoneChunk.SampleRate,
microphoneChunk.Channels,
systemChunk.SampleRate,
systemChunk.Channels);
return microphoneChunk;
}
var mixed = new byte[Math.Max(left.Pcm.Length, right.Pcm.Length)];
var cleanedMicrophone = echoCanceller.CleanMicrophone(microphoneChunk, systemChunk);
var mixed = new byte[Math.Max(cleanedMicrophone.Pcm.Length, systemChunk.Pcm.Length)];
var sampleCount = mixed.Length / sizeof(short);
for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++)
{
var byteIndex = sampleIndex * sizeof(short);
var leftSample = ReadSample(left.Pcm, byteIndex);
var rightSample = ReadSample(right.Pcm, byteIndex);
var sample = Math.Clamp(leftSample + rightSample, short.MinValue, short.MaxValue);
var leftSample = ReadSample(cleanedMicrophone.Pcm, byteIndex);
var rightSample = ReadSample(systemChunk.Pcm, byteIndex);
var sample = Math.Clamp(
(int)Math.Round(leftSample * microphoneGain + rightSample * systemAudioGain),
short.MinValue,
short.MaxValue);
BitConverter.GetBytes((short)sample).CopyTo(mixed, byteIndex);
}
return new AudioChunk(mixed, left.SampleRate, left.Channels);
return new AudioChunk(
mixed,
cleanedMicrophone.SampleRate,
cleanedMicrophone.Channels);
}
private static double NormalizeGain(double gain)
{
return double.IsFinite(gain) && gain >= 0 ? gain : 0;
}
private static int ReadSample(byte[] pcm, int byteIndex)
@@ -161,6 +200,144 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
return byteIndex + 1 < pcm.Length ? BitConverter.ToInt16(pcm, byteIndex) : 0;
}
private static bool TryReadAlignedPair(
PendingAudioStream microphone,
PendingAudioStream systemAudio,
out AudioChunk microphoneChunk,
out AudioChunk systemChunk)
{
microphoneChunk = null!;
systemChunk = null!;
var byteCount = Math.Min(microphone.ByteCount, systemAudio.ByteCount);
byteCount -= byteCount % sizeof(short);
if (byteCount <= 0 || microphone.Format is null || systemAudio.Format is null)
{
return false;
}
microphoneChunk = microphone.Read(byteCount);
systemChunk = systemAudio.Read(byteCount);
return true;
}
private static bool TryReadRemainder(
PendingAudioStream microphone,
PendingAudioStream systemAudio,
out AudioChunk microphoneChunk,
out AudioChunk systemChunk)
{
microphoneChunk = null!;
systemChunk = null!;
var byteCount = Math.Max(microphone.ByteCount, systemAudio.ByteCount);
byteCount -= byteCount % sizeof(short);
var format = microphone.Format ?? systemAudio.Format;
if (byteCount <= 0 || format is null)
{
return false;
}
microphoneChunk = microphone.ByteCount > 0
? microphone.Read(Math.Min(byteCount, microphone.ByteCount))
: new AudioChunk(new byte[byteCount], format.SampleRate, format.Channels);
systemChunk = systemAudio.ByteCount > 0
? systemAudio.Read(Math.Min(byteCount, systemAudio.ByteCount))
: new AudioChunk(new byte[byteCount], format.SampleRate, format.Channels);
if (microphoneChunk.Pcm.Length < byteCount)
{
microphoneChunk = microphoneChunk with { Pcm = PadSilence(microphoneChunk.Pcm, byteCount) };
}
if (systemChunk.Pcm.Length < byteCount)
{
systemChunk = systemChunk with { Pcm = PadSilence(systemChunk.Pcm, byteCount) };
}
return true;
}
private static bool ShouldFlushTimedOutRemainder(PendingAudioStream microphone, PendingAudioStream systemAudio)
{
return microphone.ByteCount > 0 && systemAudio.ByteCount == 0 && microphone.HasBufferedFor(SourceSkewTimeout) ||
systemAudio.ByteCount > 0 && microphone.ByteCount == 0 && systemAudio.HasBufferedFor(SourceSkewTimeout);
}
private static byte[] PadSilence(byte[] pcm, int byteCount)
{
var padded = new byte[byteCount];
Buffer.BlockCopy(pcm, 0, padded, 0, pcm.Length);
return padded;
}
private sealed class PendingAudioStream
{
private readonly LinkedList<ArraySegment<byte>> chunks = new();
private long? firstBufferedAt;
public int ByteCount { get; private set; }
public AudioFormat? Format { get; private set; }
public void Enqueue(AudioChunk chunk)
{
if (chunk.Pcm.Length == 0)
{
return;
}
firstBufferedAt ??= Environment.TickCount64;
Format ??= new AudioFormat(chunk.SampleRate, chunk.Channels);
chunks.AddLast(new ArraySegment<byte>(chunk.Pcm));
ByteCount += chunk.Pcm.Length;
}
public bool HasBufferedFor(TimeSpan duration)
{
return firstBufferedAt is not null &&
TimeSpan.FromMilliseconds(Environment.TickCount64 - firstBufferedAt.Value) >= duration;
}
public AudioChunk Read(int byteCount)
{
if (Format is null)
{
throw new InvalidOperationException("Cannot read audio before a stream format has been captured.");
}
var target = new byte[byteCount];
var written = 0;
while (written < byteCount && chunks.First is not null)
{
var chunk = chunks.First.Value;
var copyCount = Math.Min(byteCount - written, chunk.Count);
Buffer.BlockCopy(chunk.Array!, chunk.Offset, target, written, copyCount);
written += copyCount;
ByteCount -= copyCount;
if (copyCount == chunk.Count)
{
chunks.RemoveFirst();
}
else
{
chunks.First.Value = new ArraySegment<byte>(
chunk.Array!,
chunk.Offset + copyCount,
chunk.Count - copyCount);
}
}
if (ByteCount == 0)
{
firstBufferedAt = null;
}
return new AudioChunk(target, Format.SampleRate, Format.Channels);
}
}
private sealed record AudioFormat(int SampleRate, int Channels);
private sealed record SourceChunk(AudioSourceKind Kind, AudioChunk Chunk);
private enum AudioSourceKind
@@ -0,0 +1,124 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Recording;
public sealed class FileOfflineTranscriptionBacklog : IOfflineTranscriptionBacklog
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly MeetingAssistantOptions options;
private readonly ILogger<FileOfflineTranscriptionBacklog> logger;
public FileOfflineTranscriptionBacklog(
IOptions<MeetingAssistantOptions> options,
ILogger<FileOfflineTranscriptionBacklog> logger)
{
this.options = options.Value;
this.logger = logger;
}
public async Task EnqueueAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
var folder = GetBacklogFolder(options);
Directory.CreateDirectory(folder);
var path = GetItemPath(folder, item.Id);
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(item, JsonOptions), cancellationToken);
logger.LogInformation(
"Queued offline transcription backlog item {BacklogItemId} for recording {RecordingPath}",
item.Id,
item.AudioPath);
}
public async Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken)
{
var folder = GetBacklogFolder(options);
if (!Directory.Exists(folder))
{
return [];
}
var items = new List<OfflineTranscriptionBacklogItem>();
foreach (var path in Directory.EnumerateFiles(folder, "*.json", SearchOption.TopDirectoryOnly))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var content = await File.ReadAllTextAsync(path, cancellationToken);
if (JsonSerializer.Deserialize<OfflineTranscriptionBacklogItem>(content, JsonOptions) is { } item)
{
items.Add(item);
}
}
catch (JsonException exception)
{
logger.LogWarning(exception, "Could not read offline transcription backlog item {BacklogItemPath}", path);
}
catch (IOException exception)
{
logger.LogWarning(exception, "Could not read offline transcription backlog item {BacklogItemPath}", path);
}
}
return items;
}
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
var folder = GetBacklogFolder(options);
var path = GetItemPath(folder, item.Id);
if (!File.Exists(path))
{
return Task.CompletedTask;
}
File.Delete(path);
if (File.Exists(item.AudioPath))
{
DeleteCompletedAudio(item.AudioPath);
}
return Task.CompletedTask;
}
private static string GetBacklogFolder(MeetingAssistantOptions options)
{
return Path.Combine(VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder), "offline-transcription-backlog");
}
private static string GetItemPath(string folder, string id)
{
return Path.Combine(folder, $"{id}.json");
}
private void DeleteCompletedAudio(string audioPath)
{
try
{
File.Delete(audioPath);
}
catch (FileNotFoundException)
{
}
catch (DirectoryNotFoundException)
{
}
catch (IOException exception)
{
logger.LogWarning(
exception,
"Could not delete completed offline transcription recording file {RecordingPath}",
audioPath);
}
catch (UnauthorizedAccessException exception)
{
logger.LogWarning(
exception,
"Could not delete completed offline transcription recording file {RecordingPath}",
audioPath);
}
}
}
@@ -3,4 +3,11 @@ namespace MeetingAssistant.Recording;
public interface IMeetingAudioSource
{
IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken);
IAsyncEnumerable<AudioChunk> CaptureAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CaptureAsync(cancellationToken);
}
}
@@ -0,0 +1,18 @@
namespace MeetingAssistant.Recording;
public interface IMeetingInactivityClock
{
DateTimeOffset Now { get; }
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}
public sealed class SystemMeetingInactivityClock : IMeetingInactivityClock
{
public DateTimeOffset Now => DateTimeOffset.Now;
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
return Task.Delay(delay, cancellationToken);
}
}
@@ -0,0 +1,33 @@
namespace MeetingAssistant.Recording;
public interface IMeetingInactivityPromptService
{
Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken);
}
public sealed record MeetingInactivityPromptRequest(
TimeSpan InactivityDuration,
DateTimeOffset SuggestedEndTime,
int PromptIndex,
TimeSpan Threshold);
public enum MeetingInactivityPromptResponse
{
Dismissed,
Continue,
Stop
}
public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromptService
{
public Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,12 @@
using NAudio.Wave;
namespace MeetingAssistant.Recording;
public interface IMicrophoneDeviceProvider
{
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options);
IWaveIn CreateCapture(MeetingAssistantOptions options);
}
+67 -3
View File
@@ -9,16 +9,50 @@ public interface ITranscriptStore
Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
DateTimeOffset startedAt,
CancellationToken cancellationToken);
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
return AppendLineAndDiscardReferenceAsync(
session,
TranscriptLineFormatter.Format(segment).Line,
cancellationToken);
}
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
private async Task AppendLineAndDiscardReferenceAsync(
TranscriptSession session,
string line,
CancellationToken cancellationToken)
{
_ = await AppendLineAsync(session, line, cancellationToken);
}
Task<TranscriptLineReference> AppendLineAsync(
TranscriptSession session,
string line,
CancellationToken cancellationToken);
Task ReplaceLineAsync(
TranscriptSession session,
TranscriptLineReference lineReference,
string replacementLine,
CancellationToken cancellationToken);
Task ReplaceAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
CancellationToken cancellationToken)
{
return ReplaceLinesAsync(
session,
replacementSegments.Select(segment => TranscriptLineFormatter.Format(segment).Line).ToList(),
cancellationToken);
}
Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> replacementLines,
CancellationToken cancellationToken);
Task UpdateMetadataAsync(
@@ -29,3 +63,33 @@ public interface ITranscriptStore
}
public sealed record TranscriptSession(string TranscriptPath);
public sealed record TranscriptLineReference(string TranscriptPath, int BodyLineIndex, string OriginalLine);
public static class TranscriptLineFormatter
{
public static FormattedTranscriptLine Format(TranscriptionSegment segment)
{
var speaker = NormalizeSpeaker(segment.Speaker);
if (segment.Kind == TranscriptionSegmentKind.Marker)
{
return new FormattedTranscriptLine(segment.Text, speaker);
}
return new FormattedTranscriptLine(
$"[{segment.Start:hh\\:mm\\:ss}] {speaker}: {segment.Text}",
speaker);
}
public static string FormatSegmentLine(TranscriptionSegment segment)
{
return Format(segment).Line;
}
public static string NormalizeSpeaker(string? speaker)
{
return string.IsNullOrWhiteSpace(speaker) ? "Unknown" : speaker;
}
}
public sealed record FormattedTranscriptLine(string Line, string Speaker);
@@ -0,0 +1,59 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Recording;
internal static class MeetingArtifactContentDetector
{
private static readonly string[] DefaultAssistantContextLines =
[
"# Assistant Context",
"## Live Context"
];
public static async Task<bool> IsDefaultOnlyAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
if (!File.Exists(artifacts.MeetingNotePath) ||
!File.Exists(artifacts.TranscriptPath) ||
!File.Exists(artifacts.AssistantContextPath))
{
return false;
}
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath, cancellationToken);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath, cancellationToken);
var assistantContext = await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken);
return IsDefaultOnlyMeetingNote(meetingNote) &&
IsDefaultOnlyTranscript(transcript) &&
IsDefaultOnlyAssistantContext(assistantContext);
}
private static bool IsDefaultOnlyMeetingNote(string content)
{
return string.IsNullOrWhiteSpace(MarkdownDocumentParser.SplitOptional(content).Body);
}
private static bool IsDefaultOnlyTranscript(string content)
{
var body = MarkdownDocumentParser.SplitOptional(content).Body;
return GetMeaningfulLines(body)
.All(line => string.Equals(line, "# Meeting Transcript", StringComparison.OrdinalIgnoreCase));
}
private static bool IsDefaultOnlyAssistantContext(string content)
{
var body = MarkdownDocumentParser.SplitOptional(content).Body;
return GetMeaningfulLines(body)
.All(line => DefaultAssistantContextLines.Contains(line, StringComparer.OrdinalIgnoreCase));
}
private static IEnumerable<string> GetMeaningfulLines(string body)
{
return body
.Split(["\r\n", "\n"], StringSplitOptions.None)
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
namespace MeetingAssistant.Recording;
public sealed record MicrophoneDevice(
string Id,
string Name);
public sealed record MicrophoneDeviceSnapshot(
IReadOnlyList<MicrophoneDevice> Available,
MicrophoneDevice? Current);
@@ -0,0 +1,54 @@
namespace MeetingAssistant.Recording;
public sealed class MicrophoneDeviceSelection
{
private readonly object sync = new();
private string? selectedDeviceId;
public string? SelectedDeviceId
{
get
{
lock (sync)
{
return selectedDeviceId;
}
}
}
public void Select(string deviceId)
{
lock (sync)
{
selectedDeviceId = string.IsNullOrWhiteSpace(deviceId)
? null
: deviceId.Trim();
}
}
public MicrophoneDevice? Resolve(
string? configuredDeviceId,
MicrophoneDevice? defaultDevice,
IReadOnlyList<MicrophoneDevice> availableDevices)
{
var selected = SelectedDeviceId;
return FindById(availableDevices, selected) ??
FindById(availableDevices, configuredDeviceId) ??
defaultDevice;
}
private static MicrophoneDevice? FindById(
IReadOnlyList<MicrophoneDevice> devices,
string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
return devices.FirstOrDefault(device => string.Equals(
device.Id,
id.Trim(),
StringComparison.OrdinalIgnoreCase));
}
}
@@ -1,5 +1,4 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using NAudio.CoreAudioApi;
using NAudio.Wave;
@@ -7,23 +6,33 @@ namespace MeetingAssistant.Recording;
public sealed class MicrophoneAudioSource : IMeetingAudioSource
{
private readonly MeetingAssistantOptions options;
private readonly IMicrophoneDeviceProvider microphones;
private readonly ILogger<MicrophoneAudioSource> logger;
public MicrophoneAudioSource(
IOptions<MeetingAssistantOptions> options,
IMicrophoneDeviceProvider microphones,
ILogger<MicrophoneAudioSource> logger)
{
this.options = options.Value;
this.microphones = microphones;
this.logger = logger;
}
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
{
return CaptureAsync(new WasapiCapture(), cancellationToken);
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
}
private IAsyncEnumerable<AudioChunk> CaptureAsync(IWaveIn capture, CancellationToken cancellationToken)
public IAsyncEnumerable<AudioChunk> CaptureAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CaptureAsync(microphones.CreateCapture(options), options, cancellationToken);
}
private IAsyncEnumerable<AudioChunk> CaptureAsync(
IWaveIn capture,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
capture.WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
return CaptureWith(capture, "microphone", logger, cancellationToken);
@@ -94,18 +103,21 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
public sealed class SystemAudioSource : IMeetingAudioSource
{
private readonly MeetingAssistantOptions options;
private readonly ILogger<SystemAudioSource> logger;
public SystemAudioSource(
IOptions<MeetingAssistantOptions> options,
ILogger<SystemAudioSource> logger)
public SystemAudioSource(ILogger<SystemAudioSource> logger)
{
this.options = options.Value;
this.logger = logger;
}
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
{
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
}
public IAsyncEnumerable<AudioChunk> CaptureAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var capture = new WasapiLoopbackCapture
{
@@ -0,0 +1,29 @@
namespace MeetingAssistant.Recording;
internal static class NotificationActivationArguments
{
public static IReadOnlyDictionary<string, string> Parse(string argumentText)
{
if (string.IsNullOrWhiteSpace(argumentText))
{
return new Dictionary<string, string>();
}
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in argumentText.Split(['&', ';'], StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
arguments[Uri.UnescapeDataString(pair)] = "";
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
arguments[key] = value;
}
return arguments;
}
}
@@ -0,0 +1,45 @@
namespace MeetingAssistant.Recording;
public interface IOfflineTranscriptionBacklog
{
Task EnqueueAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken);
Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken);
Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken);
}
public sealed record OfflineTranscriptionBacklogItem(
string Id,
string AudioPath,
string TranscriptPath,
string MeetingNotePath,
string AssistantContextPath,
string SummaryPath,
DateTimeOffset StartedAt,
DateTimeOffset? InferredEndTime,
string LaunchProfileName);
public sealed class NoopOfflineTranscriptionBacklog : IOfflineTranscriptionBacklog
{
public static readonly NoopOfflineTranscriptionBacklog Instance = new();
private NoopOfflineTranscriptionBacklog()
{
}
public Task EnqueueAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>([]);
}
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,53 @@
namespace MeetingAssistant.Recording;
public sealed class OfflineTranscriptionBacklogHostedService : BackgroundService
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private readonly OfflineTranscriptionBacklogProcessor processor;
private readonly ILogger<OfflineTranscriptionBacklogHostedService> logger;
public OfflineTranscriptionBacklogHostedService(
OfflineTranscriptionBacklogProcessor processor,
ILogger<OfflineTranscriptionBacklogHostedService> logger)
{
this.processor = processor;
this.logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var processed = await processor.ProcessPendingAsync(stoppingToken);
if (processed > 0)
{
logger.LogInformation(
"Processed {ProcessedCount} offline transcription backlog item(s)",
processed);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Offline transcription backlog processing failed; it will be retried later");
}
try
{
await Task.Delay(RetryDelay, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
}
}
}
@@ -0,0 +1,220 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Recording;
public sealed class OfflineTranscriptionBacklogProcessor
{
private readonly IOfflineTranscriptionBacklog backlog;
private readonly ISpeechRecognitionPipelineFactory pipelineFactory;
private readonly ITranscriptStore transcriptStore;
private readonly IMeetingNoteStore meetingNoteStore;
private readonly IMeetingArtifactStore meetingArtifactStore;
private readonly IMeetingSummaryPipeline summaryPipeline;
private readonly IMeetingWorkflowEngine workflowEngine;
private readonly IRecordingDictationWordProvider dictationWordProvider;
private readonly MeetingAssistantOptions options;
private readonly ILogger<OfflineTranscriptionBacklogProcessor> logger;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
public OfflineTranscriptionBacklogProcessor(
IOfflineTranscriptionBacklog backlog,
ISpeechRecognitionPipelineFactory pipelineFactory,
ITranscriptStore transcriptStore,
IMeetingNoteStore meetingNoteStore,
IMeetingArtifactStore meetingArtifactStore,
IMeetingSummaryPipeline summaryPipeline,
IMeetingWorkflowEngine workflowEngine,
IRecordingDictationWordProvider dictationWordProvider,
IOptions<MeetingAssistantOptions> options,
ILogger<OfflineTranscriptionBacklogProcessor> logger,
ILaunchProfileOptionsProvider? launchProfiles = null)
{
this.backlog = backlog;
this.pipelineFactory = pipelineFactory;
this.transcriptStore = transcriptStore;
this.meetingNoteStore = meetingNoteStore;
this.meetingArtifactStore = meetingArtifactStore;
this.summaryPipeline = summaryPipeline;
this.workflowEngine = workflowEngine;
this.dictationWordProvider = dictationWordProvider;
this.options = options.Value;
this.logger = logger;
this.launchProfiles = launchProfiles;
}
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
{
var processed = 0;
foreach (var item in await backlog.ListAsync(cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
if (await TryProcessAsync(item, cancellationToken))
{
processed++;
}
}
return processed;
}
private async Task<bool> TryProcessAsync(
OfflineTranscriptionBacklogItem item,
CancellationToken cancellationToken)
{
try
{
await ProcessAsync(item, cancellationToken);
await backlog.CompleteAsync(item, cancellationToken);
logger.LogInformation(
"Completed offline transcription backlog item {BacklogItemId} for recording {RecordingPath}",
item.Id,
item.AudioPath);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not process offline transcription backlog item {BacklogItemId}; it will be retried later",
item.Id);
return false;
}
}
private async Task ProcessAsync(
OfflineTranscriptionBacklogItem item,
CancellationToken cancellationToken)
{
var runOptions = ResolveOptions(item.LaunchProfileName);
var artifacts = new MeetingSessionArtifacts(
item.MeetingNotePath,
item.TranscriptPath,
item.AssistantContextPath,
item.SummaryPath);
var session = new TranscriptSession(item.TranscriptPath);
var meetingNote = await meetingNoteStore.ReadAsync(item.MeetingNotePath, cancellationToken);
var pipelineOptions = await RecordingSpeechRecognitionPipelineOptions.BuildAsync(
dictationWordProvider,
runOptions,
meetingNote,
cancellationToken);
await using var pipeline = pipelineFactory.Create(item.LaunchProfileName);
await pipeline.InitializeAsync(pipelineOptions, cancellationToken);
await pipeline.WaitUntilReadyAsync(cancellationToken);
await WriteRecordingToPipelineAsync(item.AudioPath, pipeline, cancellationToken);
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
item.AudioPath,
pipelineOptions,
cancellationToken);
var lines = await TransformTranscriptLinesAsync(artifacts, runOptions, finishedSegments, cancellationToken);
await transcriptStore.ReplaceLinesAsync(session, lines, cancellationToken);
meetingNote.Frontmatter.EndTime = item.InferredEndTime ?? DateTimeOffset.Now;
var completedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
await transcriptStore.UpdateMetadataAsync(session, artifacts, completedMeetingNote, cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(artifacts, completedMeetingNote, cancellationToken);
await TransitionMeetingAsync(
artifacts,
runOptions,
AssistantContextState.Transcribing,
AssistantContextState.Summarizing,
cancellationToken);
var summaryResult = await summaryPipeline.RunAsync(artifacts, runOptions, cancellationToken);
await TransitionMeetingAsync(
artifacts,
runOptions,
AssistantContextState.Summarizing,
summaryResult.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
cancellationToken);
}
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
{
if (launchProfiles is not null)
{
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
}
return options;
}
private async Task<List<string>> TransformTranscriptLinesAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions runOptions,
IReadOnlyList<TranscriptionSegment> segments,
CancellationToken cancellationToken)
{
var lines = new List<string>(segments.Count);
foreach (var segment in segments)
{
var formatted = TranscriptLineFormatter.Format(segment);
lines.Add(await TransformTranscriptLineAsync(artifacts, runOptions, formatted, cancellationToken));
}
return lines;
}
private async Task<string> TransformTranscriptLineAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions runOptions,
FormattedTranscriptLine formatted,
CancellationToken cancellationToken)
{
try
{
return await workflowEngine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(artifacts, formatted.Line, formatted.Speaker),
runOptions,
cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Offline transcript line workflow failed for speaker {Speaker}; keeping original transcript line",
formatted.Speaker);
return formatted.Line;
}
}
private async Task TransitionMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions runOptions,
AssistantContextState from,
AssistantContextState to,
CancellationToken cancellationToken)
{
await meetingArtifactStore.UpdateAssistantContextStateAsync(artifacts, to, cancellationToken);
await workflowEngine.RunAsync(
MeetingWorkflowEvent.StateTransition(artifacts, from, to),
runOptions,
cancellationToken);
}
private static async Task WriteRecordingToPipelineAsync(
string audioPath,
ISpeechRecognitionPipeline pipeline,
CancellationToken cancellationToken)
{
await foreach (var chunk in PcmWavAudioChunkReader.ReadChunksAsync(audioPath, cancellationToken: cancellationToken))
{
await pipeline.WriteAsync(chunk, cancellationToken);
}
}
}
@@ -0,0 +1,48 @@
using System.Runtime.CompilerServices;
using NAudio.Wave;
namespace MeetingAssistant.Recording;
public static class PcmWavAudioChunkReader
{
private const int MaxChunkDurationMilliseconds = 1000;
public static async IAsyncEnumerable<AudioChunk> ReadChunksAsync(
string path,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var reader = new WaveFileReader(path);
try
{
var format = reader.WaveFormat;
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
{
throw new InvalidDataException(
$"Only 16-bit PCM WAV files are supported. Found {format.Encoding} with {format.BitsPerSample} bits per sample.");
}
var chunkSize = Math.Max(
format.BlockAlign,
format.AverageBytesPerSecond * MaxChunkDurationMilliseconds / 1000);
chunkSize -= chunkSize % format.BlockAlign;
var buffer = new byte[chunkSize];
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
{
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
}
}
finally
{
try
{
reader.Dispose();
}
catch (NotSupportedException)
{
// NAudio can throw while disposing reader streams from async iterators after all chunks were read.
}
}
}
}
@@ -0,0 +1,86 @@
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Recording;
public interface IRecordingDictationWordProvider
{
Task<IReadOnlyList<string>> ReadOptionalWordsAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken);
}
public sealed class RecordingDictationWordProvider : IRecordingDictationWordProvider
{
private readonly IDictationWordStore dictationWordStore;
private readonly ILogger<RecordingDictationWordProvider> logger;
public RecordingDictationWordProvider(
IDictationWordStore dictationWordStore,
ILogger<RecordingDictationWordProvider> logger)
{
this.dictationWordStore = dictationWordStore;
this.logger = logger;
}
public async Task<IReadOnlyList<string>> ReadOptionalWordsAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
{
var timeout = runOptions.Recording.DictationWordsReadTimeout;
if (timeout <= TimeSpan.Zero)
{
return await ReadWithoutFallbackTimeoutAsync(runOptions, cancellationToken);
}
try
{
using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var readTask = dictationWordStore.ReadWordsAsync(runOptions, readCancellation.Token);
try
{
return await readTask.WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException exception)
{
readCancellation.Cancel();
logger.LogWarning(
exception,
"Reading dictation words timed out after {Timeout}; recording will continue without dictation-word hints",
timeout);
return [];
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not read dictation words; recording will continue without dictation-word hints");
return [];
}
}
private async Task<IReadOnlyList<string>> ReadWithoutFallbackTimeoutAsync(
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
{
try
{
return await dictationWordStore.ReadWordsAsync(runOptions, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not read dictation words; recording will continue without dictation-word hints");
return [];
}
}
}

Some files were not shown because too many files have changed in this diff Show More