[
  {
    "path": ".clabot",
    "content": "{\n  \"contributors\": [],\n  \"message\": \"Thank you for your pull request! Before we can merge it, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/ruzin/stenoai/blob/main/CLA.md).\\n\\n**To sign the CLA, please comment on this PR with:**\\n\\n> I have read the CLA Document and I hereby sign the CLA\\n\\nThis is a one-time process. Once you've signed, all your future contributions to StenoAI will be covered.\",\n  \"label\": \"cla-signed\",\n  \"recheckComment\": \"I have read the CLA Document and I hereby sign the CLA\"\n}\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: ruzin\n\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Description\n\nBrief description of what this PR does and why it's needed.\n\n## Type of Change\n\n- [ ] Bug fix\n- [ ] New feature  \n- [ ] Breaking change\n- [ ] Documentation update\n\n## Testing\n\n- [ ] Tested locally with `npm start`\n- [ ] Verified CLI functionality works\n- [ ] Tested on macOS\n- [ ] No breaking changes to existing functionality\n\n## Additional Notes\n\nAny additional context about this change."
  },
  {
    "path": ".github/workflows/build-release.yml",
    "content": "name: Build and Release DMG\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version number (e.g., 1.0.0)'\n        required: true\n        default: '1.0.0'\n\npermissions:\n  contents: write\n\njobs:\n  build-macos:\n    strategy:\n      matrix:\n        include:\n          - arch: x64\n            build_cmd: build:intel\n            runner: macos-15-intel\n          - arch: arm64\n            build_cmd: build:arm64\n            runner: macos-14\n    runs-on: ${{ matrix.runner }}\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v4\n\n    - name: Setup Python\n      uses: actions/setup-python@v5\n      with:\n        python-version: '3.11'\n\n    - name: Setup Node.js\n      uses: actions/setup-node@v4\n      with:\n        node-version: '22'\n        cache: 'npm'\n        cache-dependency-path: app/package-lock.json\n\n    - name: Get version from package.json\n      id: package_version\n      working-directory: app\n      run: echo \"version=$(node -p \"require('./package.json').version\")\" >> $GITHUB_OUTPUT\n\n    - name: Install Python dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install -r requirements.txt\n        pip install pyinstaller\n\n    - name: Download bundled binaries (Ollama, ffmpeg)\n      run: |\n        chmod +x scripts/download-ollama.sh\n        ./scripts/download-ollama.sh\n\n    - name: Strip ad-hoc code signatures from dylibs (fixes install_name_tool on Intel)\n      if: matrix.arch == 'x64'\n      run: |\n        # pywhispercpp ships pre-signed dylibs that install_name_tool can't modify\n        # Strip signatures so PyInstaller can rewrite library paths\n        find \"$(python -c 'import site; print(site.getsitepackages()[0])')/pywhispercpp\" \\\n          -name \"*.dylib\" -exec codesign --remove-signature {} \\; 2>/dev/null || true\n        echo \"Stripped ad-hoc signatures from pywhispercpp dylibs\"\n\n    - name: Build Python backend with PyInstaller\n      run: |\n        pyinstaller stenoai.spec --noconfirm\n        # Verify build and architecture\n        ls -la dist/stenoai/\n        file dist/stenoai/stenoai\n        echo \"Expected arch: ${{ matrix.arch }}\"\n        echo \"Backend built successfully\"\n\n    - name: Install Electron dependencies\n      working-directory: app\n      run: npm ci\n\n    - name: Import code signing certificate\n      uses: apple-actions/import-codesign-certs@v2\n      with:\n        p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}\n        p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n\n    - name: Build and notarize DMG for ${{ matrix.arch }}\n      working-directory: app\n      run: npm run ${{ matrix.build_cmd }}\n      env:\n        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        APPLE_ID: ${{ secrets.APPLE_ID }}\n        APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}\n        APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}\n\n    - name: Upload build artifacts\n      uses: actions/upload-artifact@v4\n      with:\n        name: build-${{ matrix.arch }}\n        path: |\n          app/dist/*.dmg\n          app/dist/*.zip\n          app/dist/*.yml\n          app/dist/*.yaml\n        retention-days: 1\n\n  release:\n    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n    needs: build-macos\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v4\n      with:\n        fetch-depth: 0\n        fetch-tags: true\n\n    - name: Get version from package.json\n      id: package_version\n      working-directory: app\n      run: echo \"version=$(node -p \"require('./package.json').version\")\" >> $GITHUB_OUTPUT\n\n    - name: Extract annotated tag message\n      id: tag_body\n      if: startsWith(github.ref, 'refs/tags/v')\n      run: |\n        # Pull the body of the annotated tag (strips the PGP signature if any).\n        # Falls back to the commit subject if the tag is lightweight.\n        MSG=$(git for-each-ref --format='%(contents:body)' \"refs/tags/${{ github.ref_name }}\")\n        if [ -z \"$MSG\" ]; then\n          MSG=$(git for-each-ref --format='%(contents:subject)' \"refs/tags/${{ github.ref_name }}\")\n        fi\n        {\n          echo 'text<<STENO_RELEASE_EOF'\n          echo \"$MSG\"\n          echo 'STENO_RELEASE_EOF'\n        } >> \"$GITHUB_OUTPUT\"\n\n    - name: Download all build artifacts\n      uses: actions/download-artifact@v4\n      with:\n        path: artifacts\n\n    - name: Prepare release files\n      run: |\n        mkdir -p release\n        find artifacts -name \"*.dmg\" -exec cp {} release/ \\;\n        find artifacts -name \"*.zip\" -exec cp {} release/ \\;\n        find artifacts -name \"*.yml\" -o -name \"*.yaml\" | xargs -I {} cp {} release/ 2>/dev/null || true\n        ls -la release/\n        # Create website-friendly aliases for DMGs\n        version=\"${{ github.event.inputs.version || steps.package_version.outputs.version }}\"\n        cd release\n        for dmg in *.dmg; do\n          if [[ \"$dmg\" == *\"-x64.dmg\" ]]; then\n            cp \"$dmg\" \"stenoAI-macos-x64.dmg\"\n          elif [[ \"$dmg\" == *\"-arm64.dmg\" ]]; then\n            cp \"$dmg\" \"stenoAI-macos-arm64.dmg\"\n          fi\n        done\n        ls -la\n\n    - name: Create Release with Assets\n      uses: softprops/action-gh-release@v1\n      with:\n        name: StenoAI ${{ github.event.inputs.version || steps.package_version.outputs.version }}\n        body: |\n          ## StenoAI v${{ github.event.inputs.version || steps.package_version.outputs.version }}\n\n          ${{ steps.tag_body.outputs.text }}\n\n          ### Downloads\n\n          - **Apple Silicon (M1-M5)**: `stenoAI-macos-arm64.dmg`\n          - **Intel Macs**: `stenoAI-macos-x64.dmg`\n\n          ### First Time Setup\n          1. Download the appropriate DMG for your Mac\n          2. Install by dragging StenoAI to Applications\n          3. Launch app - setup wizard runs automatically\n          4. Grant microphone permissions when prompted\n          5. Start recording meetings!\n\n          ### Requirements\n          - macOS 10.14 or later\n          - Internet connection for initial setup\n          - Microphone access permissions\n        files: |\n          release/*.dmg\n          release/*.zip\n          release/*.yml\n          release/*.yaml\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/deploy-website.yml",
    "content": "name: Deploy Website to GitHub Pages\n\non:\n  push:\n    branches: [ main ]\n    paths: [ 'website/**' ]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: \"pages\"\n  cancel-in-progress: false\n\njobs:\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    \n    steps:\n    - name: Checkout\n      uses: actions/checkout@v4\n      \n    - name: Setup Node.js\n      uses: actions/setup-node@v4\n      with:\n        node-version: '20'\n        cache: 'npm'\n        cache-dependency-path: website/package-lock.json\n        \n    - name: Install dependencies\n      working-directory: website\n      run: npm ci\n      \n    - name: Build website\n      working-directory: website\n      run: npm run build\n      \n    - name: Setup Pages\n      uses: actions/configure-pages@v4\n      \n    - name: Upload artifact\n      uses: actions/upload-pages-artifact@v3\n      with:\n        path: website/dist\n        \n    - name: Deploy to GitHub Pages\n      id: deployment\n      uses: actions/deploy-pages@v4"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\n!app/build/\napp/build/*\n!app/build/entitlements.mac.plist\n!app/build/icon.icns\n!app/build/icon-dragonfly.icns\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\n!app/renderer/src/lib/\n\n# Superpowers skill scratch — agent working notes, not project content.\ndocs/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n.python-version\n\n# pipenv\nPipfile.lock\n\n# PEP 582\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# StenoAI specific\nrecordings/*.wav\ntranscripts/*.txt\noutput/*.json\nrecorder_state*.json\naudio_buffer.npy\n\n# Node.js\nnode_modules/\napp/node_modules/\ne2e/node_modules\n\n# Playwright\ne2e/test-results/\ne2e/playwright-report/\n\n# IDE\n.vscode/\n.idea/\n.cursor/\n.codex/\n.opencode/\n\n# AI tool configs (user/session-specific)\n.agents/\n.claude/\n.github/skills/\n\n# OS\n.DS_Store\nThumbs.db\n\n# Config with credentials\nconfig/\n*.env\n*_config.json\nconfig.json\n!app/package*.json\n\n# Local documentation and review notes\nCODE_REVIEW.md\nSESSION_LOG.md\nFEATURES.md\n\n# Prompt testing outputs\nprompt_tests/outputs/\n\n# Bundled Ollama binary (downloaded during build)\nbin/\n"
  },
  {
    "path": "CLA.md",
    "content": "# Contributor License Agreement\n\nThank you for your interest in contributing to StenoAI (the \"Project\").\n\nThis Contributor License Agreement (\"Agreement\") documents the rights granted by contributors to the Project maintainer.\n\n## 1. Definitions\n\n\"You\" (or \"Your\") means the copyright owner or legal entity authorized by the copyright owner that is making this Agreement.\n\n\"Contribution\" means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You for inclusion in the Project.\n\n\"Submit\" means any form of communication sent to the Project (including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems).\n\n## 2. Grant of Copyright License\n\nYou hereby grant to the Project maintainer and recipients of software distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:\n\n- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works\n- Sublicense the above rights to third parties\n- **Relicense Your Contributions under different terms**, including but not limited to commercial licenses\n\nThis grant includes the right to distribute Your Contributions under licenses different from the Project's current license (MIT), including proprietary commercial licenses.\n\n## 3. Grant of Patent License\n\nYou hereby grant to the Project maintainer and recipients of software distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions.\n\n## 4. You Retain Ownership\n\nYou retain all right, title, and interest in and to Your Contributions. This Agreement does not transfer ownership; it only grants licenses as described above.\n\n## 5. Your Representations\n\nYou represent that:\n\n- You are legally entitled to grant the above licenses\n- Each of Your Contributions is Your original creation (or You have rights to submit it)\n- Your Contribution submissions include complete details of any third-party licenses or restrictions\n- You will notify the Project if any of the above representations becomes inaccurate\n\n## 6. No Obligation\n\nThe Project maintainer is under no obligation to:\n\n- Accept or include Your Contribution\n- Distribute Your Contribution in any particular version\n- Provide support or maintenance for Your Contribution\n- Release the Project or Your Contribution under any particular license\n\n## 7. Support and Disclaimers\n\nYou provide Your Contributions on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including without limitation any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.\n\n---\n\n**By submitting a Contribution, You accept and agree to the terms and conditions of this Agreement for Your present and future Contributions.**\n\nThis Agreement is effective upon Your first Contribution to the Project.\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nDo not use excessive emojis anywhere.\n\n## Architecture\n\nThe app is a thin Electron shell over a PyInstaller-bundled Python CLI. There is no long-running Python service — every operation is a subprocess invocation.\n\n- **Electron main (`app/main.js`, ~4.3k lines)** owns the UI window, tray, deep-link protocol, and orchestrates everything via `ipcMain.handle(...)`. Handlers shell out to the bundled backend through `getBackendPath()` → `process.resourcesPath/stenoai/stenoai` (or `dist/stenoai/stenoai` in dev) using `child_process.spawn`.\n- **Renderer (`app/renderer/`)** is a Vite-built React + TypeScript SPA. Runs with `contextIsolation: true` and talks to the main process exclusively through the typed bridge in `app/preload.js` → `ipc()` (`app/renderer/src/lib/ipc.ts`). Built output lives at `app/renderer/dist/index.html` and is what Electron loads at runtime.\n- **Python CLI (`simple_recorder.py`, ~2.5k lines, ~46 click commands)** is the single entry point bundled by `stenoai.spec`. Sub-modules in `src/`: `audio_recorder` (sounddevice), `transcriber` (pywhispercpp), `summarizer` (Ollama HTTP client), `ollama_manager` (lifecycle of the bundled `ollama serve`), `config` (JSON-backed user settings + model registry), `folders`, `models`.\n- **State across CLI invocations** is persisted to `recorder_state.json` and similar small JSON files — there is no daemon. Long-running recordings are a `record` subprocess kept alive by the Electron main process.\n- **User data lives in `~/Library/Application Support/stenoai/`** (`recordings/`, `transcripts/`, `output/`), resolved via `src.config.get_data_dirs()`. Repo-root `recordings/`/`transcripts/`/`output/` dirs are dev-only scratch.\n- **Bundled binaries (`bin/`)**: Ollama + ffmpeg, downloaded by `scripts/download-ollama.sh`. PyInstaller copies them into `dist/stenoai/ollama/` and `dist/stenoai/ffmpeg`. Electron then re-bundles `dist/stenoai/` as an `extraResource`.\n- **Deep links**: app registers the `stenoai://` URL scheme. Handler logic is in `app/main.js` near `SHORTCUT_PROTOCOL`. Used by macOS Shortcuts: `stenoai://record/start?name=...` and `stenoai://record/stop`.\n\n## Development Commands\n\n### Backend (Python)\n- Build the bundled backend: `source venv/bin/activate && pyinstaller stenoai.spec --noconfirm`\n- Inspect CLI surface: `dist/stenoai/stenoai --help`\n- Most relevant CLI commands for debugging: `status`, `setup-check`, `list_failed`, `reprocess path/to/summary.json`, `query transcript.txt`, `pipeline filename.wav`\n- Lint: `ruff check .`\n- Run all tests: `python -m unittest discover tests`\n- Run a single test: `python -m unittest tests.test_config.ConfigStoragePathTests.test_set_storage_path_handles_permission_errors`\n\n### Desktop App (Electron)\n- Start app (dev): `cd app && npm start`\n- Build DMG (local, for testing): `cd app && npm run build`\n\nFor setup from a clean checkout, see `CONTRIBUTING.md` and `README.md`.\n\n## Production Readiness\nThis app ships as a signed DMG to real users. Before considering any change complete:\n- **Packaged app test**: Dev mode (`npm start`) is not sufficient. Always rebuild the DMG (`npm run build`) and test the installed app from `/Applications`.\n- **Cold start test**: Kill all background processes (`pkill -f ollama`) and launch the app fresh. The full pipeline (record, transcribe, summarize) must work with no pre-existing services running.\n- **No shelling out to bundled binaries for operations that have an HTTP/library API**. macOS SIP + Electron hardened runtime strips `DYLD_LIBRARY_PATH` from child processes. Use the `ollama` Python package (HTTP API) for model operations, not `subprocess.run([ollama_path, ...])`. The only acceptable use of the Ollama binary is `ollama serve` (starting the server), which is covered by the `com.apple.security.cs.allow-dyld-environment-variables` entitlement.\n- **No bare `exit()` in Python code**. PyInstaller bundles don't have `exit` as a builtin. Always use `sys.exit()`.\n\n## Brand Colors\nStenoAI logo gradient (used in website logo SVG and app header):\n- Indigo: `#6366f1`\n- Sky blue: `#0ea5e9`\n- Cyan: `#06b6d4`\n- CSS: `linear-gradient(135deg, #6366f1, #0ea5e9, #06b6d4)`\n\nApp UI accent: `--accent-primary: #818cf8` (lighter indigo, used for focus states, active tabs, toggles)\n\n## Git Workflow\n- Always create a branch for changes unless explicitly told otherwise\n- Never commit directly to `main`\n- Before creating a PR, run a self-review of the full branch diff (`git diff main...HEAD`):\n  - Review backend code for security issues, error handling gaps, edge cases, and best practices\n  - Review frontend code for layout bugs, CSS consistency, accessibility, and polish\n  - Use the frontend-design skill for UI-related changes\n  - Categorize findings by severity (critical/medium/low) and fix critical issues before merging\n\n## Git Commit Guidelines\n- Do NOT include \"Generated with Claude Code\" attribution in commit messages\n- Do NOT include \"Co-Authored-By: Claude <noreply@anthropic.com>\" in commit messages\n- Keep commit messages concise and focused on what changed\n- Use conventional commit format when appropriate (feat:, fix:, docs:, etc.)\n\n## Release Process\nReleases are automated via `.github/workflows/build-release.yml`. Never create releases manually.\n\n1. Bump version in `app/package.json` (on the branch, before merging)\n2. After PR is merged to `main`, create an **annotated tag** on main with the release notes in the tag message:\n   ```\n   git tag -a v0.2.5 -m \"Release notes here...\"\n   git push origin v0.2.5\n   ```\n3. The tag push triggers the workflow which:\n   - Builds signed + notarized DMGs for both arm64 and x64\n   - Creates a GitHub Release with the tag message as the \"What's New\" section\n   - Uploads both DMGs as release assets\n4. The tag message becomes the release notes body — write it as markdown with a summary of changes\n5. Do NOT build DMGs locally for releases, do NOT use `gh release create` manually\n\n## README \"What's New\" Section\nThe README has a \"What's New\" table that should be updated every ~2 weeks. When asked to update it (or when shipping a notable feature):\n1. Check recently merged PRs: `gh pr list --state merged --limit 10`\n2. For each notable PR, add a row to the table with the merge date and a one-sentence summary\n3. Keep \"Coming soon\" items for features that are planned but not yet shipped\n4. Remove entries older than ~2 months to keep the section fresh\n5. Most recent entries go at the top of the table\n\n## Session Logging\nWhen the user says \"log session\" or similar (e.g., \"update session log\", \"document this session\"):\n1. Update SESSION_LOG.md in the root directory with the current session details\n2. Include: date/time, summary of work, key decisions, files modified, issues resolved, next steps\n3. REPLACE or CONDENSE previous session entries to keep the file concise (max 2-3 most recent sessions)\n4. Keep only relevant context for the next Claude session - remove outdated or completed work details\n5. Format with clear headers and organized sections\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to StenoAI\n\nThank you for your interest in contributing to StenoAI! This guide will help you get started.\n\n## Getting Started\n\n### Prerequisites\n\n- macOS (required for development and testing)\n- Python 3.8+\n- Node.js 18+\n- Git\n\n### Local Development Setup\n\n1. **Fork and clone the repository**\n   ```bash\n   git clone https://github.com/your-username/stenoai.git\n   cd stenoai\n   ```\n\n2. **Set up Python environment**\n   ```bash\n   python3 -m venv venv\n   source venv/bin/activate\n   pip install -r requirements.txt\n   pip install -e .\n   ```\n\n3. **Install system dependencies**\n   ```bash\n   # Install Ollama\n   brew install ollama\n   ollama serve &\n   ollama pull llama3.2:3b\n   \n   # Install ffmpeg\n   brew install ffmpeg\n   ```\n\n4. **Set up Electron app**\n   ```bash\n   cd app\n   npm install\n   npm start\n   ```\n\n5. **Test the setup**\n   ```bash\n   # Test CLI\n   python simple_recorder.py --help\n   \n   # Test app launch\n   cd app && npm start\n   ```\n\n## Development Workflow\n\n### Making Changes\n\n1. **Create a feature branch**\n   ```bash\n   git checkout -b feature/your-feature-name\n   ```\n\n2. **Make your changes**\n   - Follow existing code style and patterns\n   - Test your changes locally\n   - Update documentation if needed\n\n3. **Test your changes**\n   ```bash\n   # Test Python code\n   python simple_recorder.py --help\n   python -c \"import src.audio_recorder, src.transcriber, src.summarizer\"\n   \n   # Test Electron app\n   cd app && npm start\n   ```\n\n4. **Commit and push**\n   ```bash\n   git add .\n   git commit -m \"Add your descriptive commit message\"\n   git push origin feature/your-feature-name\n   ```\n\n5. **Create a Pull Request**\n   - Use the PR template to describe your changes\n   - Focus on clear description and testing details\n   - Be responsive to review feedback\n\n### Code Style\n\n**Python:**\n- Follow PEP 8 guidelines\n- Use type hints where appropriate\n- Write docstrings for functions and classes\n- Use `ruff` for linting: `ruff check .`\n\n**JavaScript:**\n- Use semicolons\n- Use const/let instead of var\n- Follow existing patterns in the codebase\n\n### Testing\n\nBefore submitting a PR, please ensure:\n\n- [ ] CLI functionality works: `python simple_recorder.py --help`\n- [ ] Electron app starts: `cd app && npm start`\n- [ ] No breaking changes to existing functionality\n\n## Versioning\n\nThis project uses manual semantic versioning:\n\n- Maintainers handle version bumps and releases\n- Contributors focus on code quality, not versioning\n- Releases are created manually using `npm version` commands\n\n## Types of Contributions\n\n### Bug Reports\n\nWhen filing a bug report, please include:\n- macOS version\n- Steps to reproduce\n- Expected vs actual behavior\n- Error messages or logs\n- Screenshots if applicable\n\n### Feature Requests\n\nFor feature requests, please:\n- Describe the problem you're trying to solve\n- Explain your proposed solution\n- Consider if this fits the project's scope and vision\n\n### Code Contributions\n\nWe welcome contributions for:\n- Bug fixes\n- Performance improvements\n- New features (please discuss in an issue first)\n- Documentation improvements\n- Test coverage improvements\n\n### Documentation\n\nHelp improve our documentation:\n- Fix typos or unclear instructions\n- Add examples or clarifications\n- Update outdated information\n\n## Project Structure\n\n```\nstenoai/\n├── app/                  # Electron desktop app\n│   ├── main.js          # Main process\n│   ├── preload.js       # Context-isolated IPC bridge\n│   ├── renderer/        # React + Vite renderer (TypeScript)\n│   └── package.json     # App dependencies\n├── src/                  # Python backend\n│   ├── audio_recorder.py    # Audio recording\n│   ├── transcriber.py       # Whisper integration\n│   ├── summarizer.py        # Ollama/LLM processing\n│   └── models.py            # Data models\n├── simple_recorder.py    # CLI interface\n├── requirements.txt      # Python dependencies\n└── CLAUDE.md            # Development instructions\n```\n\n## Getting Help\n\n- Check existing [issues](https://github.com/ruzin/stenoai/issues)\n- Create a new issue for bugs or feature requests\n- Join discussions in the repository\n\n## Contributor License Agreement\n\nBy contributing to StenoAI, you agree to our [Contributor License Agreement (CLA)](CLA.md).\n\n**What this means:**\n- You retain ownership of your contributions\n- You grant us broad, irrevocable rights to use, modify, and relicense your contributions\n- This allows us to offer commercial licenses while keeping the project free for personal use\n\n**How it works:**\n- When you submit your first pull request, CLA Assistant will prompt you to sign\n- Simply comment \"I have read the CLA Document and I hereby sign the CLA\" on the PR\n- This is a one-time process - future contributions are automatically covered\n\nThe project is licensed under the MIT License. See [LICENSE](LICENSE) for details.\n\n## Recognition\n\nContributors will be recognized in our releases and README. Thank you for helping make StenoAI better!"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Skrape Limited\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"website/public/dragonfly-logo-512.png\" alt=\"StenoAI Logo\" width=\"120\" height=\"120\">\n\n  # StenoAI\n\n  *Your private stenographer*\n</div>\n\n<p align=\"center\">\n  <a href=\"https://github.com/ruzin/stenoai/actions/workflows/build-release.yml\"><img src=\"https://img.shields.io/github/actions/workflow/status/ruzin/stenoai/build-release.yml?branch=main&style=for-the-badge\" alt=\"Build\"></a>\n  <a href=\"https://github.com/ruzin/stenoai/releases\"><img src=\"https://img.shields.io/github/v/release/ruzin/stenoai?style=for-the-badge\" alt=\"Release\"></a>\n  <a href=\"https://discord.gg/DZ6vcQnxxu\"><img src=\"https://img.shields.io/badge/Discord-Join%20Server-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  <a href=\"LICENSE\"><img src=\"https://img.shields.io/badge/License-MIT-blue?style=for-the-badge\" alt=\"License\"></a>\n  <img src=\"https://img.shields.io/badge/Platform-macOS-000000?style=for-the-badge&logo=apple&logoColor=white\" alt=\"macOS\">\n  <a href=\"#sponsors\"><img src=\"https://img.shields.io/badge/Sponsors-%E2%9D%A4-EA4AAA?style=for-the-badge\" alt=\"Sponsors\"></a>\n</p>\n\n<p align=\"center\">AI-powered meeting intelligence that runs entirely on your device, your private data never leaves anywhere. Record, transcribe, summarize, and query your meetings using local AI models. Perfect for healthcare, legal and finance professionals with confidential data needs.</p>\n\n<p align=\"center\"><sub>Trusted by users at <b>AWS</b>, <b>Deliveroo</b>, <b>Tesco</b> & <b>HashiCorp</b>.</sub></p>\n\n<div align=\"center\">\n  <img src=\"website/public/readme.png\" alt=\"StenoAI Interface\" width=\"800\">\n\n  <br>\n\n  [![Twitter Follow](https://img.shields.io/twitter/follow/ruzin?style=social)](https://x.com/ruzin_saleem)\n</div>\n\n<p align=\"center\"><sub><i>Disclaimer: This is an independent open-source project for meeting-notes productivity and is not affiliated with, endorsed by, or associated with any similarly named company.</i></sub></p>\n\n## Sponsors\n\n### Recall.ai - API for desktop recording\n\nIf you're looking for a hosted desktop recording API, consider checking out [Recall.ai](https://www.recall.ai/product/desktop-recording-sdk?utm_source=github&utm_medium=sponsorship&utm_campaign=ruzin-stenoai), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.\n\n## 📢 What's New\n- **2026-04-19** 🔄 In-app auto-updates — Updates download in the background and install on next quit; no more manual DMG downloads\n- **2026-04-19** 💬 Inline ask bar — Query your meetings from a floating bar at the bottom of every note\n- **2026-04-19** 📂 Ask against saved markdown — The ask bar now reads your saved `.md` notes directly (summary, topics, and full transcript)\n- **2026-04-19** 📝 Diarised markdown export — Saved transcripts include `[You]` / `[Others]` speaker labels\n- **2026-03-25** ✍️ In-app note-taking — Jot notes during a recording and they're folded into the AI summary\n- **2026-03-23** 🗣️ Speaker diarisation — [You] vs [Others] labels for system audio recordings\n- **2026-03-23** 🌍 Auto-detect language — 99 languages supported out of the box\n- **2026-03-04** 🏷️ Auto-generated meeting titles — AI creates short titles from your transcripts\n\n## Features\n\n- **Privacy-first** — 100% on-device; your recordings, transcripts, and summaries never leave your Mac\n- **In-app note-taking** — Jot notes while you record; they're folded straight into the AI summary\n- **Ask your meetings** — Natural-language Q&A across any saved note, including summary, key topics, and full transcript\n- **System audio capture** — Record both sides of virtual meetings, headphones on, no extra setup\n- **Speaker diarisation** — `[You]` vs `[Others]` labels on system-audio recordings\n- **Multi-language** — Auto-detect and transcribe in 99 languages\n- **Markdown notes** — Summaries and transcripts saved as clean Markdown you can edit, search, or sync\n- **Remote Ollama server** — Offload summarisation to a beefier Mac or workstation on your network\n- **Bring your own cloud model** — Optional OpenAI, Anthropic, or custom API endpoint for users who prefer a hosted LLM\n- **Under the hood** — Local transcription via whisper.cpp, summarisation via bundled Ollama (5 models to choose from)\n\n## macOS Shortcuts (Optional)\n\n<details>\n<summary>Expand setup and calendar automation guide</summary>\n\nStenoAI supports Apple Shortcuts via deep links using the `stenoai://` URL scheme.\n\n- Start recording: `stenoai://record/start?name=Daily%20Standup`\n- Stop recording: `stenoai://record/stop`\n\n### How to set it up\n\n1. Open the **Shortcuts** app on macOS.\n2. Create a new shortcut (for example: \"Start StenoAI Recording\").\n3. Add the **Open URLs** action.\n4. Use one of the URLs above.\n5. (Optional) Add a keyboard shortcut from the shortcut settings.\n\n### Calendar event naming (optional)\n\nIf you want calendar-based names, resolve the event title in your Shortcut workflow and pass it as the `name` query value in the start URL.\n\nExample:\n\n`stenoai://record/start?name=Weekly%20Product%20Sync`\n\n### Calendar event start automation (via Rules bridge)\n\nmacOS Shortcuts **cannot natively trigger** exactly at Calendar event start.  \nTo run this automatically on event timing, a third-party automation app is required.\n\nThis addon uses:\n\n- **Apple Shortcuts**: builds the `stenoai://record/start?...` action.\n- **Rules – Calendar Automation**: watches Calendar events and triggers the shortcut.\n\n#### Architecture overview\n\n1. Rules App monitors upcoming Calendar events.\n2. Rules checks the event note/body for a marker keyword (for example `stenoai`).\n3. If matched, Rules runs a Shortcut.\n4. The Shortcut gets the next event title and opens:\n   - `stenoai://record/start?name={calendar_event_title}`\n5. StenoAI receives the URL and starts recording with that name.\n\n#### Step-by-step setup\n\n1. Install **Rules – Calendar Automation** on macOS.\n2. Create a Shortcut in Apple Shortcuts (example name: `StenoAI Start From Calendar Event`).\n3. In that Shortcut, add actions in this order:\n   - `Find Calendar Events` (limit to `1`, sorted by start date ascending, upcoming only)\n   - Extract the event title from the found event\n   - `URL Encode` the title\n   - `Open URLs` with:\n     - `stenoai://record/start?name=<encoded title>`\n4. Open Rules and create a calendar-trigger rule:\n   - Source: your target calendar(s)\n   - Trigger window: event start (or preferred offset)\n   - Condition: event note contains `stenoai`\n   - Action: run Shortcut `StenoAI Start From Calendar`\n5. In your Calendar event notes, add the word `stenoai` for meetings that should auto-start recording.\n6. Test with a near-future event:\n   - create event with `stenoai` in notes,\n   - wait for trigger,\n   - confirm StenoAI starts and uses the event title as session name.\n\n#### Notes\n\n- Without Rules (or another automation bridge), this cannot be fully event-driven from Calendar start time.\n- Keep using regular manual shortcuts (`Open URLs`) for non-automated scenarios.\n\nHave questions or suggestions? [Join our Discord](https://discord.gg/DZ6vcQnxxu) to chat with the community.\n</details>\n\n## Models & Performance\n\n**Transcription Models** (Whisper):\n- `small`: Default model - good accuracy and speed on Apple Silicon **(default)**\n- `base`: Faster but lower accuracy for basic meetings\n- `medium`: High accuracy for important meetings (slower)\n\n**Summarization Models** (Ollama):\n- `llama3.2:3b` (2GB): Fast and lightweight for quick meetings **(default)**\n- `gemma3:4b` (2.5GB): Lightweight and efficient\n- `qwen3.5:9b` (6.6GB): Excellent at structured output and action items\n- `deepseek-r1:14b` (9.0GB): Strong reasoning and analysis capabilities\n- `gpt-oss:20b` (14GB): OpenAI open-weight model with reasoning capabilities\n\n## Future Roadmap\n\n### Enhanced Features\n- Live transcription during recording\n- NVIDIA Parakeet as a transcription engine option\n- Editing notes after processing\n- Windows version\n\n## Installation\n\nDownload the latest release for your Mac (**requires macOS 14 Sonoma or later**):\n\n- [Apple Silicon (M1-M5)](https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-arm64.dmg)\n- [Intel Macs](https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-x64.dmg) Performance on Intel Macs is limited due to lack of dedicated AI inference capabilities on these older chips.\n\n### Installing on macOS\n\n1. **Download and open the DMG file**\n2. **Drag the app to Applications**\n3. **When you first launch the app**, macOS may show a security warning\n4. **To fix this warning:**\n   - Go to **System Settings > Privacy & Security** and click **\"Open Anyway\"**\n\n   **Alternatively:**\n   - Right-click StenoAI in Applications and select **\"Open\"**\n   - Or run in Terminal: `xattr -cr /Applications/StenoAI.app`\n5. **The app will work normally on subsequent launches**\n\nYou can run it locally as well (see below) if you don't want to install a DMG.\n\n## Local Development/Use Locally\n\n### Prerequisites\n- Python 3.9+\n- Node.js 18+\n\n### Setup\n```bash\ngit clone https://github.com/ruzin/stenoai.git\ncd stenoai\n\n# Backend setup\npython3 -m venv venv\nsource venv/bin/activate\npip install -r requirements.txt\n\n# Download bundled binaries (Ollama, ffmpeg)\n./scripts/download-ollama.sh\n\n# Build the Python backend\npip install pyinstaller\npyinstaller stenoai.spec --noconfirm\n\n# Frontend\ncd app\nnpm install\nnpm start\n```\n\nNote: Ollama and ffmpeg are bundled - no system installation needed. The setup wizard in the app will download the required AI models automatically.\n\n### Build\n```bash\ncd app\nnpm run build\n```\n\n## Project Structure\n\n```\nstenoai/\n├── app/                  # Electron desktop app\n├── src/                  # Python backend\n├── website/              # Marketing site\n├── recordings/           # Audio files\n├── transcripts/          # Text output\n└── output/              # Summaries\n```\n\n## Troubleshooting\n\n### Debug Logs\n\n**Setup wizard debug console:** during first-time setup, expand the debug console panel to see real-time logs of model downloads and service startup.\n\n**Terminal logging (recommended for runtime issues):** launch the app from a terminal to stream all logs (Python subprocess output, Whisper transcription, Ollama API traffic, error stack traces):\n```bash\n/Applications/StenoAI.app/Contents/MacOS/StenoAI\n```\n\n**System Console:**\n```bash\n# View recent StenoAI-related logs\nlog show --last 10m --predicate 'process CONTAINS \"StenoAI\" OR eventMessage CONTAINS \"ollama\"' --info\n\n# Monitor live logs\nlog stream --predicate 'eventMessage CONTAINS \"ollama\" OR process CONTAINS \"StenoAI\"' --level info\n```\n\n### Common Issues\n\n- **Update didn't install**: Auto-updates are applied on next quit. Quit via the **StenoAI → Quit** menu (not just closing the window), then reopen.\n- **No system audio / no `[Others]` speaker labels**: macOS needs **Screen Recording** permission. Go to **System Settings → Privacy & Security → Screen & System Audio Recording**, enable StenoAI, and relaunch the app.\n- **`stenoai://` deep link doesn't start recording**: Make sure StenoAI has launched at least once after install so the URL scheme is registered. If it still fails, check the terminal log for `Protocol handler registration` output.\n- **Recording stops early**: Check microphone permissions, Screen Recording permission (if using system audio), and available disk space.\n- **\"Processing failed\"**: Usually an Ollama service or model issue — check the terminal logs.\n- **Empty transcripts**: Whisper couldn't detect speech — verify audio input levels.\n- **Slow processing**: Normal for longer recordings; Ollama is CPU-intensive, especially on older Intel Macs.\n\n### Logs Location\n- **User Data**: `~/Library/Application Support/stenoai/`\n- **Recordings**: `~/Library/Application Support/stenoai/recordings/`\n- **Transcripts**: `~/Library/Application Support/stenoai/transcripts/`\n- **Summaries**: `~/Library/Application Support/stenoai/output/`\n\n## License\n\nThis project is licensed under the [MIT License](LICENSE).\n"
  },
  {
    "path": "announcements.json",
    "content": "{\n  \"announcements\": []\n}\n"
  },
  {
    "path": "app/build/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<!-- Audio recording permission -->\n\t<key>com.apple.security.device.audio-input</key>\n\t<true/>\n\n\t<!-- Network access for API calls (Ollama, etc.) -->\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\n\t<!-- File access for recordings/transcripts -->\n\t<key>com.apple.security.files.user-selected.read-write</key>\n\t<true/>\n\n\t<!-- Application Support folder access -->\n\t<key>com.apple.security.files.bookmarks.app-scope</key>\n\t<true/>\n\n\t<!-- Allow JIT compilation for Python runtime -->\n\t<key>com.apple.security.cs.allow-jit</key>\n\t<true/>\n\n\t<!-- Allow unsigned executable memory (for Python/Whisper/Ollama) -->\n\t<key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n\t<true/>\n\n\t<!-- Disable library validation (for Python dependencies) -->\n\t<key>com.apple.security.cs.disable-library-validation</key>\n\t<true/>\n\n\t<!-- Allow DYLD environment variables (for bundled Ollama dylibs) -->\n\t<key>com.apple.security.cs.allow-dyld-environment-variables</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/electron-builder.ci.yml",
    "content": "# electron-builder config for CI dry-runs (pack:unsigned).\n#\n# Inherits the base \"build\" config from package.json, but strips:\n#   - extraResources (../dist/stenoai is not built in CI — no PyInstaller)\n#   - afterSign hook (no notarization in unsigned packs)\n#   - DMG signing (no cert in CI)\n#\n# The resulting .app is NOT runnable: it has no Python backend, is unsigned,\n# and is not notarized. This config exists solely to prove the Vite output +\n# preload.js + main.js package correctly on every PR, before release time.\n# Release builds continue to use the package.json config unchanged.\n\nextraResources:\n  - from: assets\n    to: assets\n    filter:\n      - trayIcon*.png\n\ndmg:\n  sign: false\n\nmac:\n  identity: null\n  notarize: false\n"
  },
  {
    "path": "app/main.js",
    "content": "const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, safeStorage, Tray, Menu, nativeImage, Notification } = require('electron');\n\n// Prevent EPIPE crashes when stdout/stderr pipe is broken (e.g. launching terminal closed)\nprocess.stdout?.on('error', () => {});\nprocess.stderr?.on('error', () => {});\nconst path = require('path');\nconst { spawn, exec } = require('child_process');\nconst fs = require('fs');\nconst https = require('https');\nconst http = require('http');\nconst os = require('os');\nconst { URL, URLSearchParams } = require('url');\nconst crypto = require('crypto');\nconst { PostHog } = require('posthog-node');\nconst { initMain } = require('electron-audio-loopback');\nconst { autoUpdater } = require('electron-updater');\n\n// E2E test-harness hooks. Set via env vars; production sees none of these.\n//   STENOAI_USER_DATA_DIR — per-test temp userData dir (must be set before app.whenReady)\n//   STENOAI_E2E=1         — skip tray, auto-updater, PostHog telemetry\n//   STENOAI_E2E_MOCK_IPC=1 — install deterministic mock IPC handlers\nif (process.env.STENOAI_USER_DATA_DIR) {\n  app.setPath('userData', process.env.STENOAI_USER_DATA_DIR);\n}\nconst IS_E2E = process.env.STENOAI_E2E === '1';\nconst IS_E2E_MOCK_IPC = process.env.STENOAI_E2E_MOCK_IPC === '1';\nif (IS_E2E_MOCK_IPC) {\n  require('./e2e-mock-ipc').install({ ipcMain, BrowserWindow });\n}\n\n// Initialize electron-audio-loopback before app is ready\ninitMain();\n\nlet mainWindow;\nlet pythonProcess;\nlet tray = null;\nlet isQuitting = false;\n// true once the window has been shown for the first time (React mounted).\n// Prevents activate/focus handlers from showing the window before it's ready.\nlet windowReadyToShow = false;\nlet shortcutQueue = [];\nlet pendingShortcutUrls = [];\nlet rendererShortcutReady = false;\nlet launchedByShortcut = false;\n\nconst SHORTCUT_PROTOCOL = 'stenoai';\nconst SHORTCUT_HOST = 'record';\nconst SHORTCUT_SESSION_NAME_MAX_LENGTH = 120;\nconst gotSingleInstanceLock = app.requestSingleInstanceLock();\n\nfunction extractShortcutUrlFromArgv(argv = []) {\n  return argv.find(arg => typeof arg === 'string' && arg.startsWith(`${SHORTCUT_PROTOCOL}://`));\n}\n\nfunction sanitizeShortcutUrlForLogs(incomingUrl) {\n  try {\n    const parsed = new URL(incomingUrl);\n    return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`;\n  } catch (error) {\n    return '[invalid-shortcut-url]';\n  }\n}\n\nfunction sanitizeShortcutSessionName(rawValue) {\n  if (typeof rawValue !== 'string') {\n    return null;\n  }\n\n  // Keep user-visible names readable while stripping unsupported characters.\n  // Preserve Unicode letters (including diacritics) and common punctuation.\n  const sanitized = rawValue\n    .replace(/[^\\p{L}\\p{M}\\p{N}_\\s.,()@&'!+#-]/gu, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim()\n    .slice(0, SHORTCUT_SESSION_NAME_MAX_LENGTH);\n\n  return sanitized || null;\n}\n\nfunction registerShortcutProtocolClient() {\n  if (process.platform !== 'darwin') {\n    return false;\n  }\n\n  // In development (electron .), macOS protocol registration needs executable + app args.\n  if (!app.isPackaged) {\n    return app.setAsDefaultProtocolClient(\n      SHORTCUT_PROTOCOL,\n      process.execPath,\n      [path.resolve(process.argv[1])]\n    );\n  }\n\n  return app.setAsDefaultProtocolClient(SHORTCUT_PROTOCOL);\n}\n\n// Backend executable path - always use bundled stenoai\nfunction getBackendPath() {\n  if (app.isPackaged) {\n    // Production: bundled in app resources\n    return path.join(process.resourcesPath, 'stenoai', 'stenoai');\n  } else {\n    // Development: use local build\n    return path.join(__dirname, '..', 'dist', 'stenoai', 'stenoai');\n  }\n}\n\nfunction getBackendCwd() {\n  if (app.isPackaged) {\n    return path.join(process.resourcesPath, 'stenoai');\n  } else {\n    return path.join(__dirname, '..', 'dist', 'stenoai');\n  }\n}\n\nfunction parseShortcutUrl(incomingUrl) {\n  try {\n    const parsed = new URL(incomingUrl);\n    if (parsed.protocol !== `${SHORTCUT_PROTOCOL}:`) {\n      return { type: 'invalid', reason: 'invalid-protocol' };\n    }\n\n    if (parsed.hostname !== SHORTCUT_HOST) {\n      return { type: 'invalid', reason: 'invalid-host' };\n    }\n\n    const cleanPath = (parsed.pathname || '').replace(/\\/+$/, '');\n    if (cleanPath === '/start') {\n      const sessionName = sanitizeShortcutSessionName(parsed.searchParams.get('name') || '');\n      return {\n        type: 'start',\n        sessionName\n      };\n    }\n\n    if (cleanPath === '/stop') {\n      return { type: 'stop' };\n    }\n\n    return { type: 'invalid', reason: 'invalid-path' };\n  } catch (error) {\n    return { type: 'invalid', reason: 'parse-error' };\n  }\n}\n\nfunction ensureMainWindow() {\n  if (!app.isReady()) {\n    sendDebugLog('Shortcut action received before app ready; deferring window creation');\n    return false;\n  }\n\n  if (!mainWindow || mainWindow.isDestroyed()) {\n    createWindow();\n  }\n\n  return true;\n}\n\nfunction dispatchShortcutAction(action) {\n  if (!mainWindow || mainWindow.isDestroyed()) {\n    return false;\n  }\n\n  if (action.type === 'start') {\n    mainWindow.webContents.send('shortcut-start-recording', {\n      sessionName: action.sessionName || null\n    });\n    launchedByShortcut = false;\n    return true;\n  }\n\n  if (action.type === 'stop') {\n    mainWindow.webContents.send('shortcut-stop-recording');\n    launchedByShortcut = false;\n    return true;\n  }\n\n  return false;\n}\n\nfunction flushShortcutQueue() {\n  if (!rendererShortcutReady || !mainWindow || mainWindow.isDestroyed()) {\n    return;\n  }\n\n  while (shortcutQueue.length > 0) {\n    const nextAction = shortcutQueue.shift();\n    const dispatched = dispatchShortcutAction(nextAction);\n    if (!dispatched) {\n      shortcutQueue.unshift(nextAction);\n      break;\n    }\n  }\n}\n\nfunction enqueueShortcutAction(action) {\n  if (shortcutQueue.length >= 5) {\n    sendDebugLog('Shortcut queue overflow, dropping oldest action');\n    shortcutQueue.shift();\n  }\n  shortcutQueue.push(action);\n  flushShortcutQueue();\n}\n\nasync function shouldShowShortcutNotifications() {\n  try {\n    const settings = await handleGetNotifications();\n    if (!settings.success) {\n      return true;\n    }\n    return settings.notifications_enabled !== false;\n  } catch (error) {\n    return true;\n  }\n}\n\nasync function showShortcutNotification(body) {\n  if (process.platform !== 'darwin') {\n    return;\n  }\n\n  try {\n    const enabled = await shouldShowShortcutNotifications();\n    if (!enabled || !Notification.isSupported()) {\n      return;\n    }\n\n    new Notification({\n      title: 'StenoAI Shortcuts',\n      body\n    }).show();\n  } catch (error) {\n    console.error('Failed to show shortcut notification:', error.message);\n  }\n}\n\nconst BACKEND_STATUS_RETRY_ATTEMPTS = 3;\nconst BACKEND_STATUS_RETRY_DELAY_MS = 250;\n\nfunction wait(ms) {\n  return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nasync function isBackendRecording() {\n  for (let attempt = 1; attempt <= BACKEND_STATUS_RETRY_ATTEMPTS; attempt += 1) {\n    try {\n      const status = await handleGetStatus();\n      if (status.success) {\n        return status.status.includes('STATUS: RECORDING');\n      }\n    } catch (error) {\n      if (attempt === BACKEND_STATUS_RETRY_ATTEMPTS) {\n        console.error('Error checking recording status for shortcut action:', error.message);\n      }\n    }\n\n    if (attempt < BACKEND_STATUS_RETRY_ATTEMPTS) {\n      await wait(BACKEND_STATUS_RETRY_DELAY_MS);\n    }\n  }\n\n  console.warn('Backend status unavailable after retries; assuming not recording for shortcut action');\n  return false;\n}\n\nasync function handleShortcutUrl(incomingUrl) {\n  const parsedAction = parseShortcutUrl(incomingUrl);\n  const safeShortcutUrl = sanitizeShortcutUrlForLogs(incomingUrl);\n\n  if (parsedAction.type === 'invalid') {\n    sendDebugLog(`Ignored invalid shortcut URL (${parsedAction.reason}): ${safeShortcutUrl}`);\n    await showShortcutNotification('Invalid shortcut URL');\n    launchedByShortcut = false;\n    return;\n  }\n\n  const backendRecording = await isBackendRecording();\n  const recording = backendRecording || systemAudioRecordingActive;\n\n  if (parsedAction.type === 'start') {\n    if (recording) {\n      await showShortcutNotification('Recording already in progress');\n      launchedByShortcut = false;\n      return;\n    }\n\n    if (!ensureMainWindow()) {\n      launchedByShortcut = true;\n      pendingShortcutUrls.push(incomingUrl);\n      return;\n    }\n    enqueueShortcutAction(parsedAction);\n    await showShortcutNotification('Start recording requested');\n    return;\n  }\n\n  if (!recording) {\n    await showShortcutNotification('Recording already stopped');\n    launchedByShortcut = false;\n    return;\n  }\n\n  if (!ensureMainWindow()) {\n    launchedByShortcut = true;\n    pendingShortcutUrls.push(incomingUrl);\n    return;\n  }\n  enqueueShortcutAction(parsedAction);\n  await showShortcutNotification('Stop recording requested');\n}\n\n// Telemetry state\nlet posthogClient = null;\nlet telemetryEnabled = false;\nlet anonymousId = null;\n\nconst POSTHOG_API_KEY = 'phc_U2cnTyIyKGNSVaK18FyBMltd8nmN7uHxhhm21fAHwqb';\nconst POSTHOG_HOST = 'https://us.i.posthog.com';\n\n// Google Calendar OAuth2 configuration\nconst GOOGLE_CLIENT_ID = '281073275073-20da4u5t9luk2366vd5ai0a2r55d5pf5.apps.googleusercontent.com';\nconst GOOGLE_CLIENT_SECRET = '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';\nconst GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar.readonly';\nconst GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';\nconst GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';\n\n// Outlook Calendar OAuth2 configuration (PKCE public client — no client secret)\nconst OUTLOOK_CLIENT_ID = '53a8ba1f-3a2e-4fc9-afb1-b9b8ff13de19';\nconst OUTLOOK_SCOPES = 'Calendars.Read offline_access';\nconst OUTLOOK_AUTH_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize';\nconst OUTLOOK_TOKEN_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';\n\n/**\n * Return a privacy-safe duration bucket string.\n */\nfunction durationBucket(seconds) {\n  if (seconds < 60) return '<1m';\n  if (seconds < 300) return '1-5m';\n  if (seconds < 900) return '5-15m';\n  if (seconds < 1800) return '15-30m';\n  if (seconds < 3600) return '30-60m';\n  return '60m+';\n}\n\n/**\n * Initialize PostHog telemetry by reading config from Python backend.\n */\nasync function initTelemetry() {\n  if (IS_E2E) {\n    telemetryEnabled = false;\n    return;\n  }\n  try {\n    const result = await new Promise((resolve, reject) => {\n      const proc = spawn(getBackendPath(), ['get-telemetry'], {\n        cwd: getBackendCwd()\n      });\n      let stdout = '';\n      proc.stdout.on('data', (data) => { stdout += data.toString(); });\n      proc.on('close', (code) => {\n        if (code === 0) resolve(stdout);\n        else reject(new Error(`get-telemetry exited with code ${code}`));\n      });\n      proc.on('error', reject);\n    });\n\n    const config = JSON.parse(result.trim());\n    telemetryEnabled = config.telemetry_enabled;\n    anonymousId = config.anonymous_id;\n\n    if (telemetryEnabled) {\n      posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST });\n      // Identify user for DAU tracking\n      posthogClient.identify({\n        distinctId: anonymousId,\n        properties: {\n          platform: process.platform,\n          arch: process.arch\n        }\n      });\n      console.log('Telemetry initialized (anonymous analytics enabled)');\n    } else {\n      console.log('Telemetry disabled by user preference');\n    }\n  } catch (error) {\n    console.error('Failed to initialize telemetry:', error.message);\n    telemetryEnabled = false;\n  }\n}\n\n/**\n * Track an analytics event. Silent fail -- never throws.\n */\nfunction trackEvent(eventName, properties = {}) {\n  try {\n    if (!telemetryEnabled || !posthogClient || !anonymousId) return;\n\n    const packagePath = path.join(__dirname, 'package.json');\n    const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));\n\n    posthogClient.capture({\n      distinctId: anonymousId,\n      event: eventName,\n      properties: {\n        app_version: packageContent.version,\n        platform: process.platform,\n        arch: process.arch,\n        ...properties\n      }\n    });\n  } catch (error) {\n    // Silent fail -- telemetry must never break the app\n  }\n}\n\n/**\n * Flush and shut down the PostHog client.\n */\nasync function shutdownTelemetry() {\n  try {\n    if (posthogClient) {\n      await posthogClient.shutdown();\n      posthogClient = null;\n      console.log('Telemetry shut down');\n    }\n  } catch (error) {\n    // Silent fail\n  }\n}\n\n/**\n * Get the list of allowed base directories, including any custom storage path.\n */\nlet _cachedCustomStoragePath = null;\nfunction getAllowedBaseDirs() {\n  const projectRoot = path.join(__dirname, '..');\n  const dirs = [\n    projectRoot,\n    path.join(os.homedir(), 'Library', 'Application Support', 'stenoai')\n  ];\n  if (_cachedCustomStoragePath) {\n    dirs.push(_cachedCustomStoragePath);\n  }\n  return dirs;\n}\n\n/**\n * Validate that a file path is within allowed directories (security)\n * Prevents path traversal attacks by ensuring files are only accessed\n * within the app's designated data directories\n */\nfunction validateSafeFilePath(filepath, allowedBaseDirs) {\n  if (!filepath) return false;\n\n  try {\n    // Resolve to absolute path and normalize\n    const resolvedPath = path.resolve(filepath);\n\n    // Ensure it's within one of the allowed base directories\n    for (const baseDir of allowedBaseDirs) {\n      const resolvedBase = path.resolve(baseDir);\n      if (resolvedPath.startsWith(resolvedBase + path.sep) || resolvedPath === resolvedBase) {\n        return true;\n      }\n    }\n\n    return false;\n  } catch (error) {\n    console.error('Error validating file path:', error);\n    return false;\n  }\n}\n\nfunction createWindow(options = {}) {\n  rendererShortcutReady = false;\n\n  const windowOpts = {\n    width: 1200,\n    height: 800,\n    minWidth: 1000,\n    minHeight: 600,\n    webPreferences: {\n      nodeIntegration: false,\n      contextIsolation: true,\n      sandbox: false,\n      preload: path.join(__dirname, 'preload.js'),\n      scrollBounce: true,\n    },\n    titleBarStyle: 'hiddenInset',\n    show: false,\n    backgroundColor: '#FAF9F5',\n    // React UI renders the macOS traffic lights inside the sidebar's top\n    // band rather than floating above a fixed titlebar.\n    trafficLightPosition: { x: 18, y: 18 },\n  };\n  if (options.bounds && typeof options.bounds.x === 'number') {\n    Object.assign(windowOpts, options.bounds);\n  }\n\n  mainWindow = new BrowserWindow(windowOpts);\n\n  const rendererDist = path.join(__dirname, 'renderer', 'dist', 'index.html');\n  const hash = process.env.STENOAI_RENDERER_HASH;\n  if (hash) {\n    mainWindow.loadFile(rendererDist, { hash });\n  } else {\n    mainWindow.loadFile(rendererDist);\n  }\n\n  windowReadyToShow = false;\n\n  const showWhenReady = () => {\n    windowReadyToShow = true;\n    if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {\n      mainWindow.show();\n    }\n  };\n\n  mainWindow.once('ready-to-show', () => {\n    if (launchedByShortcut) {\n      return;\n    }\n    // Wait until React signals it has mounted. Fall back to showing after\n    // 4s in case the signal never arrives.\n    const fallback = setTimeout(showWhenReady, 4000);\n    ipcMain.once('renderer-ready-to-show', () => {\n      clearTimeout(fallback);\n      showWhenReady();\n    });\n  });\n\n\n\n  // On macOS, hide to tray instead of destroying (like Slack, Spotify)\n  mainWindow.on('close', (event) => {\n    if (process.platform === 'darwin' && !isQuitting) {\n      event.preventDefault();\n      mainWindow.hide();\n    }\n  });\n\n  mainWindow.on('closed', () => {\n    mainWindow = null;\n    rendererShortcutReady = false;\n    if (pythonProcess) {\n      pythonProcess.kill();\n    }\n  });\n}\n\nfunction getTrayIconPath(recording) {\n  const iconName = recording ? 'trayIconRecordingTemplate' : 'trayIconTemplate';\n  if (app.isPackaged) {\n    return path.join(process.resourcesPath, 'assets', `${iconName}.png`);\n  }\n  return path.join(__dirname, 'assets', `${iconName}.png`);\n}\n\nfunction createTray() {\n  const icon = nativeImage.createFromPath(getTrayIconPath(false));\n  icon.setTemplateImage(true);\n  tray = new Tray(icon);\n  tray.setToolTip('StenoAI');\n\n  updateTrayMenu();\n}\n\nfunction updateTrayIcon(recording) {\n  if (!tray) return;\n  const icon = nativeImage.createFromPath(getTrayIconPath(recording));\n  icon.setTemplateImage(true);\n  tray.setImage(icon);\n  tray.setToolTip(recording ? 'StenoAI - Recording' : 'StenoAI');\n  updateTrayMenu();\n}\n\nfunction showAndFocusWindow() {\n  if (mainWindow) {\n    mainWindow.show();\n    mainWindow.focus();\n  }\n}\n\nfunction updateTrayMenu() {\n  if (!tray) return;\n  const isRecording = currentRecordingProcess !== null || systemAudioRecordingActive;\n\n  const appVersion = require('./package.json').version;\n\n  const contextMenu = Menu.buildFromTemplate([\n    {\n      label: 'Open StenoAI',\n      click: showAndFocusWindow\n    },\n    {\n      label: isRecording ? 'Stop Recording' : 'Start Recording',\n      click: () => {\n        if (mainWindow) {\n          mainWindow.webContents.send(isRecording ? 'tray-stop-recording' : 'tray-start-recording');\n        }\n      }\n    },\n    {\n      label: 'Settings',\n      click: () => {\n        showAndFocusWindow();\n        if (mainWindow) {\n          mainWindow.webContents.send('tray-open-settings');\n        }\n      }\n    },\n    {\n      label: 'Hide StenoAI',\n      click: () => {\n        if (mainWindow) mainWindow.hide();\n      }\n    },\n    { type: 'separator' },\n    {\n      label: `StenoAI v${appVersion}`,\n      enabled: false\n    },\n    {\n      label: 'Report a Bug',\n      click: () => {\n        shell.openExternal('https://discord.gg/DZ6vcQnxxu');\n      }\n    },\n    { type: 'separator' },\n    {\n      label: 'Quit StenoAI',\n      click: () => {\n        app.quit();\n      }\n    }\n  ]);\n\n  tray.setContextMenu(contextMenu);\n}\n\nif (!gotSingleInstanceLock) {\n  app.quit();\n} else {\n  app.on('second-instance', (event, argv) => {\n    const shortcutUrl = extractShortcutUrlFromArgv(argv);\n    if (shortcutUrl) {\n      if (app.isReady()) {\n        handleShortcutUrl(shortcutUrl).catch(err => {\n          sendDebugLog(`Error handling shortcut URL: ${err.message}`);\n        });\n      } else {\n        launchedByShortcut = true;\n        pendingShortcutUrls.push(shortcutUrl);\n      }\n    }\n\n    if (mainWindow && !mainWindow.isDestroyed()) {\n      if (mainWindow.isMinimized()) mainWindow.restore();\n      mainWindow.show();\n      mainWindow.focus();\n    }\n  });\n\n  // Sends the custom in-app quit dialog to the renderer and waits for a response.\n  // Falls back to true (allow quit) if the window is unavailable. A 5s timeout\n  // guards against a wedged React tree — on timeout we resolve false to\n  // preserve any active recording rather than killing it silently.\n  async function showCustomQuitDialog(type, jobCount) {\n    if (!mainWindow || mainWindow.isDestroyed()) return true;\n    mainWindow.show();\n    mainWindow.focus();\n    mainWindow.webContents.send('show-quit-dialog', { type, jobCount });\n    return new Promise((resolve) => {\n      const handler = (_event, data) => {\n        clearTimeout(timer);\n        resolve(data && data.confirmed === true);\n      };\n      const timer = setTimeout(() => {\n        ipcMain.removeListener('quit-dialog-response', handler);\n        resolve(false);\n      }, 5000);\n      ipcMain.once('quit-dialog-response', handler);\n    });\n  }\n\n  app.on('before-quit', async (event) => {\n    if (isQuitting) return;\n\n    // Use synchronous flag -- systemAudioRecordingActive is updated via IPC on each state change\n    if (currentRecordingProcess || systemAudioRecordingActive) {\n      event.preventDefault();\n      const confirmed = await showCustomQuitDialog('recording');\n      if (confirmed) {\n        if (currentRecordingProcess) {\n          currentRecordingProcess.kill('SIGTERM');\n          currentRecordingProcess = null;\n          currentRecordingSessionName = null;\n        }\n        if (systemAudioRecordingActive && mainWindow && !mainWindow.isDestroyed()) {\n          try {\n            await mainWindow.webContents.executeJavaScript('stopSystemAudioRecording(\"quit\")');\n          } catch (e) {\n            // Best effort -- file is saved even if processing doesn't start\n          }\n        }\n        systemAudioRecordingActive = false;\n        updateTrayIcon(false);\n        isQuitting = true;\n        app.quit();\n      }\n    } else if (isProcessing || processingQueue.length > 0) {\n      event.preventDefault();\n      const jobCount = processingQueue.length + (isProcessing ? 1 : 0);\n      const confirmed = await showCustomQuitDialog('processing', jobCount);\n      if (confirmed) {\n        isQuitting = true;\n        app.quit();\n      }\n    } else {\n      isQuitting = true;\n    }\n  });\n\n  app.on('open-url', (event, incomingUrl) => {\n    if (process.platform !== 'darwin') {\n      return;\n    }\n\n    event.preventDefault();\n    sendDebugLog(`Received shortcut URL via open-url: ${sanitizeShortcutUrlForLogs(incomingUrl)}`);\n\n    if (!app.isReady()) {\n      launchedByShortcut = true;\n      pendingShortcutUrls.push(incomingUrl);\n      return;\n    }\n\n    handleShortcutUrl(incomingUrl).catch(err => {\n      sendDebugLog(`Error handling shortcut URL: ${err.message}`);\n    });\n  });\n\n  app.whenReady().then(async () => {\n    // Set application menu with Help > Learn More\n    const appMenu = Menu.buildFromTemplate([\n      { role: 'appMenu' },\n      { role: 'fileMenu' },\n      { role: 'editMenu' },\n      { role: 'viewMenu' },\n      { role: 'windowMenu' },\n      {\n        role: 'help',\n        submenu: [\n          {\n            label: 'Learn More',\n            click: () => {\n              shell.openExternal('https://github.com/ruzin/stenoai');\n            }\n          },\n          {\n            label: 'Report a Bug',\n            click: () => {\n              shell.openExternal('https://discord.gg/DZ6vcQnxxu');\n            }\n          }\n        ]\n      }\n    ]);\n    Menu.setApplicationMenu(appMenu);\n\n    createWindow();\n    if (!IS_E2E) createTray();\n    setupAutoUpdater();\n    const protocolRegistered = registerShortcutProtocolClient();\n    sendDebugLog(`Protocol handler registration (${SHORTCUT_PROTOCOL}): ${protocolRegistered}`);\n\n    // Load hide-dock-icon preference and apply\n    if (process.platform === 'darwin' && app.dock) {\n      try {\n        const dockResult = await new Promise((resolve, reject) => {\n          const proc = spawn(getBackendPath(), ['get-dock-icon'], {\n            cwd: getBackendCwd()\n          });\n          let stdout = '';\n          proc.stdout.on('data', (data) => { stdout += data.toString(); });\n          proc.on('close', (code) => {\n            if (code === 0) resolve(stdout);\n            else reject(new Error(`get-dock-icon exited with code ${code}`));\n          });\n          proc.on('error', reject);\n        });\n\n        const dockConfig = JSON.parse(dockResult.trim());\n        if (dockConfig.hide_dock_icon) {\n          app.dock.hide();\n          console.log('Dock icon hidden (menu bar only mode)');\n        }\n      } catch (e) {\n        console.error('Failed to load dock icon preference:', e.message);\n      }\n    }\n\n    // Initialize telemetry and track app open\n    await initTelemetry();\n    trackEvent('app_opened');\n\n    // Load custom storage path for file validation\n    try {\n      const spResult = await runPythonScript('simple_recorder.py', ['get-storage-path'], true);\n      const spData = JSON.parse(spResult.trim());\n      if (spData.storage_path) {\n        _cachedCustomStoragePath = spData.storage_path;\n        console.log('Custom storage path loaded:', _cachedCustomStoragePath);\n      }\n    } catch (e) {\n      // Non-fatal - custom path just won't be cached\n    }\n\n    // Register global hotkey for toggle recording (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows/Linux)\n    const hotkeyModifier = process.platform === 'darwin' ? 'Command+Shift+R' : 'Ctrl+Shift+R';\n    const registered = globalShortcut.register(hotkeyModifier, () => {\n      console.log('Global hotkey triggered: toggle recording');\n      if (mainWindow) {\n        mainWindow.webContents.send('toggle-recording-hotkey');\n      }\n    });\n\n    if (registered) {\n      console.log(`Global hotkey registered: ${hotkeyModifier}`);\n    } else {\n      console.error(`Failed to register global hotkey: ${hotkeyModifier}`);\n    }\n\n    if (pendingShortcutUrls.length > 0) {\n      const urlsToProcess = [...pendingShortcutUrls];\n      pendingShortcutUrls = [];\n\n      for (const shortcutUrl of urlsToProcess) {\n        await handleShortcutUrl(shortcutUrl);\n      }\n    }\n  });\n\n  // Fallback for launch contexts where deep-link may arrive via argv instead of open-url.\n  if (process.platform === 'darwin') {\n    const argvShortcutUrl = extractShortcutUrlFromArgv(process.argv);\n    if (argvShortcutUrl) {\n      pendingShortcutUrls.push(argvShortcutUrl);\n      launchedByShortcut = true;\n    }\n  }\n\n  app.on('will-quit', async () => {\n    globalShortcut.unregisterAll();\n    if (tray) {\n      tray.destroy();\n      tray = null;\n    }\n    // Kill Ollama on quit. The process may have been started by Electron or\n    // the Python backend — both write the PID to ollama.pid in _internal/.\n    const pidFile = path.join(getBackendCwd(), '_internal', 'ollama.pid');\n    try {\n      const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10);\n      if (pid) {\n        process.kill(pid, 'SIGTERM');\n        // Give it a moment to shut down, then force-kill if still alive\n        setTimeout(() => {\n          try { process.kill(pid, 'SIGKILL'); } catch (_) {}\n        }, 1000);\n      }\n      require('fs').unlinkSync(pidFile);\n    } catch (_) {}\n    // Also kill if Electron spawned it directly\n    if (ollamaPid) {\n      try { process.kill(ollamaPid, 'SIGTERM'); } catch (_) {}\n      ollamaPid = null;\n    }\n    await shutdownTelemetry();\n  });\n\n  app.on('window-all-closed', () => {\n    if (process.platform !== 'darwin') {\n      app.quit();\n    }\n  });\n\n  app.on('activate', () => {\n    if (mainWindow) {\n      if (mainWindow.isMinimized()) mainWindow.restore();\n      // Only show if the window has finished its initial load.\n      // On first launch, windowReadyToShow is false until React mounts.\n      if (windowReadyToShow) {\n        mainWindow.show();\n        mainWindow.focus();\n      }\n      launchedByShortcut = false;\n    } else {\n      launchedByShortcut = false;\n      createWindow();\n    }\n  });\n}\n\n// Focus window handler (used by notification click to bring app to foreground)\nipcMain.on('focus-window', () => {\n    if (mainWindow) {\n        if (mainWindow.isMinimized()) mainWindow.restore();\n        mainWindow.show();\n        mainWindow.focus();\n    }\n});\n\nipcMain.on('shortcut-renderer-ready', () => {\n  rendererShortcutReady = true;\n  flushShortcutQueue();\n});\n\n// Microphone permission handlers\nipcMain.handle('check-microphone-permission', async () => {\n  try {\n    const status = systemPreferences.getMediaAccessStatus('microphone');\n    console.log('Microphone permission status:', status);\n    return { success: true, status };\n  } catch (error) {\n    console.error('Error checking microphone permission:', error);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('request-microphone-permission', async () => {\n  try {\n    console.log('Requesting microphone permission...');\n    const granted = await systemPreferences.askForMediaAccess('microphone');\n    console.log('Microphone permission granted:', granted);\n    return { success: true, granted };\n  } catch (error) {\n    console.error('Error requesting microphone permission:', error);\n    return { success: false, error: error.message };\n  }\n});\n\n// Debug functionality handled by side panel now\n\n// Backend communication - always uses bundled stenoai executable\nfunction runPythonScript(script, args = [], silent = false, extraEnv = {}) {\n  return new Promise((resolve, reject) => {\n    const backendPath = getBackendPath();\n\n    // Log the command being executed (unless silent)\n    console.log('Running:', `${backendPath} ${args.join(' ')}`);\n    if (!silent) {\n      sendDebugLog(`$ stenoai ${args.join(' ')}`);\n    }\n\n    const process = spawn(backendPath, args, {\n      cwd: getBackendCwd(),\n      env: Object.keys(extraEnv).length > 0 ? { ...require('process').env, ...extraEnv } : undefined\n    });\n\n    let stdout = '';\n    let stderr = '';\n\n    process.stdout.on('data', (data) => {\n      const output = data.toString();\n      stdout += output;\n      console.log('Python stdout:', output);\n      // Stream stdout to debug panel in real-time (unless silent)\n      if (!silent) {\n        output.split('\\n').forEach(line => {\n          if (line.trim()) sendDebugLog(line.trim());\n        });\n      }\n    });\n\n    process.stderr.on('data', (data) => {\n      const output = data.toString();\n      stderr += output;\n      console.log('Python stderr:', output);\n      // Stream stderr to debug panel in real-time (unless silent)\n      if (!silent) {\n        output.split('\\n').forEach(line => {\n          if (line.trim()) sendDebugLog('STDERR: ' + line.trim());\n        });\n      }\n    });\n\n    process.on('close', (code) => {\n      if (!silent) {\n        sendDebugLog(`Command completed with exit code: ${code}`);\n      }\n      if (code === 0) {\n        resolve(stdout);\n      } else {\n        reject(new Error(`Python script failed with code ${code}: ${stderr}`));\n      }\n    });\n    \n    process.on('error', (error) => {\n      sendDebugLog(`Command error: ${error.message}`);\n      reject(error);\n    });\n  });\n}\n\nasync function getBackendStatusInternal(silent = true) {\n  const result = await runPythonScript('simple_recorder.py', ['status'], silent);\n  return { success: true, status: result };\n}\n\nasync function handleGetStatus() {\n  try {\n    return await getBackendStatusInternal(true); // Silent mode\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n}\n\nasync function handleGetNotifications() {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-notifications']);\n    const jsonData = JSON.parse(result);\n\n    return {\n      success: true,\n      ...jsonData\n    };\n  } catch (error) {\n    sendDebugLog(`Error getting notification settings: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n}\n\n// IPC Handlers - Separate start/stop with better error handling\nipcMain.handle('start-recording', async (event, sessionName) => {\n  try {\n    sendDebugLog(`Starting recording session: ${sessionName || 'Meeting'}`);\n    sendDebugLog('$ python simple_recorder.py start');\n\n    // Start recording (removed clear-state to prevent race conditions)\n    const result = await runPythonScript('simple_recorder.py', ['start', sessionName || 'Meeting']);\n\n    if (result.includes('SUCCESS')) {\n      sendDebugLog('Recording started successfully');\n      trackEvent('recording_started');\n      return { success: true, message: result };\n    } else {\n      sendDebugLog(`Recording failed: ${result}`);\n      return { success: false, error: result };\n    }\n  } catch (error) {\n    console.error('Start recording error:', error.message);\n    sendDebugLog(`Recording error: ${error.message}`);\n    trackEvent('error_occurred', { error_type: 'start_recording' });\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('stop-recording', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['stop']);\n\n    if (result.includes('SUCCESS') || result.includes('Recording saved')) {\n      trackEvent('recording_stopped');\n      return { success: true, message: result };\n    } else {\n      return { success: false, error: result };\n    }\n  } catch (error) {\n    console.error('Stop recording error:', error.message);\n    trackEvent('error_occurred', { error_type: 'stop_recording' });\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-status', handleGetStatus);\n\nipcMain.handle('process-recording', async (event, audioFile, sessionName) => {\n  try {\n    const cloudKey = loadCloudApiKey();\n    const env = cloudKey ? { STENOAI_CLOUD_API_KEY: cloudKey } : {};\n    const result = await runPythonScript('simple_recorder.py', ['process', audioFile, '--name', sessionName], false, env);\n    trackEvent('transcription_completed', { success: true });\n    trackEvent('summarization_completed', { success: true });\n    return { success: true, result: result };\n  } catch (error) {\n    trackEvent('error_occurred', { error_type: 'process_recording' });\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('test-system', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['test']);\n    return { success: true, result: result };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('select-audio-file', async () => {\n  const result = await dialog.showOpenDialog(mainWindow, {\n    properties: ['openFile'],\n    filters: [\n      { name: 'Audio Files', extensions: ['wav', 'mp3', 'm4a', 'aac', 'webm'] }\n    ]\n  });\n  \n  if (!result.canceled && result.filePaths.length > 0) {\n    return { success: true, filePath: result.filePaths[0] };\n  }\n  \n  return { success: false, error: 'No file selected' };\n});\n\nipcMain.handle('list-meetings', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['list-meetings'], true); // Silent mode\n    return { success: true, meetings: JSON.parse(result) };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('clear-state', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['clear-state']);\n    return { success: true, message: result };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('reprocess-meeting', async (event, summaryFile, regenerateTitle, sessionName) => {\n  try {\n    const args = ['reprocess', summaryFile];\n    if (regenerateTitle) args.push('--regenerate-title');\n\n    sendDebugLog(`🔄 Reprocessing meeting: ${summaryFile}`);\n    sendDebugLog(`$ stenoai ${args.join(' ')}`);\n\n    const cloudKey = loadCloudApiKey();\n    const reprocessEnv = cloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: cloudKey } : undefined;\n\n    await new Promise((resolve, reject) => {\n      const proc = spawn(getBackendPath(), args, {\n        cwd: getBackendCwd(),\n        env: reprocessEnv\n      });\n\n      let stderrBuf = '';\n\n      const procTimeout = setTimeout(() => {\n        console.error('reprocess timed out after 30 minutes, killing');\n        proc.kill();\n      }, 30 * 60 * 1000);\n\n      proc.on('error', (err) => {\n        clearTimeout(procTimeout);\n        reject(new Error(`reprocess spawn error: ${err.message}`));\n      });\n\n      proc.stdout.on('data', (data) => {\n        const text = data.toString();\n        text.split('\\n').forEach(line => {\n          if (line.startsWith('CHUNK:')) {\n            try {\n              const encoded = line.slice(6);\n              const chunk = Buffer.from(encoded, 'base64').toString('utf-8');\n              if (mainWindow && !mainWindow.isDestroyed()) {\n                mainWindow.webContents.send('summary-chunk', { chunk, sessionName });\n              }\n            } catch (e) { console.log('CHUNK decode error:', e.message); }\n          } else if (line.startsWith('TITLE:')) {\n            const title = line.slice(6);\n            if (mainWindow && !mainWindow.isDestroyed()) {\n              mainWindow.webContents.send('summary-title', { title, sessionName });\n            }\n          } else if (line === 'STREAM_COMPLETE') {\n            if (mainWindow && !mainWindow.isDestroyed()) {\n              mainWindow.webContents.send('summary-complete', { success: true, sessionName });\n            }\n          } else if (line.trim()) {\n            sendDebugLog(line.trim());\n          }\n        });\n      });\n\n      proc.stderr.on('data', (data) => {\n        const msg = data.toString().trim();\n        if (msg) {\n          stderrBuf += msg + '\\n';\n          sendDebugLog(`STDERR: ${msg}`);\n        }\n      });\n\n      proc.on('close', (code) => {\n        clearTimeout(procTimeout);\n        if (code === 0) {\n          console.log(`✅ Completed reprocessing: ${sessionName}`);\n          if (mainWindow && !mainWindow.isDestroyed()) {\n            mainWindow.webContents.send('processing-complete', {\n              success: true,\n              sessionName,\n              message: 'Reprocessing completed successfully'\n            });\n          }\n          resolve();\n        } else {\n          reject(new Error(`reprocess exited with code ${code}: ${stderrBuf.slice(-500)}`));\n        }\n      });\n    });\n\n    sendDebugLog('✅ Meeting reprocessed successfully');\n    return { success: true };\n  } catch (error) {\n    sendDebugLog(`❌ Reprocessing failed: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('regen-meeting-title', async (event, summaryFile, sessionName) => {\n  try {\n    const cloudKey = loadCloudApiKey();\n    const regenEnv = cloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: cloudKey } : undefined;\n\n    await new Promise((resolve, reject) => {\n      const proc = spawn(getBackendPath(), ['regen-title', summaryFile], {\n        cwd: getBackendCwd(),\n        env: regenEnv,\n      });\n\n      let stderrBuf = '';\n      const procTimeout = setTimeout(() => { proc.kill(); }, 2 * 60 * 1000);\n\n      proc.on('error', (err) => { clearTimeout(procTimeout); reject(new Error(err.message)); });\n\n      proc.stdout.on('data', (data) => {\n        data.toString().split('\\n').forEach((line) => {\n          if (line.startsWith('TITLE:')) {\n            const title = line.slice(6);\n            if (mainWindow && !mainWindow.isDestroyed()) {\n              mainWindow.webContents.send('summary-title', { title, sessionName });\n            }\n          }\n        });\n      });\n\n      proc.stderr.on('data', (data) => { stderrBuf += data.toString(); });\n\n      proc.on('close', (code) => {\n        clearTimeout(procTimeout);\n        if (code === 0) resolve();\n        else reject(new Error(`regen-title exited with code ${code}: ${stderrBuf.slice(-300)}`));\n      });\n    });\n\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('query-transcript', async (event, summaryFile, question) => {\n  try {\n    sendDebugLog(`🤖 Querying transcript: ${question.substring(0, 50)}...`);\n\n    // Run the query command (pass cloud key for cloud provider)\n    const cloudKey = loadCloudApiKey();\n    const env = cloudKey ? { STENOAI_CLOUD_API_KEY: cloudKey } : {};\n    const result = await runPythonScript('simple_recorder.py', ['query', summaryFile, '-q', question], false, env);\n\n    // Parse the JSON response\n    try {\n      const jsonResponse = JSON.parse(result.trim());\n      if (jsonResponse.success) {\n        sendDebugLog('✅ Query answered successfully');\n        trackEvent('ai_query_used', { success: true });\n        return { success: true, answer: jsonResponse.answer };\n      } else {\n        sendDebugLog(`❌ Query failed: ${jsonResponse.error}`);\n        trackEvent('ai_query_used', { success: false });\n        return { success: false, error: jsonResponse.error };\n      }\n    } catch (parseError) {\n      // If parsing fails, check if the result contains any JSON\n      const jsonMatch = result.match(/\\{[\\s\\S]*\\}/);\n      if (jsonMatch) {\n        const jsonResponse = JSON.parse(jsonMatch[0]);\n        if (jsonResponse.success) {\n          trackEvent('ai_query_used', { success: true });\n          return { success: true, answer: jsonResponse.answer };\n        } else {\n          trackEvent('ai_query_used', { success: false });\n          return { success: false, error: jsonResponse.error };\n        }\n      }\n      sendDebugLog(`❌ Failed to parse query response: ${parseError.message}`);\n      trackEvent('ai_query_used', { success: false });\n      return { success: false, error: 'Failed to parse AI response' };\n    }\n  } catch (error) {\n    sendDebugLog(`❌ Query failed: ${error.message}`);\n    trackEvent('error_occurred', { error_type: 'query_transcript' });\n    return { success: false, error: error.message };\n  }\n});\n\nconst activeQueryProcs = new Map();\n\nipcMain.on('query-cancel', (_event, queryId) => {\n  const proc = activeQueryProcs.get(queryId);\n  if (proc) {\n    console.log(`[QUERY] Cancelling queryId=${queryId}`);\n    proc.kill();\n    activeQueryProcs.delete(queryId);\n  }\n});\n\nipcMain.on('query-transcript-stream', (event, queryId, summaryFile, question) => {\n  console.log(`[QUERY] IPC received: question=\"${question.substring(0, 50)}\" file=\"${summaryFile}\"`);\n  sendDebugLog(`🤖 Streaming query: ${question.substring(0, 50)}...`);\n  const cloudKey = loadCloudApiKey();\n  const env = cloudKey ? { ...process.env, STENOAI_CLOUD_API_KEY: cloudKey } : process.env;\n\n  let proc;\n  try {\n    const backendPath = getBackendPath();\n    proc = require('child_process').spawn(backendPath, ['query-streaming', summaryFile, '-q', question], {\n      env,\n      cwd: getBackendCwd(),\n    });\n  } catch (err) {\n    event.sender.send('query-done', { queryId, success: false, error: err.message });\n    return;\n  }\n\n  activeQueryProcs.set(queryId, proc);\n  // Kill the spawned proc if the renderer sender goes away before the query\n  // finishes. Keep a reference so we can remove the listener on normal close\n  // (otherwise repeated queries on a long-lived sender leak one-time listeners).\n  const onSenderDestroyed = () => {\n    if (activeQueryProcs.has(queryId)) {\n      proc.kill();\n      activeQueryProcs.delete(queryId);\n    }\n  };\n  event.sender.once('destroyed', onSenderDestroyed);\n  let buf = '';\n  let chunkCount = 0;\n  proc.stdout.on('data', (data) => {\n    buf += data.toString();\n    const lines = buf.split('\\n');\n    buf = lines.pop();\n    for (const line of lines) {\n      if (line.startsWith('CHAT_CHUNK:') || line.startsWith('CHUNK:')) {\n        const prefixLen = line.startsWith('CHAT_CHUNK:') ? 11 : 6;\n        try {\n          const chunk = Buffer.from(line.slice(prefixLen), 'base64').toString('utf-8');\n          chunkCount++;\n          if (chunkCount === 1) console.log(`[QUERY] First chunk received (queryId=${queryId})`);\n          if (!event.sender.isDestroyed()) event.sender.send('query-chunk', { queryId, chunk });\n          else {\n            console.log(`[QUERY] Sender destroyed, killing process queryId=${queryId}`);\n            proc.kill();\n            activeQueryProcs.delete(queryId);\n          }\n        } catch (e) { console.log(`[QUERY] Chunk decode error: ${e.message}`); }\n      } else if (line === 'CHAT_STREAM_COMPLETE' || line === 'STREAM_COMPLETE') {\n        console.log(`[QUERY] STREAM_COMPLETE received, ${chunkCount} chunks sent`);\n        if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true });\n        else console.log(`[QUERY] Sender destroyed at STREAM_COMPLETE`);\n      } else if (line.startsWith('CHAT_STREAM_ERROR:') || line.startsWith('STREAM_ERROR:')) {\n        const errMsg = line.startsWith('CHAT_STREAM_ERROR:') ? line.slice(18) : line.slice(13);\n        console.log(`[QUERY] STREAM_ERROR: ${errMsg}`);\n        if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: false, error: errMsg });\n      }\n    }\n  });\n\n  proc.stderr.on('data', (data) => {\n    const msg = data.toString().trim();\n    if (msg) console.log(`[QUERY stderr] ${msg.substring(0, 200)}`);\n  });\n\n  proc.on('close', (code) => {\n    activeQueryProcs.delete(queryId);\n    if (!event.sender.isDestroyed()) {\n      event.sender.removeListener('destroyed', onSenderDestroyed);\n    }\n    console.log(`[QUERY] Process closed, code=${code}, chunks=${chunkCount}, bufRemainder=${buf.length > 0 ? JSON.stringify(buf.substring(0, 100)) : 'empty'}`);\n    if (buf.trim() === 'CHAT_STREAM_COMPLETE' || buf.trim() === 'STREAM_COMPLETE') {\n      console.log(`[QUERY] STREAM_COMPLETE was in buf remainder — sending done now`);\n      if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true });\n    } else if (code !== 0 && code !== null && !event.sender.isDestroyed()) {\n      // code === null means killed (cancelled) — renderer already handles that case\n      event.sender.send('query-done', { queryId, success: false, error: `Process exited with code ${code}` });\n    }\n  });\n\n  proc.on('error', (err) => {\n    activeQueryProcs.delete(queryId);\n    if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: false, error: err.message });\n  });\n});\n\n// Cross-note chat (Chat tab). Same wire protocol as query-transcript-stream\n// (CHAT_CHUNK / CHAT_STREAM_COMPLETE / CHAT_STREAM_ERROR -> query-chunk /\n// query-done) so the renderer can reuse useStreamingQuery. Cloud-only —\n// the Python CLI rejects local providers because we don't have retrieval\n// yet and a full-corpus prompt blows local context windows.\nipcMain.on('chat-global-stream', (event, queryId, question, folderId) => {\n  sendDebugLog(`💬 Global chat query: ${String(question || '').slice(0, 80)}... (folder: ${folderId || 'all'})`);\n  const cloudKey = loadCloudApiKey();\n  const env = cloudKey ? { ...process.env, STENOAI_CLOUD_API_KEY: cloudKey } : process.env;\n\n  const args = ['chat-global-streaming', '-q', question];\n  if (folderId && typeof folderId === 'string' && folderId !== 'all') {\n    args.push('-f', folderId);\n  }\n\n  let proc;\n  try {\n    proc = require('child_process').spawn(\n      getBackendPath(),\n      args,\n      { env, cwd: getBackendCwd() },\n    );\n  } catch (err) {\n    event.sender.send('query-done', { queryId, success: false, error: err.message });\n    return;\n  }\n\n  activeQueryProcs.set(queryId, proc);\n  const onSenderDestroyed = () => {\n    if (activeQueryProcs.has(queryId)) {\n      proc.kill();\n      activeQueryProcs.delete(queryId);\n    }\n  };\n  event.sender.once('destroyed', onSenderDestroyed);\n\n  let buf = '';\n  let chunkCount = 0;\n  proc.stdout.on('data', (data) => {\n    buf += data.toString();\n    const lines = buf.split('\\n');\n    buf = lines.pop();\n    for (const line of lines) {\n      if (line.startsWith('CHAT_CHUNK:')) {\n        try {\n          const chunk = Buffer.from(line.slice(11), 'base64').toString('utf-8');\n          chunkCount++;\n          if (!event.sender.isDestroyed()) {\n            event.sender.send('query-chunk', { queryId, chunk });\n          } else {\n            proc.kill();\n            activeQueryProcs.delete(queryId);\n          }\n        } catch (e) { /* ignore decode errors */ }\n      } else if (line === 'CHAT_STREAM_COMPLETE') {\n        if (!event.sender.isDestroyed()) {\n          event.sender.send('query-done', { queryId, success: true });\n        }\n      } else if (line.startsWith('CHAT_STREAM_ERROR:')) {\n        const errMsg = line.slice(18);\n        if (!event.sender.isDestroyed()) {\n          event.sender.send('query-done', { queryId, success: false, error: errMsg });\n        }\n      }\n    }\n  });\n\n  proc.stderr.on('data', (data) => {\n    const msg = data.toString().trim();\n    if (msg) sendDebugLog(`[chat-global stderr] ${msg.slice(0, 200)}`);\n  });\n\n  proc.on('close', (code) => {\n    activeQueryProcs.delete(queryId);\n    if (!event.sender.isDestroyed()) {\n      event.sender.removeListener('destroyed', onSenderDestroyed);\n    }\n    if (buf.trim() === 'CHAT_STREAM_COMPLETE') {\n      if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true });\n    } else if (code !== 0 && code !== null && !event.sender.isDestroyed()) {\n      event.sender.send('query-done', { queryId, success: false, error: `Process exited with code ${code}` });\n    }\n  });\n\n  proc.on('error', (err) => {\n    activeQueryProcs.delete(queryId);\n    if (!event.sender.isDestroyed()) {\n      event.sender.send('query-done', { queryId, success: false, error: err.message });\n    }\n  });\n});\n\n// Chat sessions persistence.\n//\n// The legacy renderer reads/writes `chat_sessions.json` as a flat array.\n// The new renderer uses an enriched `{ sessions: [...] }` shape. To avoid\n// silently breaking the legacy UI when a user toggles between renderers, we\n// store the new shape in a separate file (`chat_sessions_v2.json`) and never\n// modify the legacy file. On first load, if v2 is absent we read the legacy\n// file once for migration; subsequent saves only touch v2.\n//\n// Writes use tmp+rename to keep the file atomic across crashes / power loss\n// (a truncated chat_sessions file is hard to recover and would lose all\n// chat history on next launch).\nconst CHAT_SESSIONS_V2_FILENAME = 'chat_sessions_v2.json';\nconst CHAT_SESSIONS_LEGACY_FILENAME = 'chat_sessions.json';\n\nfunction chatSessionsV2Path() {\n  return path.join(app.getPath('userData'), CHAT_SESSIONS_V2_FILENAME);\n}\n\nfunction chatSessionsLegacyPath() {\n  return path.join(app.getPath('userData'), CHAT_SESSIONS_LEGACY_FILENAME);\n}\n\nipcMain.handle('save-chat-sessions', async (event, data) => {\n  const filePath = chatSessionsV2Path();\n  const tmpPath = `${filePath}.tmp`;\n  try {\n    fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf-8');\n    fs.renameSync(tmpPath, filePath);\n    return { success: true };\n  } catch (err) {\n    try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch (_) {}\n    return { success: false, error: err.message };\n  }\n});\n\nipcMain.handle('load-chat-sessions', async () => {\n  const v2Path = chatSessionsV2Path();\n  // Prefer v2 file when present\n  if (fs.existsSync(v2Path)) {\n    try {\n      const raw = fs.readFileSync(v2Path, 'utf-8');\n      return { success: true, data: JSON.parse(raw) };\n    } catch (err) {\n      // Corrupt v2 file — quarantine it so we don't keep failing on every load,\n      // then fall through to legacy migration / empty state.\n      const corruptPath = `${v2Path}.corrupt-${Date.now()}`;\n      try { fs.renameSync(v2Path, corruptPath); } catch (_) {}\n      console.error(`[chat-sessions] v2 file unreadable, quarantined to ${corruptPath}:`, err.message);\n    }\n  }\n  // First run on the new renderer: try to migrate from the legacy file.\n  // Legacy file is read but never modified, so legacy renderer remains intact.\n  const legacyPath = chatSessionsLegacyPath();\n  if (fs.existsSync(legacyPath)) {\n    try {\n      const raw = fs.readFileSync(legacyPath, 'utf-8');\n      return { success: true, data: JSON.parse(raw), migratedFromLegacy: true };\n    } catch (err) {\n      console.error('[chat-sessions] legacy file unreadable:', err.message);\n    }\n  }\n  return { success: true, data: null };\n});\n\nipcMain.handle('save-meeting-notes', async (event, sessionName, notes) => {\n  try {\n    const outputDir = path.join(getBackendCwd(), '_internal', 'output');\n    if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });\n    const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_');\n    const notesFile = path.join(outputDir, `${safeName}_notes.txt`);\n    fs.writeFileSync(notesFile, notes, 'utf-8');\n    return { success: true, path: notesFile };\n  } catch (error) {\n    console.error('Failed to save meeting notes:', error);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('update-meeting', async (event, summaryFilePath, updates) => {\n  try {\n    const projectRoot = path.join(__dirname, '..');\n\n    // Define allowed base directories for file operations (includes custom storage)\n    const allowedBaseDirs = getAllowedBaseDirs();\n\n    // Convert to absolute path if needed\n    const absolutePath = path.isAbsolute(summaryFilePath)\n      ? summaryFilePath\n      : path.join(projectRoot, summaryFilePath);\n\n    // Security: Validate file path is within allowed directories\n    if (!validateSafeFilePath(absolutePath, allowedBaseDirs)) {\n      console.error(`Security: Blocked attempt to update file outside allowed directories: ${absolutePath}`);\n      return {\n        success: false,\n        error: 'Invalid file path'\n      };\n    }\n\n    // Read existing data\n    if (!fs.existsSync(absolutePath)) {\n      return {\n        success: false,\n        error: 'Meeting file not found'\n      };\n    }\n\n    const isMarkdown = absolutePath.endsWith('.md');\n    let data;\n\n    if (isMarkdown) {\n      const raw = fs.readFileSync(absolutePath, 'utf8');\n      // Escape a string for a YAML double-quoted scalar. Backslash MUST be\n      // escaped before the quote, and embedded newlines must become literal\n      // \\n so they don't end the scalar mid-line.\n      const yamlQuote = (s) =>\n        '\"' + String(s)\n          .replace(/\\\\/g, '\\\\\\\\')\n          .replace(/\"/g, '\\\\\"')\n          .replace(/\\n/g, '\\\\n')\n          .replace(/\\r/g, '')\n        + '\"';\n\n      // Strip the outer quotes only — the simple frontmatter we read here is\n      // for the response shape (data.session_info.name) and doesn't need to\n      // reverse YAML escapes for its sole consumer (the renderer).\n      const readTitle = (rawValue) => rawValue.trim().replace(/^\"|\"$/g, '');\n\n      // Line-based rewrite: only mutate the keys we're updating, leave every\n      // other line (including non-string values like arrays/booleans) byte-\n      // identical so we don't corrupt structured fields like `folders: [...]`.\n      let title = '';\n      let updatedAt = new Date().toISOString();\n      let body = raw;\n      let updatedRaw = raw;\n\n      if (raw.startsWith('---')) {\n        const parts = raw.split('---', 3);\n        if (parts.length >= 3) {\n          const fmText = parts[1];\n          body = parts[2];\n          const lines = fmText.split('\\n');\n          let titleSeen = false;\n          let updatedAtSeen = false;\n          const newLines = lines.map((line) => {\n            const colon = line.indexOf(':');\n            if (colon === -1) return line;\n            const key = line.slice(0, colon).trim();\n            if (key === 'title') {\n              titleSeen = true;\n              const original = line.slice(colon + 1);\n              if (updates.name !== undefined) {\n                return `title: ${yamlQuote(updates.name)}`;\n              }\n              title = readTitle(original);\n              return line;\n            }\n            if (key === 'updated_at') {\n              updatedAtSeen = true;\n              return `updated_at: ${yamlQuote(updatedAt)}`;\n            }\n            return line;\n          });\n          if (!titleSeen && updates.name !== undefined) {\n            // Insert before the trailing blank line (if any) for readability.\n            const insertIdx = newLines[newLines.length - 1] === '' ? newLines.length - 1 : newLines.length;\n            newLines.splice(insertIdx, 0, `title: ${yamlQuote(updates.name)}`);\n            title = updates.name;\n          } else if (updates.name !== undefined) {\n            title = updates.name;\n          }\n          if (!updatedAtSeen) {\n            const insertIdx = newLines[newLines.length - 1] === '' ? newLines.length - 1 : newLines.length;\n            newLines.splice(insertIdx, 0, `updated_at: ${yamlQuote(updatedAt)}`);\n          }\n          updatedRaw = `---${newLines.join('\\n')}---${body}`;\n        }\n      }\n\n      fs.writeFileSync(absolutePath, updatedRaw, 'utf8');\n\n      data = {\n        session_info: {\n          name: updates.name !== undefined ? updates.name : title,\n          summary_file: absolutePath,\n          updated_at: updatedAt,\n        },\n      };\n    } else {\n      data = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));\n\n      if (updates.name !== undefined) {\n        data.session_info.name = updates.name;\n      }\n      if (updates.summary !== undefined) {\n        data.summary = updates.summary;\n      }\n      if (updates.participants !== undefined) {\n        data.participants = updates.participants;\n      }\n      if (updates.key_points !== undefined) {\n        data.key_points = updates.key_points;\n      }\n      if (updates.action_items !== undefined) {\n        data.action_items = updates.action_items;\n      }\n\n      data.session_info.updated_at = new Date().toISOString();\n      fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf8');\n    }\n\n    console.log(`Updated meeting: ${absolutePath}`);\n\n    return {\n      success: true,\n      message: 'Meeting updated successfully'\n    };\n  } catch (error) {\n    console.error('Update meeting error:', error);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('reveal-meeting-folder', async (event, filePath) => {\n  try {\n    const projectRoot = path.join(__dirname, '..');\n    const allowedBaseDirs = getAllowedBaseDirs();\n    const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);\n    if (!validateSafeFilePath(absolutePath, allowedBaseDirs)) {\n      return { success: false, error: 'Invalid file path: outside allowed directories' };\n    }\n    shell.showItemInFolder(absolutePath);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('delete-meeting', async (event, meetingData) => {\n  try {\n    const fs = require('fs');\n    const path = require('path');\n\n    // meetingData is the actual meeting object, not a file path\n    const meeting = meetingData;\n\n    // Build correct file paths from the meeting data - convert to absolute paths\n    const projectRoot = path.join(__dirname, '..');\n\n    // Define allowed base directories for file operations (includes custom storage)\n    const allowedBaseDirs = getAllowedBaseDirs();\n\n    const summaryFile = meeting.session_info?.summary_file;\n    const transcriptFile = meeting.session_info?.transcript_file;\n    const audioFile = meeting.session_info?.audio_file;\n    const sessionName = meeting.session_info?.name;\n\n    // Convert relative paths to absolute paths\n    const absolutePaths = [];\n    if (summaryFile) {\n      absolutePaths.push(path.isAbsolute(summaryFile) ? summaryFile : path.join(projectRoot, summaryFile));\n    }\n    if (transcriptFile) {\n      absolutePaths.push(path.isAbsolute(transcriptFile) ? transcriptFile : path.join(projectRoot, transcriptFile));\n    }\n    if (audioFile) {\n      absolutePaths.push(path.isAbsolute(audioFile) ? audioFile : path.join(projectRoot, audioFile));\n    }\n    if (summaryFile && sessionName) {\n      const outputDir = path.dirname(path.isAbsolute(summaryFile) ? summaryFile : path.join(projectRoot, summaryFile));\n      const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_');\n      absolutePaths.push(path.join(outputDir, `${safeName}_notes.txt`));\n    }\n\n    console.log('Attempting to delete files:', absolutePaths);\n\n    let deletedCount = 0;\n    let validationErrors = 0;\n\n    // Delete all related files with path validation\n    for (const file of absolutePaths) {\n      try {\n        // Security: Validate file path is within allowed directories\n        if (!validateSafeFilePath(file, allowedBaseDirs)) {\n          console.error(`Security: Blocked attempt to delete file outside allowed directories: ${file}`);\n          validationErrors++;\n          continue;\n        }\n\n        if (fs.existsSync(file)) {\n          fs.unlinkSync(file);\n          deletedCount++;\n          console.log(`Deleted: ${file}`);\n        } else {\n          console.log(`File not found (already deleted?): ${file}`);\n        }\n      } catch (err) {\n        console.warn(`Could not delete ${file}:`, err.message);\n      }\n    }\n\n    if (validationErrors > 0) {\n      return {\n        success: false,\n        error: `Blocked ${validationErrors} file deletion(s) due to security validation`\n      };\n    }\n    \n    return { \n      success: true, \n      message: `Deleted meeting and ${deletedCount} associated files` \n    };\n  } catch (error) {\n    console.error('Delete meeting error:', error);\n    return { success: false, error: error.message };\n  }\n});\n\n// Queue status handler\nipcMain.handle('get-queue-status', async () => {\n  return {\n    success: true,\n    isProcessing,\n    queueSize: processingQueue.length,\n    currentJob: currentProcessingJob?.sessionName || null,\n    hasRecording: currentRecordingProcess !== null || systemAudioRecordingActive,\n    isPaused: currentRecordingProcess !== null && recordingRuntimeState.isPaused,\n    elapsedSeconds: currentRecordingProcess !== null ? getRecordingElapsedSeconds() : 0,\n    sessionName: currentRecordingSessionName\n  };\n});\n\n// Global recording state management\nlet systemAudioRecordingActive = false;  // Track system audio recording for tray/quit\nlet currentRecordingProcess = null;\nlet currentRecordingSessionName = null;  // Surfaced in get-queue-status so renderer knows which meeting is live\nlet processingQueue = [];\nlet isProcessing = false;\nlet currentProcessingJob = null;\nlet recordingRuntimeState = {\n  startedAtMs: null,\n  pausedAtMs: null,\n  pausedTotalMs: 0,\n  isPaused: false\n};\nlet ollamaProcess = null;  // Track spawned Ollama process for cleanup on quit\nlet ollamaPid = null;      // Store PID separately since unref() disconnects the process\nlet ollamaStartedByUs = false;\n\nfunction resetRecordingRuntimeState() {\n  recordingRuntimeState = {\n    startedAtMs: null,\n    pausedAtMs: null,\n    pausedTotalMs: 0,\n    isPaused: false\n  };\n}\n\nfunction startRecordingRuntimeState() {\n  recordingRuntimeState = {\n    startedAtMs: Date.now(),\n    pausedAtMs: null,\n    pausedTotalMs: 0,\n    isPaused: false\n  };\n}\n\nfunction markRecordingPaused() {\n  if (!recordingRuntimeState.startedAtMs || recordingRuntimeState.isPaused) {\n    return;\n  }\n  recordingRuntimeState.isPaused = true;\n  recordingRuntimeState.pausedAtMs = Date.now();\n}\n\nfunction markRecordingResumed() {\n  if (!recordingRuntimeState.isPaused) {\n    return;\n  }\n  if (recordingRuntimeState.pausedAtMs) {\n    recordingRuntimeState.pausedTotalMs += Date.now() - recordingRuntimeState.pausedAtMs;\n  }\n  recordingRuntimeState.isPaused = false;\n  recordingRuntimeState.pausedAtMs = null;\n}\n\nfunction getRecordingElapsedSeconds() {\n  if (!recordingRuntimeState.startedAtMs) {\n    return 0;\n  }\n\n  let pausedMs = recordingRuntimeState.pausedTotalMs;\n  if (recordingRuntimeState.isPaused && recordingRuntimeState.pausedAtMs) {\n    pausedMs += Date.now() - recordingRuntimeState.pausedAtMs;\n  }\n\n  return Math.max(\n    0,\n    Math.floor((Date.now() - recordingRuntimeState.startedAtMs - pausedMs) / 1000)\n  );\n}\n\n// Processing queue management\nasync function processNextInQueue() {\n  if (isProcessing || processingQueue.length === 0) {\n    return;\n  }\n  \n  isProcessing = true;\n  currentProcessingJob = processingQueue.shift();\n  \n  console.log(`🔄 Processing queued job: ${currentProcessingJob.sessionName}`);\n  \n  try {\n    const queueCloudKey = loadCloudApiKey();\n    const queueEnv = queueCloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: queueCloudKey } : undefined;\n    const processArgs = ['process-streaming', currentProcessingJob.audioFile, '--name', currentProcessingJob.sessionName];\n    if (currentProcessingJob.notesFile && fs.existsSync(currentProcessingJob.notesFile)) {\n      processArgs.push('--notes', currentProcessingJob.notesFile);\n    }\n\n    await new Promise((resolve, reject) => {\n      const proc = spawn(getBackendPath(), processArgs, {\n        cwd: getBackendCwd(),\n        env: queueEnv\n      });\n\n      let stderrBuf = '';\n\n      // Timeout: kill process if it runs longer than 30 minutes\n      const procTimeout = setTimeout(() => {\n        console.error('process-streaming timed out after 30 minutes, killing');\n        proc.kill();\n      }, 30 * 60 * 1000);\n\n      proc.on('error', (err) => {\n        clearTimeout(procTimeout);\n        reject(new Error(`process-streaming spawn error: ${err.message}`));\n      });\n\n      proc.stdout.on('data', (data) => {\n        const text = data.toString();\n        // Parse protocol lines\n        text.split('\\n').forEach(line => {\n          if (line.startsWith('CHUNK:')) {\n            try {\n              const encoded = line.slice(6);\n              const chunk = Buffer.from(encoded, 'base64').toString('utf-8');\n              if (mainWindow && !mainWindow.isDestroyed()) {\n                mainWindow.webContents.send('summary-chunk', { chunk, sessionName: currentProcessingJob.sessionName });\n              }\n            } catch (e) { console.log('CHUNK decode error:', e.message); }\n          } else if (line.startsWith('TRANSCRIPTION_COMPLETE:')) {\n            sendDebugLog(`Transcription complete (${line.split(':')[1]} chars)`);\n            trackEvent('transcription_completed', { success: true });\n          } else if (line.startsWith('TITLE:')) {\n            const title = line.slice(6);\n            if (mainWindow && !mainWindow.isDestroyed()) {\n              mainWindow.webContents.send('summary-title', { title, sessionName: currentProcessingJob.sessionName });\n            }\n          } else if (line === 'STREAM_COMPLETE') {\n            trackEvent('summarization_completed', { success: true });\n          } else if (line.startsWith('SAVED:')) {\n            sendDebugLog(`Summary saved: ${line.slice(6)}`);\n          } else if (line.trim()) {\n            sendDebugLog(line.trim());\n          }\n        });\n      });\n\n      proc.stderr.on('data', (data) => {\n        const msg = data.toString().trim();\n        if (msg) {\n          stderrBuf += msg + '\\n';\n          sendDebugLog(`STDERR: ${msg}`);\n        }\n      });\n\n      proc.on('close', (code) => {\n        clearTimeout(procTimeout);\n        if (code === 0) {\n          console.log(`✅ Completed streaming processing: ${currentProcessingJob.sessionName}`);\n          // Notify frontend that streaming is done and meeting is saved\n          if (mainWindow && !mainWindow.isDestroyed()) {\n            mainWindow.webContents.send('summary-complete', {\n              success: true,\n              sessionName: currentProcessingJob.sessionName\n            });\n            // Also send processing-complete for backward compat (reloads meeting list)\n            mainWindow.webContents.send('processing-complete', {\n              success: true,\n              sessionName: currentProcessingJob.sessionName,\n              message: 'Processing completed successfully'\n            });\n          }\n          resolve();\n        } else {\n          reject(new Error(`process-streaming exited with code ${code}: ${stderrBuf.slice(-500)}`));\n        }\n      });\n    });\n\n  } catch (error) {\n    console.error(`❌ Processing failed for ${currentProcessingJob.sessionName}:`, error);\n    trackEvent('error_occurred', { error_type: 'processing_queue' });\n\n    if (mainWindow && !mainWindow.isDestroyed()) {\n      mainWindow.webContents.send('processing-complete', {\n        success: false,\n        sessionName: currentProcessingJob.sessionName,\n        error: error.message\n      });\n    }\n  } finally {\n    isProcessing = false;\n    currentProcessingJob = null;\n    // Process next job in queue\n    setTimeout(processNextInQueue, 1000);\n  }\n}\n\nfunction addToProcessingQueue(audioFile, sessionName, notesFile) {\n  processingQueue.push({ audioFile, sessionName, notesFile });\n  console.log(`📋 Added to processing queue: ${sessionName} (Queue size: ${processingQueue.length})`);\n  processNextInQueue();\n}\n\nipcMain.handle('start-recording-ui', async (_, sessionName) => {\n  try {\n    if (currentRecordingProcess) {\n      return { success: false, error: 'Recording already in progress' };\n    }\n\n    // Start recording (removed clear-state to prevent race conditions)\n\n    console.log('Starting long recording process...');\n    sendDebugLog(`Starting recording process: ${sessionName || 'Meeting'}`);\n    sendDebugLog('$ stenoai record 7200');\n\n    const actualSessionName = sessionName || 'Meeting';\n\n    // Start background recording with 2-hour limit\n    // Pass cloud API key via env var for cloud summarization\n    const recordEnv = {};\n    const cloudKey = loadCloudApiKey();\n    if (cloudKey) recordEnv.STENOAI_CLOUD_API_KEY = cloudKey;\n\n    currentRecordingProcess = spawn(getBackendPath(), ['record', '7200', actualSessionName], {\n      cwd: getBackendCwd(),\n      env: Object.keys(recordEnv).length > 0 ? { ...require('process').env, ...recordEnv } : undefined\n    });\n    currentRecordingSessionName = actualSessionName;\n    startRecordingRuntimeState();\n\n    let hasStarted = false;\n    let processingSucceeded = false;\n    let recordedAudioFile = null;\n    // Authoritative pointer to the final summary file once Python finishes\n    // auto-renaming + writing it (emitted as `SAVED:<path>`). Use this in\n    // preference to the name/audio fallbacks since it can't drift.\n    let savedSummaryFile = null;\n\n    currentRecordingProcess.stdout.on('data', (data) => {\n      const output = data.toString();\n\n      // Capture the audio file path when the recording is saved\n      const audioMatch = output.match(/Recording saved:\\s*(.+\\.wav)/);\n      if (audioMatch) {\n        recordedAudioFile = audioMatch[1].trim();\n      }\n      console.log('Recording stdout:', output);\n\n      // Parse streaming protocol + send to debug panel\n      output.split('\\n').forEach(line => {\n        if (line.startsWith('CHUNK:')) {\n          const encoded = line.slice(6);\n          try {\n            const chunk = Buffer.from(encoded, 'base64').toString('utf-8');\n            if (mainWindow && !mainWindow.isDestroyed()) {\n              mainWindow.webContents.send('summary-chunk', { chunk, sessionName: actualSessionName });\n            }\n          } catch (e) { /* ignore decode errors */ }\n        } else if (line.startsWith('TITLE:')) {\n          const title = line.slice(6);\n          if (mainWindow && !mainWindow.isDestroyed()) {\n            mainWindow.webContents.send('summary-title', { title, sessionName: actualSessionName });\n          }\n        } else if (line === 'STREAM_COMPLETE') {\n          if (mainWindow && !mainWindow.isDestroyed()) {\n            mainWindow.webContents.send('summary-complete', { success: true, sessionName: actualSessionName });\n          }\n        } else if (line.startsWith('SAVED:')) {\n          savedSummaryFile = line.slice(6).trim();\n        } else if (line.trim()) {\n          sendDebugLog(line.trim());\n        }\n      });\n\n      // Background recording process handles complete pipeline - just notify when done\n      if (output.includes('✅ Complete processing finished!')) {\n        processingSucceeded = true;\n        console.log(`🎉 Recording and processing completed for: ${actualSessionName}`);\n        // Notify frontend that everything is done\n        if (mainWindow) {\n          // Get the processed meeting data to send to frontend\n          runPythonScript('simple_recorder.py', ['list-meetings'], true)\n            .then(meetingsResult => {\n              const allMeetings = JSON.parse(meetingsResult);\n              // Prefer the SAVED:<path> pointer Python emits — that's the\n              // exact summary file written this session and survives the\n              // auto-rename. Fall back to name match (only if user kept the\n              // placeholder), then to audio-file basename.\n              let processedMeeting = null;\n              if (savedSummaryFile) {\n                processedMeeting = allMeetings.find(\n                  m => m.session_info?.summary_file === savedSummaryFile,\n                );\n              }\n              if (!processedMeeting) {\n                processedMeeting = allMeetings.find(m => m.session_info?.name === actualSessionName);\n              }\n              if (!processedMeeting && recordedAudioFile) {\n                const audioBasename = path.basename(recordedAudioFile);\n                processedMeeting = allMeetings.find(m =>\n                  m.session_info?.audio_file && path.basename(m.session_info.audio_file) === audioBasename\n                );\n              }\n\n              mainWindow.webContents.send('processing-complete', {\n                success: true,\n                sessionName: actualSessionName,\n                message: 'Recording and processing completed successfully',\n                meetingData: processedMeeting\n              });\n            })\n            .catch(error => {\n              console.error('Error getting processed meeting data:', error);\n              // Fallback - send without meetingData, frontend will refresh\n              mainWindow.webContents.send('processing-complete', {\n                success: true,\n                sessionName: actualSessionName,\n                message: 'Recording and processing completed successfully'\n              });\n            });\n        }\n      }\n\n      // Detect explicit processing failure from backend\n      if (output.includes('❌ Processing pipeline failed')) {\n        processingSucceeded = true; // Prevent duplicate notification from close handler\n        console.error(`Processing failed for: ${actualSessionName}`);\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.webContents.send('processing-complete', {\n            success: false,\n            sessionName: actualSessionName,\n            message: 'Processing failed: summarization error (check that Ollama and a model are available)'\n          });\n        }\n      }\n\n      // Don't queue background recordings for additional processing - they handle it themselves!\n\n      if (output.includes('Recording to:') && !hasStarted) {\n        hasStarted = true;\n      }\n    });\n\n    currentRecordingProcess.stderr.on('data', (data) => {\n      const output = data.toString();\n      console.log('Recording stderr:', output);\n\n      // Send real-time stderr to debug panel (same as runPythonScript)\n      output.split('\\n').forEach(line => {\n        if (line.trim()) sendDebugLog('STDERR: ' + line.trim());\n      });\n    });\n\n    currentRecordingProcess.on('close', (code) => {\n      console.log(`Recording process closed with code ${code}`);\n      sendDebugLog(`Recording process completed with exit code: ${code}`);\n      currentRecordingProcess = null;\n      currentRecordingSessionName = null;\n      resetRecordingRuntimeState();\n      updateTrayIcon(false);\n\n      // If process exited without a success or failure message, notify the user\n      if (!processingSucceeded && hasStarted && mainWindow && !mainWindow.isDestroyed()) {\n        console.error(`Recording process exited (code ${code}) without completing processing`);\n        mainWindow.webContents.send('processing-complete', {\n          success: false,\n          sessionName: actualSessionName,\n          message: `Processing failed unexpectedly (exit code ${code})`\n        });\n      }\n    });\n\n    // Give it time to start\n    await new Promise(resolve => setTimeout(resolve, 2000));\n    \n    if (currentRecordingProcess) {\n      trackEvent('recording_started');\n      updateTrayIcon(true);\n      return { success: true, message: 'Recording started successfully' };\n    } else {\n      return { success: false, error: 'Failed to start recording process' };\n    }\n  } catch (error) {\n    console.error('Start recording UI error:', error.message);\n    currentRecordingProcess = null;\n    currentRecordingSessionName = null;\n    resetRecordingRuntimeState();\n    updateTrayIcon(false);\n    trackEvent('error_occurred', { error_type: 'start_recording_ui' });\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('pause-recording-ui', async () => {\n  try {\n    if (!currentRecordingProcess) {\n      sendDebugLog('Pause failed: No recording process found');\n      return { success: false, error: 'No recording in progress' };\n    }\n\n    console.log('Pausing recording process...');\n    sendDebugLog('Sending SIGUSR1 to pause recording...');\n\n    // Send SIGUSR1 to pause recording (Unix only)\n    if (process.platform !== 'win32') {\n      currentRecordingProcess.kill('SIGUSR1');\n      markRecordingPaused();\n      sendDebugLog('SIGUSR1 sent successfully');\n      return { success: true, message: 'Recording paused' };\n    } else {\n      return { success: false, error: 'Pause not supported on Windows' };\n    }\n  } catch (error) {\n    console.error('Pause recording UI error:', error.message);\n    sendDebugLog(`Pause error: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('resume-recording-ui', async () => {\n  try {\n    if (!currentRecordingProcess) {\n      sendDebugLog('Resume failed: No recording process found');\n      return { success: false, error: 'No recording in progress' };\n    }\n\n    console.log('Resuming recording process...');\n    sendDebugLog('Sending SIGUSR2 to resume recording...');\n\n    // Send SIGUSR2 to resume recording (Unix only)\n    if (process.platform !== 'win32') {\n      currentRecordingProcess.kill('SIGUSR2');\n      markRecordingResumed();\n      sendDebugLog('SIGUSR2 sent successfully');\n      return { success: true, message: 'Recording resumed' };\n    } else {\n      return { success: false, error: 'Resume not supported on Windows' };\n    }\n  } catch (error) {\n    console.error('Resume recording UI error:', error.message);\n    sendDebugLog(`Resume error: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('stop-recording-ui', async () => {\n  try {\n    if (!currentRecordingProcess) {\n      return { success: false, error: 'No recording in progress' };\n    }\n\n    console.log('Stopping recording process...');\n\n    // Send SIGTERM to trigger graceful stop and processing\n    currentRecordingProcess.kill('SIGTERM');\n\n    // Don't wait - let the process complete independently\n    // The process will handle: stop recording → transcribe → summarize → exit\n    currentRecordingProcess = null;\n    currentRecordingSessionName = null;\n    resetRecordingRuntimeState();\n    updateTrayIcon(false);\n\n    trackEvent('recording_stopped');\n    return {\n      success: true,\n      message: 'Recording stopped - processing will complete in background'\n    };\n  } catch (error) {\n    console.error('Stop recording UI error:', error.message);\n    currentRecordingProcess = null;\n    currentRecordingSessionName = null;\n    resetRecordingRuntimeState();\n    updateTrayIcon(false);\n    trackEvent('error_occurred', { error_type: 'stop_recording_ui' });\n    return { success: false, error: error.message };\n  }\n});\n\n// Setup IPC handlers\n\nipcMain.handle('startup-setup-check', async () => {\n  try {\n    console.log('Running startup setup check...');\n    \n    // Use Python backend to check setup\n    const result = await runPythonScript('simple_recorder.py', ['setup-check']);\n    console.log('Setup check result:', result);\n    \n    // Parse the output to determine if setup is complete\n    const allGood = result.includes('🎉 System check passed!');\n    \n    // Extract check results for UI display\n    const lines = result.split('\\n');\n    const checks = [];\n    \n    lines.forEach(line => {\n      if (line.includes('✅') || line.includes('❌') || line.includes('⚠️')) {\n        const parts = line.split(/\\s{2,}/); // Split on multiple spaces\n        if (parts.length >= 2) {\n          checks.push([parts[0].trim(), parts[1].trim()]);\n        }\n      }\n    });\n    \n    console.log('Parsed checks:', checks);\n    console.log('All good:', allGood);\n    \n    return { \n      success: true, \n      allGood,\n      checks\n    };\n  } catch (error) {\n    console.error('Setup check error:', error);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('setup-system-check', async () => {\n  try {\n    // Check Python installation\n    const pythonResult = await new Promise((resolve) => {\n      exec('python3 --version', (error, stdout, stderr) => {\n        if (error) {\n          resolve(false);\n        } else {\n          resolve(true);\n        }\n      });\n    });\n    \n    if (!pythonResult) {\n      return { success: false, error: 'Python 3 not found. Please install Python 3.8+' };\n    }\n    \n    // Create required directories - match Python logic for DMG vs development\n    const os = require('os');\n    const currentPath = __dirname;\n    let baseDir;\n    \n    // Detect if running from app bundle (DMG install) or development\n    if (currentPath.includes('StenoAI.app') || currentPath.includes('Applications')) {\n      // DMG/Production: Use Application Support folder\n      baseDir = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai');\n    } else {\n      // Development: Use project relative paths  \n      baseDir = path.join(__dirname, '..');\n    }\n    \n    const dirs = ['recordings', 'transcripts', 'output'];\n    \n    for (const dir of dirs) {\n      const dirPath = path.join(baseDir, dir);\n      if (!fs.existsSync(dirPath)) {\n        fs.mkdirSync(dirPath, { recursive: true });\n      }\n    }\n    \n    // Create venv directory if it doesn't exist  \n    const projectRoot = path.join(__dirname, '..');\n    const venvPath = path.join(projectRoot, 'venv');\n    if (!fs.existsSync(venvPath)) {\n      await new Promise((resolve, reject) => {\n        const process = spawn('python3', ['-m', 'venv', 'venv'], {\n          cwd: projectRoot\n        });\n        \n        process.on('close', (code) => {\n          if (code === 0) {\n            resolve();\n          } else {\n            reject(new Error('Failed to create virtual environment'));\n          }\n        });\n        \n        process.on('error', reject);\n      });\n    }\n    \n    trackEvent('setup_completed', { step: 'system_check' });\n    return { success: true, message: 'System setup complete - Python and directories ready' };\n  } catch (error) {\n    trackEvent('setup_failed', { step: 'system_check' });\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('setup-ffmpeg', async () => {\n  try {\n    sendDebugLog('$ Checking for existing ffmpeg installation...');\n\n    // Check bundled ffmpeg first (shipped with the app), then system paths\n    const bundledFfmpeg = app.isPackaged\n      ? path.join(process.resourcesPath, 'stenoai', 'ffmpeg')\n      : path.join(__dirname, '..', 'dist', 'stenoai', 'ffmpeg');\n    const ffmpegPaths = [bundledFfmpeg, 'ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'];\n    sendDebugLog(`$ Checking: ${ffmpegPaths.join(', ')}`);\n    let ffmpegPath = null;\n\n    for (const testPath of ffmpegPaths) {\n      try {\n        const found = await new Promise((resolve) => {\n          const proc = spawn(testPath, ['-version'], { timeout: 5000 });\n          proc.on('error', () => resolve(false));\n          proc.on('close', (code) => resolve(code === 0));\n        });\n\n        if (found) {\n          ffmpegPath = testPath;\n          sendDebugLog(`Found ffmpeg at: ${testPath}`);\n          break;\n        }\n      } catch (error) {\n        // Try next path\n        continue;\n      }\n    }\n\n    if (!ffmpegPath) {\n      sendDebugLog('ffmpeg not found in any common locations');\n    }\n    \n    // Install ffmpeg if not present\n    if (!ffmpegPath) {\n      sendDebugLog('ffmpeg not found, checking for Homebrew...');\n      sendDebugLog('$ Checking: brew, /opt/homebrew/bin/brew, /usr/local/bin/brew');\n\n      // First check if Homebrew is installed and get its path\n      const brewPaths = ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew'];\n      let brewPath = null;\n\n      for (const testPath of brewPaths) {\n        try {\n          const found = await new Promise((resolve) => {\n            const proc = spawn(testPath, ['--version'], { timeout: 5000 });\n            proc.on('error', () => resolve(false));\n            proc.on('close', (code) => resolve(code === 0));\n          });\n\n          if (found) {\n            brewPath = testPath;\n            sendDebugLog(`Found Homebrew at: ${testPath}`);\n            break;\n          }\n        } catch (error) {\n          // Try next path\n          continue;\n        }\n      }\n\n      if (!brewPath) {\n        sendDebugLog('Homebrew not found in any common locations');\n      }\n      \n      // Install Homebrew if missing\n      if (!brewPath) {\n        sendDebugLog('Homebrew not found, installing...');\n        sendDebugLog('$ /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"');\n\n        // Note: This uses the official Homebrew installation script\n        // Using exec here is intentional as this is the documented installation method\n        // The URL is hardcoded and not user-controlled\n        await new Promise((resolve, reject) => {\n          const process = exec('/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"',\n               { timeout: 600000 });\n\n          process.stdout.on('data', (data) => {\n            sendDebugLog(data.toString().trim());\n          });\n\n          process.stderr.on('data', (data) => {\n            sendDebugLog('STDERR: ' + data.toString().trim());\n          });\n\n          process.on('close', (code) => {\n            if (code === 0) {\n              sendDebugLog('Homebrew installation completed successfully');\n              resolve();\n            } else {\n              sendDebugLog(`Homebrew installation failed with exit code: ${code}`);\n              reject(new Error('Failed to install Homebrew automatically'));\n            }\n          });\n        });\n\n        // After installing, set brewPath to the default location\n        brewPath = '/opt/homebrew/bin/brew';\n      } else {\n        sendDebugLog('Homebrew found, proceeding with ffmpeg installation...');\n      }\n\n      // Now install ffmpeg via Homebrew using spawn for security\n      sendDebugLog(`$ ${brewPath} install ffmpeg`);\n      await new Promise((resolve, reject) => {\n        const process = spawn(brewPath, ['install', 'ffmpeg'], { timeout: 300000 });\n\n        process.stdout.on('data', (data) => {\n          sendDebugLog(data.toString().trim());\n        });\n\n        process.stderr.on('data', (data) => {\n          sendDebugLog('STDERR: ' + data.toString().trim());\n        });\n\n        process.on('close', (code) => {\n          if (code === 0) {\n            sendDebugLog('ffmpeg installation completed successfully');\n            resolve();\n          } else {\n            sendDebugLog(`ffmpeg installation failed with exit code: ${code}`);\n            reject(new Error('Failed to install ffmpeg via Homebrew'));\n          }\n        });\n\n        process.on('error', (error) => {\n          sendDebugLog(`ffmpeg installation error: ${error.message}`);\n          reject(error);\n        });\n      });\n    } else {\n      sendDebugLog('ffmpeg already installed, skipping installation');\n    }\n    \n    return { success: true, message: 'ffmpeg ready' };\n  } catch (error) {\n    sendDebugLog(`ffmpeg setup failed: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('setup-python', async () => {\n  try {\n    // Python backend is bundled via PyInstaller - no setup needed\n    sendDebugLog('Python backend is bundled, skipping setup');\n    return { success: true, message: 'Python backend bundled' };\n\n    // Legacy code below - kept for reference but never runs\n    const projectRoot = path.join(__dirname, '..');\n    const venvPath = path.join(projectRoot, 'venv');\n\n    sendDebugLog(`Working directory: ${projectRoot}`);\n    \n    // Create virtual environment if it doesn't exist\n    if (!fs.existsSync(venvPath)) {\n      sendDebugLog('Python virtual environment not found, creating...');\n      sendDebugLog('$ python3 -m venv venv');\n      \n      await new Promise((resolve, reject) => {\n        const process = spawn('python3', ['-m', 'venv', 'venv'], {\n          cwd: projectRoot,\n          stdio: 'pipe'\n        });\n        \n        process.stdout.on('data', (data) => {\n          sendDebugLog(data.toString().trim());\n        });\n        \n        process.stderr.on('data', (data) => {\n          sendDebugLog('STDERR: ' + data.toString().trim());\n        });\n        \n        process.on('close', (code) => {\n          if (code === 0) {\n            sendDebugLog('Virtual environment created successfully');\n            resolve();\n          } else {\n            sendDebugLog(`Virtual environment creation failed with exit code: ${code}`);\n            reject(new Error('Failed to create virtual environment'));\n          }\n        });\n        \n        process.on('error', (error) => {\n          sendDebugLog(`Process error: ${error.message}`);\n          reject(error);\n        });\n      });\n    } else {\n      sendDebugLog('Python virtual environment already exists');\n    }\n    \n    // Install requirements including Whisper\n    sendDebugLog('Installing Python dependencies...');\n    sendDebugLog('$ pip install -r requirements.txt openai-whisper');\n    \n    return new Promise((resolve) => {\n      const pythonPath = path.join(venvPath, 'bin', 'python');\n      const process = spawn(pythonPath, ['-m', 'pip', 'install', '-r', 'requirements.txt', 'openai-whisper'], {\n        cwd: projectRoot,\n        stdio: 'pipe'\n      });\n      \n      let output = '';\n      \n      process.stdout.on('data', (data) => {\n        const text = data.toString().trim();\n        if (text) {\n          sendDebugLog(text);\n          output += text;\n        }\n      });\n      \n      process.stderr.on('data', (data) => {\n        const text = data.toString().trim();\n        if (text) {\n          sendDebugLog('STDERR: ' + text);\n          output += text;\n        }\n      });\n      \n      process.on('close', (code) => {\n        if (code === 0) {\n          sendDebugLog('Python dependencies installation completed successfully');\n          trackEvent('setup_completed', { step: 'python_dependencies' });\n          resolve({ success: true, message: 'Python dependencies and Whisper installed' });\n        } else {\n          sendDebugLog(`Python dependencies installation failed with exit code: ${code}`);\n          trackEvent('setup_failed', { step: 'python_dependencies' });\n          resolve({ success: false, error: `Installation failed: ${output}` });\n        }\n      });\n      \n      process.on('error', (error) => {\n        resolve({ success: false, error: `Process error: ${error.message}` });\n      });\n    });\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\n// ── Auto-updater ──\nfunction setupAutoUpdater() {\n  if (IS_E2E) {\n    sendDebugLog('Auto-updater: skipped (E2E mode)');\n    return;\n  }\n  // Don't check for updates in dev mode\n  if (!app.isPackaged) {\n    sendDebugLog('Auto-updater: skipped (dev mode)');\n    return;\n  }\n\n  autoUpdater.autoDownload = true;\n  autoUpdater.autoInstallOnAppQuit = true;\n\n  autoUpdater.on('checking-for-update', () => {\n    sendDebugLog('Auto-updater: checking for updates...');\n  });\n\n  autoUpdater.on('update-available', (info) => {\n    sendDebugLog(`Auto-updater: update available (v${info.version})`);\n    if (mainWindow) {\n      mainWindow.webContents.send('update-available', { version: info.version });\n    }\n  });\n\n  autoUpdater.on('update-not-available', () => {\n    sendDebugLog('Auto-updater: up to date');\n  });\n\n  autoUpdater.on('download-progress', (progress) => {\n    sendDebugLog(`Auto-updater: downloading ${Math.round(progress.percent)}%`);\n    if (mainWindow) {\n      mainWindow.webContents.send('update-download-progress', { percent: Math.round(progress.percent) });\n    }\n  });\n\n  autoUpdater.on('update-downloaded', (info) => {\n    sendDebugLog(`Auto-updater: v${info.version} ready to install`);\n    if (mainWindow) {\n      mainWindow.webContents.send('update-downloaded', { version: info.version });\n    }\n  });\n\n  autoUpdater.on('error', (err) => {\n    sendDebugLog(`Auto-updater error: ${err.message}`);\n  });\n\n  // Check on launch (after a short delay to not block startup)\n  setTimeout(() => {\n    autoUpdater.checkForUpdates().catch(() => {});\n  }, 10000);\n\n  // Re-check every 30 minutes\n  setInterval(() => {\n    autoUpdater.checkForUpdates().catch(() => {});\n  }, 30 * 60 * 1000);\n}\n\nipcMain.on('install-update', () => {\n  // Bypass the mainWindow 'close' handler's preventDefault+hide so that\n  // quitAndInstall's window-close step actually quits the app. Without this\n  // the app just minimises and Squirrel never gets to apply the update.\n  isQuitting = true;\n  autoUpdater.quitAndInstall(false, true);\n});\n\n// Add IPC handler for sending debug logs to frontend\nfunction sendDebugLog(message) {\n  // Send to main window (both setup console and debug panel)\n  if (mainWindow) {\n    mainWindow.webContents.send('debug-log', message);\n  }\n}\n\nipcMain.handle('setup-ollama-and-model', async () => {\n  try {\n    // Check AI provider -- skip local Ollama setup for remote/cloud\n    try {\n      const providerResult = await runPythonScript('simple_recorder.py', ['get-ai-provider'], true);\n      const providerConfig = JSON.parse(providerResult.trim());\n      if (providerConfig.ai_provider === 'remote' || providerConfig.ai_provider === 'cloud') {\n        sendDebugLog(`AI provider is \"${providerConfig.ai_provider}\" -- skipping local Ollama setup`);\n        return { success: true, skipped: true };\n      }\n    } catch (e) {\n      sendDebugLog(`Could not read AI provider, proceeding with local setup: ${e.message}`);\n    }\n\n    // Check macOS version — bundled Ollama requires macOS 14 (Sonoma) or later\n    const macosRelease = os.release(); // e.g. \"23.1.0\" for macOS 14.1\n    const darwinMajor = parseInt(macosRelease.split('.')[0], 10);\n    // Darwin 23 = macOS 14 (Sonoma), Darwin 22 = macOS 13 (Ventura), etc.\n    if (darwinMajor < 23) {\n      const macosVersion = darwinMajor >= 22 ? '13 (Ventura)' : darwinMajor >= 21 ? '12 (Monterey)' : `(Darwin ${darwinMajor})`;\n      sendDebugLog(`macOS ${macosVersion} detected — Ollama requires macOS 14 (Sonoma) or later`);\n      return { success: false, error: 'StenoAI requires macOS 14 (Sonoma) or later for local AI summarization. Please update your macOS or use a remote Ollama server in Settings.' };\n    }\n\n    sendDebugLog('Locating bundled Ollama...');\n    const finalOllamaPath = await findOllamaExecutable();\n    if (!finalOllamaPath) {\n      sendDebugLog('Error: Bundled Ollama not found');\n      return { success: false, error: 'Bundled Ollama not found. Please reinstall StenoAI.' };\n    }\n    sendDebugLog(`Found bundled Ollama at: ${finalOllamaPath}`);\n\n    // Reuse already-running Ollama if its API is reachable on 11434.\n    // Avoids \"address already in use\" when the user (or a previous launch)\n    // already has Ollama up.\n    const httpProbe = require('http');\n    const ollamaAlreadyRunning = await new Promise((resolve) => {\n      const req = httpProbe.get('http://127.0.0.1:11434/api/tags', { timeout: 1500 }, (res) => {\n        resolve(res.statusCode === 200);\n      });\n      req.on('error', () => resolve(false));\n      req.on('timeout', () => { req.destroy(); resolve(false); });\n    });\n    if (ollamaAlreadyRunning) {\n      sendDebugLog('Ollama already running on 127.0.0.1:11434 — reusing existing instance');\n    }\n\n    let ollamaExited = false;\n    let ollamaExitCode = null;\n    let ollamaDyldError = false;\n    if (!ollamaAlreadyRunning) {\n      sendDebugLog('Starting Ollama service...');\n      sendDebugLog(`$ ${finalOllamaPath} serve`);\n      ollamaProcess = spawn(finalOllamaPath, ['serve'], { detached: true, stdio: ['ignore', 'ignore', 'pipe'], env: getOllamaEnv() });\n      ollamaPid = ollamaProcess.pid;\n      // Write PID file so quit handler can find the process\n      try { require('fs').writeFileSync(path.join(getBackendCwd(), '_internal', 'ollama.pid'), String(ollamaPid)); } catch (_) {}\n      ollamaProcess.stderr.on('data', (data) => {\n        const msg = data.toString().trim();\n        if (msg) sendDebugLog(`Ollama: ${msg}`);\n        if (msg.includes('Symbol not found') || msg.includes('dyld')) ollamaDyldError = true;\n      });\n      ollamaProcess.on('exit', (code) => {\n        ollamaExited = true;\n        ollamaExitCode = code;\n        ollamaPid = null;\n        if (code !== 0 && code !== null) {\n          sendDebugLog(`Ollama process exited with code ${code}`);\n        }\n      });\n      ollamaProcess.unref();\n      ollamaStartedByUs = true;\n    }\n\n    // Wait for Ollama to be ready (poll with early exit detection).\n    // When we reused an existing instance, skip the wait — it's already up.\n    sendDebugLog('Waiting for Ollama service to be ready...');\n    const maxAttempts = ollamaAlreadyRunning ? 1 : 30;\n    let ready = ollamaAlreadyRunning;\n    for (let i = 0; i < maxAttempts && !ready; i++) {\n      await new Promise(resolve => setTimeout(resolve, 1000));\n      if (ollamaExited) {\n        sendDebugLog(`Ollama process died during startup (exit code: ${ollamaExitCode})`);\n        break;\n      }\n      try {\n        const http = require('http');\n        ready = await new Promise((resolve) => {\n          const req = http.get('http://127.0.0.1:11434/api/tags', { timeout: 2000 }, (res) => {\n            resolve(res.statusCode === 200);\n          });\n          req.on('error', () => resolve(false));\n          req.on('timeout', () => { req.destroy(); resolve(false); });\n        });\n        if (ready) {\n          sendDebugLog(`Ollama ready after ${i + 1} seconds`);\n          break;\n        }\n      } catch (e) {\n        // Continue polling\n      }\n    }\n\n    if (!ready) {\n      if (ollamaExited) {\n        if (ollamaDyldError) {\n          return { success: false, error: 'Ollama crashed due to incompatible macOS version. StenoAI requires macOS 14 (Sonoma) or later for local AI. Please update macOS or use a remote Ollama server in Settings.' };\n        }\n        return { success: false, error: `Ollama failed to start (exit code: ${ollamaExitCode}). Check debug logs for details.` };\n      }\n      sendDebugLog('Warning: Ollama may not be fully ready, attempting pull anyway...');\n    }\n    \n    sendDebugLog('Downloading AI model (this may take several minutes)...');\n    sendDebugLog('POST http://127.0.0.1:11434/api/pull {name: \"llama3.2:3b\"}');\n\n    const http = require('http');\n    return new Promise((resolve) => {\n      const postData = JSON.stringify({ name: 'llama3.2:3b' });\n      const req = http.request({\n        hostname: '127.0.0.1',\n        port: 11434,\n        path: '/api/pull',\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        timeout: 600000\n      }, (res) => {\n        let lastStatus = '';\n        res.on('data', (chunk) => {\n          // Ollama streams newline-delimited JSON\n          const lines = chunk.toString().split('\\n').filter(Boolean);\n          for (const line of lines) {\n            try {\n              const json = JSON.parse(line);\n              if (json.error) {\n                sendDebugLog(`Pull error: ${json.error}`);\n                return;\n              }\n              // Log progress without spamming duplicate status\n              const status = json.status || '';\n              if (json.total && json.completed) {\n                const pct = Math.round((json.completed / json.total) * 100);\n                const msg = `${status} ${pct}%`;\n                if (msg !== lastStatus) {\n                  sendDebugLog(msg);\n                  lastStatus = msg;\n                }\n              } else if (status !== lastStatus) {\n                sendDebugLog(status);\n                lastStatus = status;\n              }\n            } catch (e) {\n              // Non-JSON line, log as-is\n              sendDebugLog(chunk.toString().trim());\n            }\n          }\n        });\n\n        res.on('end', async () => {\n          if (res.statusCode === 200) {\n            sendDebugLog('AI model download completed successfully');\n            try {\n              await runPythonScript('simple_recorder.py', ['set-model', 'llama3.2:3b'], true);\n            } catch (e) {\n              // Non-fatal -- config reset is best-effort\n            }\n            trackEvent('setup_completed', { step: 'ollama_and_model' });\n            resolve({ success: true, message: 'Ollama and AI model ready' });\n          } else {\n            sendDebugLog(`AI model download failed with status: ${res.statusCode}`);\n            trackEvent('setup_failed', { step: 'ollama_and_model' });\n            resolve({ success: false, error: 'Failed to download AI model', details: `HTTP ${res.statusCode}` });\n          }\n        });\n      });\n\n      req.on('error', (error) => {\n        sendDebugLog(`Pull request error: ${error.message}`);\n        resolve({ success: false, error: 'Failed to download AI model', details: error.message });\n      });\n\n      req.on('timeout', () => {\n        req.destroy();\n        sendDebugLog('Model pull timed out after 10 minutes');\n        resolve({ success: false, error: 'Model download timed out' });\n      });\n\n      req.write(postData);\n      req.end();\n    });\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('setup-whisper', async () => {\n  try {\n    // Download whisper model using the bundled backend\n    const backendPath = getBackendPath();\n    sendDebugLog('Downloading Whisper transcription model (~500MB)...');\n    sendDebugLog(`$ ${backendPath} download-whisper-model`);\n\n    return new Promise((resolve) => {\n      const process = spawn(backendPath, ['download-whisper-model'], {\n        stdio: 'pipe'\n      });\n\n      process.stdout.on('data', (data) => {\n        const text = data.toString().trim();\n        if (text) sendDebugLog(text);\n      });\n\n      process.stderr.on('data', (data) => {\n        const text = data.toString().trim();\n        if (text) sendDebugLog('STDERR: ' + text);\n      });\n\n      process.on('close', (code) => {\n        if (code === 0) {\n          sendDebugLog('Whisper model downloaded successfully');\n          resolve({ success: true, message: 'Whisper model ready' });\n        } else {\n          sendDebugLog(`Whisper model download failed with exit code: ${code}`);\n          resolve({ success: false, error: 'Failed to download Whisper model' });\n        }\n      });\n\n      process.on('error', (error) => {\n        sendDebugLog(`Process error: ${error.message}`);\n        resolve({ success: false, error: error.message });\n      });\n    });\n\n    // Legacy code below - kept for reference but never runs\n    const projectRoot = path.join(__dirname, '..');\n    const pythonPath = path.join(projectRoot, 'venv', 'bin', 'python');\n\n    sendDebugLog('Installing Whisper speech recognition...');\n    sendDebugLog(`$ ${pythonPath} -m pip install openai-whisper`);\n    \n    return new Promise((resolve) => {\n      const process = spawn(pythonPath, ['-m', 'pip', 'install', 'openai-whisper'], {\n        cwd: projectRoot,\n        stdio: 'pipe'\n      });\n      \n      let output = '';\n      \n      process.stdout.on('data', (data) => {\n        const text = data.toString().trim();\n        if (text) {\n          sendDebugLog(text);\n          output += text;\n        }\n      });\n      \n      process.stderr.on('data', (data) => {\n        const text = data.toString().trim();\n        if (text) {\n          sendDebugLog('STDERR: ' + text);\n          output += text;\n        }\n      });\n      \n      process.on('close', (code) => {\n        if (code === 0) {\n          sendDebugLog('Whisper installation completed successfully');\n          resolve({ success: true, message: 'Whisper installed successfully' });\n        } else {\n          sendDebugLog(`Whisper installation failed with exit code: ${code}`);\n          resolve({ success: false, error: `Whisper installation failed: ${output}` });\n        }\n      });\n      \n      process.on('error', (error) => {\n        resolve({ success: false, error: `Process error: ${error.message}` });\n      });\n    });\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('setup-test', async () => {\n  try {\n    sendDebugLog('Running system test...');\n    sendDebugLog('$ python simple_recorder.py test');\n    \n    // Test the complete system\n    const result = await runPythonScript('simple_recorder.py', ['test']);\n    \n    // Log the full result to debug console\n    result.split('\\n').forEach(line => {\n      if (line.trim()) sendDebugLog(line.trim());\n    });\n    \n    if (result.includes('System check passed') || result.includes('SUCCESS')) {\n      sendDebugLog('System test completed successfully');\n      trackEvent('setup_completed', { step: 'system_test' });\n      return { success: true, message: 'System test passed' };\n    } else {\n      // Extract specific error details from the output\n      const errorLines = result.split('\\n').filter(line => line.includes('ERROR:'));\n      const specificError = errorLines.length > 0 ? errorLines[errorLines.length - 1].replace('ERROR: ', '') : 'Unknown error';\n      sendDebugLog(`System test failed: ${specificError}`);\n      trackEvent('setup_failed', { step: 'system_test' });\n      return { success: false, error: `System test failed: ${specificError}`, details: result };\n    }\n  } catch (error) {\n    sendDebugLog(`System test error: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\n// Settings window IPC handlers  \nipcMain.handle('trigger-setup-wizard', async () => {\n  try {\n    console.log('🔧 Starting setup wizard from settings...');\n    \n    // Trigger the main window's setup flow\n    if (mainWindow) {\n      mainWindow.webContents.send('trigger-setup-flow');\n    }\n    \n    return { success: true, message: 'Setup wizard triggered in main window' };\n  } catch (error) {\n    console.error('Setup wizard failed:', error);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-app-version', async () => {\n  try {\n    const packagePath = path.join(__dirname, 'package.json');\n    const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));\n    return {\n      success: true,\n      version: packageContent.version,\n      name: packageContent.productName || packageContent.name\n    };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\n// Storage path handlers\nipcMain.handle('get-storage-path', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-storage-path'], true);\n    const jsonData = JSON.parse(result.trim());\n    // Python only returns the user's custom path (empty string when not set).\n    // Augment with the platform default so the renderer can show \"where your\n    // data actually lives\" without hardcoding the path. custom_path mirrors\n    // storage_path but is null when empty for cleaner conditionals.\n    const customPath = jsonData.storage_path && jsonData.storage_path.trim()\n      ? jsonData.storage_path\n      : null;\n    const defaultPath = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai');\n    return {\n      success: true,\n      storage_path: customPath || defaultPath,\n      custom_path: customPath,\n      default_path: defaultPath,\n    };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-storage-path', async (event, storagePath) => {\n  try {\n    const args = ['set-storage-path'];\n    if (storagePath) {\n      args.push(storagePath);\n    }\n    const result = await runPythonScript('simple_recorder.py', args);\n    // Update cached custom path for file validation\n    _cachedCustomStoragePath = storagePath || null;\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      return JSON.parse(jsonMatch[0]);\n    }\n    return { success: true, storage_path: storagePath };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('select-storage-folder', async () => {\n  try {\n    const result = await dialog.showOpenDialog(mainWindow, {\n      properties: ['openDirectory', 'createDirectory'],\n      title: 'Choose storage location for StenoAI data',\n      buttonLabel: 'Select Folder'\n    });\n\n    if (!result.canceled && result.filePaths.length > 0) {\n      return { success: true, folderPath: result.filePaths[0] };\n    }\n    return { success: false, error: 'No folder selected' };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\n// Folder management handlers\nipcMain.handle('list-folders', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['list-folders'], true);\n    return { success: true, ...JSON.parse(result.trim()) };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('create-folder', async (event, name, color) => {\n  try {\n    const args = ['create-folder', name];\n    if (color) args.push('--color', color);\n    const result = await runPythonScript('simple_recorder.py', args);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('rename-folder', async (event, folderId, name) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['rename-folder', folderId, name]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('update-folder-icon', async (event, folderId, icon) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['update-folder-icon', folderId, icon]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('delete-folder', async (event, folderId) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['delete-folder', folderId]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('reorder-folders', async (event, folderIds) => {\n  try {\n    const args = ['reorder-folders', ...folderIds];\n    const result = await runPythonScript('simple_recorder.py', args);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('add-meeting-to-folder', async (event, summaryFile, folderId) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['add-meeting-to-folder', summaryFile, folderId]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('remove-meeting-from-folder', async (event, summaryFile, folderId) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['remove-meeting-from-folder', summaryFile, folderId]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-ai-prompts', async () => {\n  try {\n    // Read the summarization prompt from the Python backend\n    const summarizerPath = path.join(__dirname, '..', 'src', 'summarizer.py');\n    \n    if (fs.existsSync(summarizerPath)) {\n      const content = fs.readFileSync(summarizerPath, 'utf8');\n      \n      // Extract the full prompt from the _create_permissive_prompt method\n      const promptMatch = content.match(/def _create_permissive_prompt[\\s\\S]*?return f\"\"\"([\\s\\S]*?)\"\"\"/);\n      \n      if (promptMatch) {\n        return {\n          success: true,\n          summarization: promptMatch[1].trim()\n        };\n      }\n    }\n    \n    return {\n      success: true,\n      summarization: 'Prompt not found in summarizer.py'\n    };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\n// Helper function to ensure Ollama service is running\nasync function ensureOllamaRunning() {\n  try {\n    // Check if Ollama service is responding\n    const http = require('http');\n    const response = await new Promise((resolve) => {\n      const req = http.get('http://127.0.0.1:11434/api/version', { timeout: 3000 }, (res) => {\n        resolve(res.statusCode === 200);\n      });\n      req.on('error', () => resolve(false));\n      req.on('timeout', () => { req.destroy(); resolve(false); });\n    });\n\n    if (response) {\n      return true; // Service is running\n    }\n\n    // Service not running, try to start it\n    // Check macOS version — bundled Ollama requires macOS 14 (Sonoma) or later\n    const macRelease = os.release();\n    if (parseInt(macRelease.split('.')[0], 10) < 23) {\n      sendDebugLog('macOS version too old for bundled Ollama — requires macOS 14 (Sonoma) or later');\n      return false;\n    }\n\n    const ollamaPath = await findOllamaExecutable();\n    if (!ollamaPath) {\n      return false;\n    }\n\n    // Start Ollama service in background with proper env vars for dylibs\n    ollamaProcess = spawn(ollamaPath, ['serve'], { detached: true, stdio: 'ignore', env: getOllamaEnv() });\n    ollamaPid = ollamaProcess.pid;\n    try { require('fs').writeFileSync(path.join(getBackendCwd(), '_internal', 'ollama.pid'), String(ollamaPid)); } catch (_) {}\n    ollamaProcess.on('exit', () => { ollamaPid = null; });\n    ollamaProcess.unref();\n    ollamaStartedByUs = true;\n\n    // Wait for service to start\n    await new Promise(resolve => setTimeout(resolve, 2000));\n    return true;\n  } catch (error) {\n    console.error('Error ensuring Ollama is running:', error);\n    return false;\n  }\n}\n\n// Check if Ollama is installed (for setup wizard)\nipcMain.handle('check-ollama-installed', async () => {\n  try {\n    const ollamaPath = await findOllamaExecutable();\n    if (!ollamaPath) {\n      return { success: true, installed: false };\n    }\n    return { success: true, installed: true, path: ollamaPath };\n  } catch (error) {\n    return { success: false, installed: false, error: error.message };\n  }\n});\n\n// Model management handlers\nipcMain.handle('check-model-installed', async (event, modelName) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['check-model', modelName]);\n    // Parse the last JSON line from output (skip any log lines)\n    const lines = result.trim().split('\\n');\n    for (let i = lines.length - 1; i >= 0; i--) {\n      try {\n        const data = JSON.parse(lines[i]);\n        return { success: true, installed: data.installed };\n      } catch (e) {\n        continue;\n      }\n    }\n    return { success: false, installed: false, error: 'Could not parse backend response' };\n  } catch (error) {\n    return { success: false, installed: false, error: error.message };\n  }\n});\n\nipcMain.handle('list-models', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['list-models']);\n    const jsonData = JSON.parse(result);\n\n    return {\n      success: true,\n      ...jsonData\n    };\n  } catch (error) {\n    sendDebugLog(`Error listing models: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-current-model', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-model']);\n    const jsonData = JSON.parse(result);\n\n    return {\n      success: true,\n      ...jsonData\n    };\n  } catch (error) {\n    sendDebugLog(`Error getting current model: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-model', async (event, modelName) => {\n  try {\n    sendDebugLog(`Setting model to: ${modelName}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-model', modelName]);\n\n    // Extract JSON from output (might have other text before it)\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      const jsonData = JSON.parse(jsonMatch[0]);\n      trackEvent('model_changed', { model: modelName });\n      return jsonData;\n    }\n\n    trackEvent('model_changed', { model: modelName });\n    return { success: true, model: modelName };\n  } catch (error) {\n    sendDebugLog(`Error setting model: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\n\n\nipcMain.handle('get-whisper-model', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-whisper-model'], true);\n    return JSON.parse(result.trim());\n  } catch (e) { return { success: false, error: e.message }; }\n});\n\nipcMain.handle('set-whisper-model', async (event, modelSize) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-whisper-model', modelSize]);\n    return JSON.parse(result.trim());\n  } catch (e) { return { success: false, error: e.message }; }\n});\n\nipcMain.handle('get-keep-recordings', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-keep-recordings'], true);\n    return JSON.parse(result.trim());\n  } catch (e) { return { success: false, error: e.message }; }\n});\n\nipcMain.handle('set-keep-recordings', async (event, enabled) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-keep-recordings', enabled.toString()]);\n    return JSON.parse(result.trim());\n  } catch (e) { return { success: false, error: e.message }; }\n});\n\nipcMain.handle('get-notifications', handleGetNotifications);\n\nipcMain.handle('set-notifications', async (event, enabled) => {\n  try {\n    sendDebugLog(`Setting notifications to: ${enabled}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-notifications', enabled ? 'True' : 'False']);\n\n    // Extract JSON from output\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      const jsonData = JSON.parse(jsonMatch[0]);\n      return jsonData;\n    }\n\n    return { success: true, notifications_enabled: enabled };\n  } catch (error) {\n    sendDebugLog(`Error setting notifications: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-telemetry', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-telemetry']);\n    const jsonData = JSON.parse(result);\n\n    return {\n      success: true,\n      ...jsonData\n    };\n  } catch (error) {\n    sendDebugLog(`Error getting telemetry settings: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-telemetry', async (event, enabled) => {\n  try {\n    sendDebugLog(`Setting telemetry to: ${enabled}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-telemetry', enabled ? 'True' : 'False']);\n\n    // Update in-memory state\n    telemetryEnabled = enabled;\n\n    if (enabled && !posthogClient) {\n      posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST });\n      console.log('Telemetry re-enabled');\n    } else if (!enabled && posthogClient) {\n      await shutdownTelemetry();\n      console.log('Telemetry disabled');\n    }\n\n    // Extract JSON from output\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      const jsonData = JSON.parse(jsonMatch[0]);\n      return jsonData;\n    }\n\n    return { success: true, telemetry_enabled: enabled };\n  } catch (error) {\n    sendDebugLog(`Error setting telemetry: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\n// Hide dock icon IPC handlers\nipcMain.handle('get-dock-icon', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-dock-icon']);\n    const jsonData = JSON.parse(result);\n\n    return {\n      success: true,\n      ...jsonData\n    };\n  } catch (error) {\n    sendDebugLog(`Error getting dock icon settings: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-dock-icon', async (event, hidden) => {\n  try {\n    sendDebugLog(`Setting hide dock icon to: ${hidden}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-dock-icon', hidden ? 'True' : 'False']);\n\n    // Apply immediately\n    if (process.platform === 'darwin' && app.dock) {\n      if (hidden) {\n        app.dock.hide();\n      } else {\n        app.dock.show();\n      }\n    }\n\n    // Extract JSON from output\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      const jsonData = JSON.parse(jsonMatch[0]);\n      return jsonData;\n    }\n\n    return { success: true, hide_dock_icon: hidden };\n  } catch (error) {\n    sendDebugLog(`Error setting dock icon: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\n// System audio capture IPC handlers\nipcMain.handle('get-system-audio', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-system-audio'], true);\n    const jsonData = JSON.parse(result);\n    return { success: true, ...jsonData };\n  } catch (error) {\n    sendDebugLog(`Error getting system audio setting: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-system-audio', async (event, enabled) => {\n  try {\n    sendDebugLog(`Setting system audio to: ${enabled}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-system-audio', enabled ? 'True' : 'False']);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      return JSON.parse(jsonMatch[0]);\n    }\n    return { success: true, system_audio_enabled: enabled };\n  } catch (error) {\n    sendDebugLog(`Error setting system audio: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\n// Language IPC handlers\nipcMain.handle('get-language', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-language'], true);\n    const jsonData = JSON.parse(result);\n    return { success: true, ...jsonData };\n  } catch (error) {\n    sendDebugLog(`Error getting language setting: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-language', async (event, languageCode) => {\n  try {\n    sendDebugLog(`Setting language to: ${languageCode}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-language', languageCode]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) {\n      return JSON.parse(jsonMatch[0]);\n    }\n    return { success: true, language: languageCode };\n  } catch (error) {\n    sendDebugLog(`Error setting language: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-user-name', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-user-name'], true);\n    const jsonData = JSON.parse(result.trim());\n    return { success: true, ...jsonData };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-user-name', async (event, name) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-user-name', String(name ?? '')]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: true, user_name: String(name ?? '').trim() };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\n// AI Provider IPC handlers\n\nfunction getCloudKeyPath() {\n  return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.cloud-api-key');\n}\n\nfunction saveCloudApiKey(key) {\n  try {\n    const keyDir = path.dirname(getCloudKeyPath());\n    if (!fs.existsSync(keyDir)) {\n      fs.mkdirSync(keyDir, { recursive: true });\n    }\n    const encrypted = safeStorage.encryptString(key);\n    fs.writeFileSync(getCloudKeyPath(), encrypted);\n    return true;\n  } catch (error) {\n    console.error('Failed to save cloud API key:', error.message);\n    return false;\n  }\n}\n\nfunction loadCloudApiKey() {\n  try {\n    const keyPath = getCloudKeyPath();\n    if (!fs.existsSync(keyPath)) return null;\n    const encrypted = fs.readFileSync(keyPath);\n    return safeStorage.decryptString(encrypted);\n  } catch (error) {\n    console.error('Failed to load cloud API key:', error.message);\n    return null;\n  }\n}\n\nfunction hasCloudApiKey() {\n  return fs.existsSync(getCloudKeyPath());\n}\n\nipcMain.handle('get-ai-provider', async () => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['get-ai-provider'], true);\n    const jsonData = JSON.parse(result.trim());\n    // Override cloud_api_key_set with safeStorage check\n    jsonData.cloud_api_key_set = hasCloudApiKey();\n    return { success: true, ...jsonData };\n  } catch (error) {\n    sendDebugLog(`Error getting AI provider: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-ai-provider', async (event, provider) => {\n  try {\n    sendDebugLog(`Setting AI provider to: ${provider}`);\n    const result = await runPythonScript('simple_recorder.py', ['set-ai-provider', provider]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: true, ai_provider: provider };\n  } catch (error) {\n    sendDebugLog(`Error setting AI provider: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-remote-ollama-url', async (event, url) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-remote-ollama-url', url]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-cloud-api-url', async (event, url) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-cloud-api-url', url]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-cloud-api-key', async (event, key) => {\n  try {\n    const saved = saveCloudApiKey(key);\n    return { success: saved, cloud_api_key_set: saved };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-cloud-provider', async (event, provider) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-cloud-provider', provider]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('set-cloud-model', async (event, model) => {\n  try {\n    const result = await runPythonScript('simple_recorder.py', ['set-cloud-model', model]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('test-remote-ollama', async (event, url) => {\n  try {\n    sendDebugLog(`Testing remote Ollama at: ${url}`);\n    const result = await runPythonScript('simple_recorder.py', ['test-remote-ollama', url]);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: false, error: 'No response' };\n  } catch (error) {\n    sendDebugLog(`Remote Ollama test failed: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('test-cloud-api', async () => {\n  try {\n    sendDebugLog('Testing cloud API connection...');\n    const apiKey = loadCloudApiKey();\n    const env = apiKey ? { STENOAI_CLOUD_API_KEY: apiKey } : {};\n    const result = await runPythonScript('simple_recorder.py', ['test-cloud-api'], false, env);\n    const jsonMatch = result.match(/\\{.*\\}/s);\n    if (jsonMatch) return JSON.parse(jsonMatch[0]);\n    return { success: false, error: 'No response' };\n  } catch (error) {\n    sendDebugLog(`Cloud API test failed: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('get-recordings-dir', async () => {\n  try {\n    // Get recordings directory from Python config\n    const result = await runPythonScript('simple_recorder.py', ['get-storage-path'], true);\n    const jsonData = JSON.parse(result.trim());\n\n    let recordingsDir;\n    if (jsonData.storage_path) {\n      recordingsDir = path.join(jsonData.storage_path, 'recordings');\n    } else if (app.isPackaged) {\n      recordingsDir = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', 'recordings');\n    } else {\n      recordingsDir = path.join(__dirname, '..', 'recordings');\n    }\n\n    // Ensure directory exists\n    if (!fs.existsSync(recordingsDir)) {\n      fs.mkdirSync(recordingsDir, { recursive: true });\n    }\n\n    return { success: true, path: recordingsDir };\n  } catch (error) {\n    sendDebugLog(`Error getting recordings dir: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('process-system-audio-recording', async (event, audioFilePath, sessionName) => {\n  try {\n    sendDebugLog(`Queuing system audio recording for processing: ${audioFilePath}`);\n\n    // Validate file path\n    const allowedBaseDirs = getAllowedBaseDirs();\n    if (!validateSafeFilePath(audioFilePath, allowedBaseDirs)) {\n      return { success: false, error: 'Invalid file path' };\n    }\n\n    if (!fs.existsSync(audioFilePath)) {\n      return { success: false, error: 'Audio file not found' };\n    }\n\n    const actualSessionName = sessionName || 'Meeting';\n\n    // Check for user notes file\n    const safeName = actualSessionName.replace(/[^a-zA-Z0-9_-]/g, '_');\n    const notesFile = path.join(getBackendCwd(), '_internal', 'output', `${safeName}_notes.txt`);\n    const notesPath = fs.existsSync(notesFile) ? notesFile : undefined;\n\n    // Use the existing processing queue to avoid concurrent Ollama/Whisper runs\n    addToProcessingQueue(audioFilePath, actualSessionName, notesPath);\n\n    trackEvent('recording_stopped', { recording_mode: 'system_audio' });\n    return { success: true, message: 'Added to processing queue' };\n  } catch (error) {\n    sendDebugLog(`Error queuing system audio: ${error.message}`);\n    trackEvent('error_occurred', { error_type: 'process_system_audio' });\n    return { success: false, error: error.message };\n  }\n});\n\n// Track system audio recording state for tray icon\nipcMain.on('system-audio-recording-state', (event, isRecording) => {\n  systemAudioRecordingActive = isRecording;\n  updateTrayIcon(isRecording);\n  updateTrayMenu();\n});\n\nipcMain.handle('pull-model', async (event, modelName) => {\n  try {\n    sendDebugLog(`Pulling model: ${modelName}`);\n    sendDebugLog('This may take several minutes...');\n\n    return new Promise((resolve) => {\n      const proc = spawn(getBackendPath(), ['pull-model', modelName], {\n        cwd: getBackendCwd()\n      });\n\n      let lastStdoutLine = '';\n\n      proc.stdout.on('data', (data) => {\n        const output = data.toString().trim();\n        sendDebugLog(output);\n        if (output) lastStdoutLine = output;\n\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.webContents.send('model-pull-progress', {\n            model: modelName,\n            progress: output\n          });\n        }\n      });\n\n      proc.stderr.on('data', (data) => {\n        const output = data.toString().trim();\n        sendDebugLog(output);\n\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.webContents.send('model-pull-progress', {\n            model: modelName,\n            progress: output\n          });\n        }\n      });\n\n      proc.on('close', (code) => {\n        // The backend prints a JSON result as the last stdout line.\n        // Check it even on exit code 0, since the Python CLI may\n        // catch errors and still exit cleanly.\n        let pullResult = null;\n        try { pullResult = JSON.parse(lastStdoutLine); } catch (_) {}\n\n        const succeeded = code === 0 && (!pullResult || pullResult.success !== false);\n\n        if (succeeded) {\n          sendDebugLog(`Successfully pulled model: ${modelName}`);\n\n          if (mainWindow && !mainWindow.isDestroyed()) {\n            mainWindow.webContents.send('model-pull-complete', {\n              model: modelName,\n              success: true\n            });\n          }\n\n          resolve({ success: true, model: modelName });\n        } else {\n          const errorMsg = (pullResult && pullResult.error) || `Process exited with code ${code}`;\n          sendDebugLog(`Failed to pull model: ${modelName} - ${errorMsg}`);\n\n          if (mainWindow && !mainWindow.isDestroyed()) {\n            mainWindow.webContents.send('model-pull-complete', {\n              model: modelName,\n              success: false,\n              error: errorMsg\n            });\n          }\n\n          resolve({ success: false, error: errorMsg });\n        }\n      });\n\n      proc.on('error', (error) => {\n        sendDebugLog(`Error pulling model: ${error.message}`);\n\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.webContents.send('model-pull-complete', {\n            model: modelName,\n            success: false,\n            error: error.message\n          });\n        }\n\n        resolve({ success: false, error: error.message });\n      });\n    });\n  } catch (error) {\n    sendDebugLog(`Error in pull-model handler: ${error.message}`);\n    return { success: false, error: error.message };\n  }\n});\n\n// Helper to build env vars for running the bundled Ollama binary directly\nfunction getOllamaEnv() {\n  let ollamaDir;\n  if (app.isPackaged) {\n    ollamaDir = path.join(process.resourcesPath, 'stenoai', '_internal', 'ollama');\n  } else {\n    ollamaDir = path.join(__dirname, '..', 'bin');\n  }\n  const env = { ...process.env };\n  const existing = env.DYLD_LIBRARY_PATH || '';\n  env.DYLD_LIBRARY_PATH = existing ? `${ollamaDir}:${existing}` : ollamaDir;\n  env.MLX_METAL_PATH = path.join(ollamaDir, 'mlx.metallib');\n  return env;\n}\n\n// Helper function to find Ollama executable (bundled only)\nasync function findOllamaExecutable() {\n  let bundledOllamaPath;\n  if (app.isPackaged) {\n    // Production: bundled inside PyInstaller _internal directory\n    bundledOllamaPath = path.join(process.resourcesPath, 'stenoai', '_internal', 'ollama', 'ollama');\n  } else {\n    // Development: in project bin/ directory\n    bundledOllamaPath = path.join(__dirname, '..', 'bin', 'ollama');\n  }\n\n  if (fs.existsSync(bundledOllamaPath)) {\n    console.log(`Using bundled Ollama: ${bundledOllamaPath}`);\n    return bundledOllamaPath;\n  }\n\n  console.error(`Bundled Ollama not found at: ${bundledOllamaPath}`);\n  return null;\n}\n\n// Update checking functionality\nasync function checkForUpdates() {\n  return new Promise((resolve) => {\n    const options = {\n      hostname: 'api.github.com',\n      path: '/repos/ruzin/stenoai/releases/latest',\n      method: 'GET',\n      headers: {\n        'User-Agent': 'StenoAI-Updater'\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      \n      res.on('data', (chunk) => {\n        data += chunk;\n      });\n      \n      res.on('end', () => {\n        try {\n          const release = JSON.parse(data);\n          const latestVersion = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present\n          \n          // Get current version from package.json\n          const packagePath = path.join(__dirname, 'package.json');\n          const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));\n          const currentVersion = packageContent.version;\n          \n          console.log(`Current version: ${currentVersion}, Latest version: ${latestVersion}`);\n          \n          // Simple version comparison (works for semantic versioning)\n          const isUpdateAvailable = compareVersions(currentVersion, latestVersion) < 0;\n          \n          resolve({\n            success: true,\n            updateAvailable: isUpdateAvailable,\n            currentVersion: currentVersion,\n            latestVersion: latestVersion,\n            releaseUrl: release.html_url,\n            releaseName: release.name || `Version ${latestVersion}`,\n            downloadUrl: getDownloadUrl(release.assets)\n          });\n        } catch (error) {\n          console.error('Error parsing GitHub API response:', error);\n          resolve({ success: false, error: 'Failed to parse update data' });\n        }\n      });\n    });\n    \n    req.on('error', (error) => {\n      console.error('Error checking for updates:', error);\n      resolve({ success: false, error: error.message });\n    });\n    \n    req.setTimeout(10000, () => {\n      req.destroy();\n      resolve({ success: false, error: 'Update check timeout' });\n    });\n    \n    req.end();\n  });\n}\n\nfunction compareVersions(current, latest) {\n  const currentParts = current.split('.').map(Number);\n  const latestParts = latest.split('.').map(Number);\n  \n  for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {\n    const currentPart = currentParts[i] || 0;\n    const latestPart = latestParts[i] || 0;\n    \n    if (currentPart < latestPart) return -1;\n    if (currentPart > latestPart) return 1;\n  }\n  \n  return 0;\n}\n\nfunction getDownloadUrl(assets) {\n  // Find the appropriate download URL based on platform/architecture\n  const platform = process.platform;\n  const arch = process.arch;\n  \n  if (platform === 'darwin') {\n    // Look for macOS DMG files\n    const armAsset = assets.find(asset => \n      asset.name.includes('arm64') && asset.name.includes('dmg')\n    );\n    const intelAsset = assets.find(asset => \n      asset.name.includes('x64') && asset.name.includes('dmg')\n    );\n    \n    // Prefer ARM64 for Apple Silicon, fallback to Intel\n    if (arch === 'arm64' && armAsset) return armAsset.browser_download_url;\n    if (intelAsset) return intelAsset.browser_download_url;\n    if (armAsset) return armAsset.browser_download_url;\n  }\n  \n  // Fallback to first asset or releases page\n  return assets.length > 0 ? assets[0].browser_download_url : null;\n}\n\nipcMain.handle('check-for-updates', async () => {\n  return await checkForUpdates();\n});\n\nipcMain.handle('check-announcements', async () => {\n  const packagePath = path.join(__dirname, 'package.json');\n  const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));\n  const currentVersion = packageContent.version;\n\n  // Try local file first (for development/testing)\n  const localPath = path.join(__dirname, '..', 'announcements.json');\n  if (fs.existsSync(localPath)) {\n    try {\n      const localData = JSON.parse(fs.readFileSync(localPath, 'utf8'));\n      console.log('Loaded announcements from local file');\n      return {\n        success: true,\n        announcements: localData.announcements || [],\n        currentVersion\n      };\n    } catch (error) {\n      console.error('Error reading local announcements.json:', error);\n    }\n  }\n\n  // Fall back to remote\n  return new Promise((resolve) => {\n    const options = {\n      hostname: 'raw.githubusercontent.com',\n      path: '/ruzin/stenoai/main/announcements.json',\n      method: 'GET',\n      headers: {\n        'User-Agent': 'StenoAI-App'\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n\n      res.on('data', (chunk) => {\n        data += chunk;\n      });\n\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          resolve({\n            success: true,\n            announcements: parsed.announcements || [],\n            currentVersion\n          });\n        } catch (error) {\n          console.error('Error parsing announcements:', error);\n          resolve({ success: false, error: 'Failed to parse announcements' });\n        }\n      });\n    });\n\n    req.on('error', (error) => {\n      console.error('Error fetching announcements:', error);\n      resolve({ success: false, error: error.message });\n    });\n\n    req.setTimeout(10000, () => {\n      req.destroy();\n      resolve({ success: false, error: 'Announcements fetch timeout' });\n    });\n\n    req.end();\n  });\n});\n\nipcMain.handle('open-release-page', async (event, url) => {\n  try {\n    if (typeof url !== 'string' || !url) {\n      return { success: false, error: 'invalid url' };\n    }\n    let parsed;\n    try { parsed = new URL(url); } catch {\n      return { success: false, error: 'invalid url' };\n    }\n    // Release pages live on github.com -- restrict to that origin so a\n    // compromised renderer cannot launch arbitrary external URLs through\n    // this channel.\n    if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') {\n      return { success: false, error: 'unsupported url' };\n    }\n    await shell.openExternal(url);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\n// Generic external-URL opener for renderer-triggered links (e.g. meeting\n// join URLs on Home). Http/https only — rejects custom schemes so a\n// compromised renderer cannot launch arbitrary protocol handlers.\nipcMain.handle('open-external', async (event, url) => {\n  try {\n    if (typeof url !== 'string' || !url) {\n      return { success: false, error: 'invalid url' };\n    }\n    let parsed;\n    try { parsed = new URL(url); } catch {\n      return { success: false, error: 'invalid url' };\n    }\n    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n      return { success: false, error: 'unsupported scheme' };\n    }\n    await shell.openExternal(url);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n// ── Google Calendar: Token Storage ──────────────────────────────────────\n\nfunction getTokenFilePath() {\n  return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.google-tokens');\n}\n\nfunction saveGoogleTokens(tokens) {\n  try {\n    const tokenDir = path.dirname(getTokenFilePath());\n    if (!fs.existsSync(tokenDir)) {\n      fs.mkdirSync(tokenDir, { recursive: true });\n    }\n    const encrypted = safeStorage.encryptString(JSON.stringify(tokens));\n    fs.writeFileSync(getTokenFilePath(), encrypted);\n    console.log('Google tokens saved');\n  } catch (error) {\n    console.error('Failed to save Google tokens:', error.message);\n  }\n}\n\nfunction loadGoogleTokens() {\n  try {\n    const tokenPath = getTokenFilePath();\n    if (!fs.existsSync(tokenPath)) return null;\n    const encrypted = fs.readFileSync(tokenPath);\n    const decrypted = safeStorage.decryptString(encrypted);\n    return JSON.parse(decrypted);\n  } catch (error) {\n    console.error('Failed to load Google tokens:', error.message);\n    return null;\n  }\n}\n\nfunction deleteGoogleTokens() {\n  try {\n    const tokenPath = getTokenFilePath();\n    if (fs.existsSync(tokenPath)) {\n      fs.unlinkSync(tokenPath);\n      console.log('Google tokens deleted');\n    }\n  } catch (error) {\n    console.error('Failed to delete Google tokens:', error.message);\n  }\n}\n\n// ── Outlook Calendar: Token Storage ─────────────────────────────────────\n\nfunction getOutlookTokenFilePath() {\n  return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.outlook-tokens');\n}\n\nfunction saveOutlookTokens(tokens) {\n  try {\n    const tokenDir = path.dirname(getOutlookTokenFilePath());\n    if (!fs.existsSync(tokenDir)) {\n      fs.mkdirSync(tokenDir, { recursive: true });\n    }\n    const encrypted = safeStorage.encryptString(JSON.stringify(tokens));\n    fs.writeFileSync(getOutlookTokenFilePath(), encrypted);\n    console.log('Outlook tokens saved');\n  } catch (error) {\n    console.error('Failed to save Outlook tokens:', error.message);\n  }\n}\n\nfunction loadOutlookTokens() {\n  try {\n    const tokenPath = getOutlookTokenFilePath();\n    if (!fs.existsSync(tokenPath)) return null;\n    const encrypted = fs.readFileSync(tokenPath);\n    const decrypted = safeStorage.decryptString(encrypted);\n    return JSON.parse(decrypted);\n  } catch (error) {\n    console.error('Failed to load Outlook tokens:', error.message);\n    return null;\n  }\n}\n\nfunction deleteOutlookTokens() {\n  try {\n    const tokenPath = getOutlookTokenFilePath();\n    if (fs.existsSync(tokenPath)) {\n      fs.unlinkSync(tokenPath);\n      console.log('Outlook tokens deleted');\n    }\n  } catch (error) {\n    console.error('Failed to delete Outlook tokens:', error.message);\n  }\n}\n\n// ── Google Calendar: OAuth2 Flow with PKCE ──────────────────────────────\n\nfunction startGoogleAuth() {\n  return new Promise((resolve, reject) => {\n    // Generate PKCE code verifier and challenge\n    const codeVerifier = crypto.randomBytes(32).toString('base64url');\n    const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');\n    const state = crypto.randomBytes(16).toString('hex');\n    let timeoutId = null;\n\n    // Start temporary HTTP server on loopback for OAuth redirect\n    const server = http.createServer(async (req, res) => {\n      try {\n        const reqUrl = new URL(req.url, `http://127.0.0.1`);\n        if (!reqUrl.pathname.startsWith('/callback')) {\n          res.writeHead(404);\n          res.end();\n          return;\n        }\n\n        const code = reqUrl.searchParams.get('code');\n        const error = reqUrl.searchParams.get('error');\n        const returnedState = reqUrl.searchParams.get('state');\n\n        if (returnedState !== state) {\n          res.writeHead(400, { 'Content-Type': 'text/html' });\n          res.end('<html><body><h2>Invalid state parameter</h2><p>Possible CSRF attack. Please try again.</p></body></html>');\n          return;\n        }\n\n        if (error) {\n          res.writeHead(200, { 'Content-Type': 'text/html' });\n          res.end('<html><body><h2>Authorization denied</h2><p>You can close this tab.</p></body></html>');\n          server.close();\n          if (timeoutId) clearTimeout(timeoutId);\n          reject(new Error(`Auth denied: ${error}`));\n          return;\n        }\n\n        if (!code) {\n          res.writeHead(400, { 'Content-Type': 'text/html' });\n          res.end('<html><body><h2>Missing authorization code</h2></body></html>');\n          return;\n        }\n\n        // Exchange code for tokens\n        const port = server.address().port;\n        const tokens = await exchangeCodeForTokens(code, codeVerifier, port);\n        saveGoogleTokens(tokens);\n\n        res.writeHead(200, { 'Content-Type': 'text/html' });\n        res.end('<html><body style=\"font-family: -apple-system, sans-serif; text-align: center; padding: 60px;\"><h2>Connected to Google Calendar</h2><p>You can close this tab and return to StenoAI.</p></body></html>');\n\n        server.close();\n        if (timeoutId) clearTimeout(timeoutId);\n\n        // Notify renderer and bring app to foreground\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.webContents.send('google-auth-changed');\n          mainWindow.show();\n          mainWindow.focus();\n        }\n\n        resolve({ success: true });\n      } catch (err) {\n        res.writeHead(500, { 'Content-Type': 'text/html' });\n        res.end('<html><body><h2>Authentication failed</h2><p>Please try again.</p></body></html>');\n        server.close();\n        if (timeoutId) clearTimeout(timeoutId);\n        reject(err);\n      }\n    });\n\n    // Listen on loopback only (security: not 0.0.0.0)\n    server.listen(0, '127.0.0.1', () => {\n      const port = server.address().port;\n      const redirectUri = `http://127.0.0.1:${port}/callback`;\n\n      const authParams = new URLSearchParams({\n        client_id: GOOGLE_CLIENT_ID,\n        redirect_uri: redirectUri,\n        response_type: 'code',\n        scope: GOOGLE_SCOPES,\n        access_type: 'offline',\n        prompt: 'consent',\n        state: state,\n        code_challenge: codeChallenge,\n        code_challenge_method: 'S256'\n      });\n\n      const authUrl = `${GOOGLE_AUTH_URL}?${authParams.toString()}`;\n      shell.openExternal(authUrl);\n    });\n\n    timeoutId = setTimeout(() => {\n      if (server.listening) {\n        server.close();\n        reject(new Error('OAuth timeout: no response within 5 minutes'));\n      }\n    }, 5 * 60 * 1000);\n  });\n}\n\nfunction exchangeCodeForTokens(code, codeVerifier, port) {\n  return new Promise((resolve, reject) => {\n    const redirectUri = `http://127.0.0.1:${port}/callback`;\n    const postData = new URLSearchParams({\n      code,\n      client_id: GOOGLE_CLIENT_ID,\n      client_secret: GOOGLE_CLIENT_SECRET,\n      redirect_uri: redirectUri,\n      grant_type: 'authorization_code',\n      code_verifier: codeVerifier\n    }).toString();\n\n    const options = {\n      hostname: 'oauth2.googleapis.com',\n      path: '/token',\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/x-www-form-urlencoded',\n        'Content-Length': Buffer.byteLength(postData)\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => { data += chunk; });\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          if (parsed.error) {\n            reject(new Error(`Token exchange failed: ${parsed.error_description || parsed.error}`));\n            return;\n          }\n          // Store expiry as absolute timestamp\n          parsed.expires_at = Date.now() + (parsed.expires_in * 1000);\n          resolve(parsed);\n        } catch (err) {\n          reject(new Error('Failed to parse token response'));\n        }\n      });\n    });\n\n    req.on('error', reject);\n    req.write(postData);\n    req.end();\n  });\n}\n\n// ── Google Calendar: Token Refresh ──────────────────────────────────────\n\nasync function getValidAccessToken() {\n  const tokens = loadGoogleTokens();\n  if (!tokens) return null;\n\n  // Check if token is expired or about to expire (5-min buffer)\n  const bufferMs = 5 * 60 * 1000;\n  if (tokens.expires_at && Date.now() < tokens.expires_at - bufferMs) {\n    return tokens.access_token;\n  }\n\n  // Token expired, try to refresh\n  if (!tokens.refresh_token) {\n    deleteGoogleTokens();\n    if (mainWindow && !mainWindow.isDestroyed()) {\n      mainWindow.webContents.send('google-auth-changed');\n    }\n    return null;\n  }\n\n  try {\n    const newTokens = await refreshAccessToken(tokens.refresh_token);\n    // Preserve the refresh token (Google may not return it again)\n    newTokens.refresh_token = newTokens.refresh_token || tokens.refresh_token;\n    newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000);\n    saveGoogleTokens(newTokens);\n    return newTokens.access_token;\n  } catch (error) {\n    console.error('Token refresh failed:', error.message);\n    if (error.message && (error.message.includes('invalid_grant') || error.message.includes('Token has been expired or revoked'))) {\n      deleteGoogleTokens();\n      if (mainWindow && !mainWindow.isDestroyed()) {\n        mainWindow.webContents.send('google-auth-changed');\n      }\n    }\n    return null;\n  }\n}\n\nfunction refreshAccessToken(refreshToken) {\n  return new Promise((resolve, reject) => {\n    const postData = new URLSearchParams({\n      client_id: GOOGLE_CLIENT_ID,\n      client_secret: GOOGLE_CLIENT_SECRET,\n      refresh_token: refreshToken,\n      grant_type: 'refresh_token'\n    }).toString();\n\n    const options = {\n      hostname: 'oauth2.googleapis.com',\n      path: '/token',\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/x-www-form-urlencoded',\n        'Content-Length': Buffer.byteLength(postData)\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => { data += chunk; });\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          if (parsed.error) {\n            reject(new Error(`Refresh failed: ${parsed.error_description || parsed.error}`));\n            return;\n          }\n          resolve(parsed);\n        } catch (err) {\n          reject(new Error('Failed to parse refresh response'));\n        }\n      });\n    });\n\n    req.on('error', reject);\n    req.write(postData);\n    req.end();\n  });\n}\n\n// ── Google Calendar: Fetch Events ───────────────────────────────────────\n\nfunction fetchCalendarEvents(accessToken, maxResults = 7) {\n  return new Promise((resolve, reject) => {\n    const now = new Date();\n    const weekAhead = new Date(now);\n    weekAhead.setDate(weekAhead.getDate() + 7);\n    const params = new URLSearchParams({\n      timeMin: now.toISOString(),\n      timeMax: weekAhead.toISOString(),\n      maxResults: String(maxResults),\n      singleEvents: 'true',\n      orderBy: 'startTime',\n      fields: 'items(id,summary,description,start,end,attendees,htmlLink,conferenceData)'\n    });\n\n    const options = {\n      hostname: 'www.googleapis.com',\n      path: `/calendar/v3/calendars/primary/events?${params.toString()}`,\n      method: 'GET',\n      headers: {\n        'Authorization': `Bearer ${accessToken}`\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => { data += chunk; });\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          if (parsed.error) {\n            reject(new Error(`Calendar API error: ${parsed.error.message || parsed.error}`));\n            return;\n          }\n          resolve(parsed.items || []);\n        } catch (err) {\n          reject(new Error('Failed to parse calendar response'));\n        }\n      });\n    });\n\n    req.on('error', reject);\n    req.end();\n  });\n}\n\n// ── Outlook Calendar: OAuth2 Flow with PKCE ─────────────────────────────\n\nfunction startOutlookAuth() {\n  return new Promise((resolve, reject) => {\n    const codeVerifier = crypto.randomBytes(32).toString('base64url');\n    const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');\n    const state = crypto.randomBytes(16).toString('hex');\n    let timeoutId = null;\n\n    const server = http.createServer(async (req, res) => {\n      try {\n        const reqUrl = new URL(req.url, `http://localhost`);\n        // Ignore favicon and other noise — only handle the root path\n        if (reqUrl.pathname !== '/') {\n          res.writeHead(404);\n          res.end();\n          return;\n        }\n\n        const code = reqUrl.searchParams.get('code');\n        const error = reqUrl.searchParams.get('error');\n        const returnedState = reqUrl.searchParams.get('state');\n\n        if (returnedState !== state) {\n          res.writeHead(400, { 'Content-Type': 'text/html' });\n          res.end('<html><body><h2>Invalid state parameter</h2><p>Possible CSRF attack. Please try again.</p></body></html>');\n          return;\n        }\n\n        if (error) {\n          res.writeHead(200, { 'Content-Type': 'text/html' });\n          res.end('<html><body><h2>Authorization denied</h2><p>You can close this tab.</p></body></html>');\n          server.close();\n          if (timeoutId) clearTimeout(timeoutId);\n          reject(new Error(`Auth denied: ${error}`));\n          return;\n        }\n\n        if (!code) {\n          res.writeHead(400, { 'Content-Type': 'text/html' });\n          res.end('<html><body><h2>Missing authorization code</h2></body></html>');\n          return;\n        }\n\n        const port = server.address().port;\n        const tokens = await exchangeOutlookCodeForTokens(code, codeVerifier, port);\n        saveOutlookTokens(tokens);\n\n        res.writeHead(200, { 'Content-Type': 'text/html' });\n        res.end('<html><body style=\"font-family: -apple-system, sans-serif; text-align: center; padding: 60px;\"><h2>Connected to Outlook Calendar</h2><p>You can close this tab and return to StenoAI.</p></body></html>');\n\n        server.close();\n        if (timeoutId) clearTimeout(timeoutId);\n\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.webContents.send('outlook-auth-changed');\n          mainWindow.show();\n          mainWindow.focus();\n        }\n\n        resolve({ success: true });\n      } catch (err) {\n        res.writeHead(500, { 'Content-Type': 'text/html' });\n        res.end('<html><body><h2>Authentication failed</h2><p>Please try again.</p></body></html>');\n        server.close();\n        if (timeoutId) clearTimeout(timeoutId);\n        reject(err);\n      }\n    });\n\n    server.listen(0, '127.0.0.1', () => {\n      const port = server.address().port;\n      const redirectUri = `http://localhost:${port}`;\n\n      const authParams = new URLSearchParams({\n        client_id: OUTLOOK_CLIENT_ID,\n        redirect_uri: redirectUri,\n        response_type: 'code',\n        scope: OUTLOOK_SCOPES,\n        response_mode: 'query',\n        state: state,\n        code_challenge: codeChallenge,\n        code_challenge_method: 'S256'\n      });\n\n      const authUrl = `${OUTLOOK_AUTH_URL}?${authParams.toString()}`;\n      shell.openExternal(authUrl);\n    });\n\n    timeoutId = setTimeout(() => {\n      if (server.listening) {\n        server.close();\n        reject(new Error('OAuth timeout: no response within 5 minutes'));\n      }\n    }, 5 * 60 * 1000);\n  });\n}\n\nfunction exchangeOutlookCodeForTokens(code, codeVerifier, port) {\n  return new Promise((resolve, reject) => {\n    const redirectUri = `http://localhost:${port}`;\n    const postData = new URLSearchParams({\n      code,\n      client_id: OUTLOOK_CLIENT_ID,\n      redirect_uri: redirectUri,\n      grant_type: 'authorization_code',\n      code_verifier: codeVerifier\n    }).toString();\n\n    const tokenUrl = new URL(OUTLOOK_TOKEN_URL);\n    const options = {\n      hostname: tokenUrl.hostname,\n      path: tokenUrl.pathname,\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/x-www-form-urlencoded',\n        'Content-Length': Buffer.byteLength(postData)\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => { data += chunk; });\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          if (parsed.error) {\n            reject(new Error(`Token exchange failed: ${parsed.error_description || parsed.error}`));\n            return;\n          }\n          parsed.expires_at = Date.now() + (parsed.expires_in * 1000);\n          resolve(parsed);\n        } catch (err) {\n          reject(new Error('Failed to parse token response'));\n        }\n      });\n    });\n\n    req.on('error', reject);\n    req.write(postData);\n    req.end();\n  });\n}\n\n// ── Outlook Calendar: Token Refresh ─────────────────────────────────────\n\nasync function getValidOutlookAccessToken() {\n  const tokens = loadOutlookTokens();\n  if (!tokens) return null;\n\n  const bufferMs = 5 * 60 * 1000;\n  if (tokens.expires_at && Date.now() < tokens.expires_at - bufferMs) {\n    return tokens.access_token;\n  }\n\n  if (!tokens.refresh_token) {\n    deleteOutlookTokens();\n    if (mainWindow && !mainWindow.isDestroyed()) {\n      mainWindow.webContents.send('outlook-auth-changed');\n    }\n    return null;\n  }\n\n  try {\n    const newTokens = await refreshOutlookAccessToken(tokens.refresh_token);\n    newTokens.refresh_token = newTokens.refresh_token || tokens.refresh_token;\n    newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000);\n    saveOutlookTokens(newTokens);\n    return newTokens.access_token;\n  } catch (error) {\n    console.error('Outlook token refresh failed:', error.message);\n    // Only delete tokens for irrecoverable errors (revoked/expired grant)\n    // Transient network errors should not force re-authentication\n    if (error.message && (error.message.includes('invalid_grant') || error.message.includes('interaction_required'))) {\n      deleteOutlookTokens();\n      if (mainWindow && !mainWindow.isDestroyed()) {\n        mainWindow.webContents.send('outlook-auth-changed');\n      }\n    }\n    return null;\n  }\n}\n\nfunction refreshOutlookAccessToken(refreshToken) {\n  return new Promise((resolve, reject) => {\n    const postData = new URLSearchParams({\n      client_id: OUTLOOK_CLIENT_ID,\n      refresh_token: refreshToken,\n      grant_type: 'refresh_token',\n      scope: OUTLOOK_SCOPES\n    }).toString();\n\n    const tokenUrl = new URL(OUTLOOK_TOKEN_URL);\n    const options = {\n      hostname: tokenUrl.hostname,\n      path: tokenUrl.pathname,\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/x-www-form-urlencoded',\n        'Content-Length': Buffer.byteLength(postData)\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => { data += chunk; });\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          if (parsed.error) {\n            reject(new Error(`Refresh failed: ${parsed.error_description || parsed.error}`));\n            return;\n          }\n          resolve(parsed);\n        } catch (err) {\n          reject(new Error('Failed to parse refresh response'));\n        }\n      });\n    });\n\n    req.on('error', reject);\n    req.write(postData);\n    req.end();\n  });\n}\n\n// ── Outlook Calendar: Fetch Events ──────────────────────────────────────\n\nfunction fetchOutlookCalendarEvents(accessToken, maxResults = 7) {\n  return new Promise((resolve, reject) => {\n    const now = new Date();\n    const weekAhead = new Date(now);\n    weekAhead.setDate(weekAhead.getDate() + 7);\n\n    const params = new URLSearchParams({\n      startDateTime: now.toISOString(),\n      endDateTime: weekAhead.toISOString(),\n      $top: String(maxResults),\n      $orderby: 'start/dateTime',\n      $select: 'id,subject,body,start,end,attendees,webLink,onlineMeeting,isOnlineMeeting'\n    });\n\n    const options = {\n      hostname: 'graph.microsoft.com',\n      path: `/v1.0/me/calendarView?${params.toString()}`,\n      method: 'GET',\n      headers: {\n        'Authorization': `Bearer ${accessToken}`,\n        'Prefer': 'outlook.timezone=\"UTC\"'\n      }\n    };\n\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => { data += chunk; });\n      res.on('end', () => {\n        try {\n          const parsed = JSON.parse(data);\n          if (parsed.error) {\n            reject(new Error(`Outlook Calendar API error: ${parsed.error.message || parsed.error}`));\n            return;\n          }\n          const events = (parsed.value || []).map(normalizeOutlookEvent);\n          resolve(events);\n        } catch (err) {\n          reject(new Error('Failed to parse Outlook calendar response'));\n        }\n      });\n    });\n\n    req.on('error', reject);\n    req.end();\n  });\n}\n\nfunction normalizeOutlookEvent(event) {\n  // Map Microsoft Graph event shape to Google Calendar shape for renderer compatibility\n  const stripHtml = (html) => {\n    if (!html) return '';\n    return html\n      .replace(/<br\\s*\\/?>/gi, '\\n')\n      .replace(/<\\/(?:div|p|tr|li|h[1-6])>/gi, '\\n')\n      .replace(/<[^>]*>/g, '')\n      .replace(/&nbsp;/g, ' ')\n      .replace(/&amp;/g, '&')\n      .replace(/&lt;/g, '<')\n      .replace(/&gt;/g, '>')\n      .replace(/&quot;/g, '\"')\n      .replace(/\\r\\n/g, '\\n')\n      .replace(/[ \\t]+/g, ' ')\n      .replace(/(\\s*\\n){3,}/g, '\\n\\n')\n      .trim();\n  };\n\n  const ensureUtcSuffix = (dt) => {\n    if (!dt) return undefined;\n    return dt.endsWith('Z') ? dt : dt + 'Z';\n  };\n\n  return {\n    id: event.id,\n    summary: event.subject || 'No title',\n    description: stripHtml(event.body?.content),\n    start: {\n      dateTime: ensureUtcSuffix(event.start?.dateTime),\n      timeZone: 'UTC'\n    },\n    end: {\n      dateTime: ensureUtcSuffix(event.end?.dateTime),\n      timeZone: 'UTC'\n    },\n    attendees: (event.attendees || []).map(a => ({\n      email: a.emailAddress?.address || '',\n      displayName: a.emailAddress?.name || '',\n      responseStatus: a.status?.response || ''\n    })),\n    htmlLink: event.webLink,\n    conferenceData: event.isOnlineMeeting && event.onlineMeeting ? {\n      entryPoints: [{ uri: event.onlineMeeting.joinUrl, entryPointType: 'video' }]\n    } : undefined\n  };\n}\n\n// ── Google Calendar: IPC Handlers ───────────────────────────────────────\n\nipcMain.handle('google-auth-start', async () => {\n  try {\n    await startGoogleAuth();\n    // Only disconnect Outlook after Google auth succeeds\n    deleteOutlookTokens();\n    return { success: true };\n  } catch (error) {\n    console.error('Google auth failed:', error.message);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('google-auth-status', async () => {\n  try {\n    const tokens = loadGoogleTokens();\n    return { success: true, connected: !!tokens };\n  } catch (error) {\n    return { success: false, connected: false };\n  }\n});\n\nipcMain.handle('google-auth-disconnect', async () => {\n  try {\n    // Best-effort token revocation\n    const tokens = loadGoogleTokens();\n    if (tokens && tokens.access_token) {\n      try {\n        await new Promise((resolve) => {\n          const revokeParams = new URLSearchParams({ token: tokens.access_token });\n          const req = https.request({\n            hostname: 'oauth2.googleapis.com',\n            path: `/revoke?${revokeParams.toString()}`,\n            method: 'POST',\n            headers: { 'Content-Type': 'application/x-www-form-urlencoded' }\n          }, () => resolve());\n          req.on('error', () => resolve()); // Best-effort\n          req.end();\n        });\n      } catch (e) {\n        // Best-effort revocation -- ignore errors\n      }\n    }\n\n    deleteGoogleTokens();\n\n    if (mainWindow && !mainWindow.isDestroyed()) {\n      mainWindow.webContents.send('google-auth-changed');\n    }\n\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n\nfunction normalizeCalendarEvent(event) {\n  const start =\n    event.start?.dateTime ||\n    event.start?.date ||\n    (typeof event.start === 'string' ? event.start : '');\n  const end =\n    event.end?.dateTime ||\n    event.end?.date ||\n    (typeof event.end === 'string' ? event.end : '');\n  return {\n    id: event.id,\n    title: event.summary || event.title || 'No title',\n    start,\n    end,\n    meeting_url:\n      event.hangoutLink ||\n      event.onlineMeeting?.joinUrl ||\n      event.meeting_url ||\n      undefined,\n  };\n}\n\nipcMain.handle('get-calendar-events', async () => {\n  try {\n    // Check which provider is connected (only one at a time)\n    const googleToken = await getValidAccessToken();\n    if (googleToken) {\n      const raw = await fetchCalendarEvents(googleToken);\n      return { success: true, events: raw.map(normalizeCalendarEvent) };\n    }\n\n    const outlookToken = await getValidOutlookAccessToken();\n    if (outlookToken) {\n      const raw = await fetchOutlookCalendarEvents(outlookToken);\n      return { success: true, events: raw.map(normalizeCalendarEvent) };\n    }\n\n    return { success: false, needsAuth: true };\n  } catch (error) {\n    console.error('Failed to fetch calendar events:', error.message);\n    return { success: false, error: error.message };\n  }\n});\n\n// ── Outlook Calendar: IPC Handlers ──────────────────────────────────────\n\nipcMain.handle('outlook-auth-start', async () => {\n  try {\n    await startOutlookAuth();\n    // Only disconnect Google after Outlook auth succeeds\n    deleteGoogleTokens();\n    return { success: true };\n  } catch (error) {\n    console.error('Outlook auth failed:', error.message);\n    return { success: false, error: error.message };\n  }\n});\n\nipcMain.handle('outlook-auth-status', async () => {\n  try {\n    const tokens = loadOutlookTokens();\n    return { success: true, connected: !!tokens };\n  } catch (error) {\n    return { success: false, connected: false };\n  }\n});\n\nipcMain.handle('outlook-auth-disconnect', async () => {\n  try {\n    deleteOutlookTokens();\n\n    if (mainWindow && !mainWindow.isDestroyed()) {\n      mainWindow.webContents.send('outlook-auth-changed');\n    }\n\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: error.message };\n  }\n});\n"
  },
  {
    "path": "app/package-lock.json",
    "content": "{\n  \"name\": \"stenoai\",\n  \"version\": \"0.2.13\",\n  \"lockfileVersion\": 3,\n  \"requires\": true,\n  \"packages\": {\n    \"\": {\n      \"name\": \"stenoai\",\n      \"version\": \"0.2.13\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"axios\": \"^1.6.0\",\n        \"electron-audio-loopback\": \"^1.0.0\",\n        \"electron-updater\": \"^6.8.3\",\n        \"posthog-node\": \"^4.18.0\"\n      },\n      \"devDependencies\": {\n        \"@electron/notarize\": \"^3.1.1\",\n        \"@playwright/test\": \"^1.48.0\",\n        \"@types/node\": \"^20.12.0\",\n        \"electron\": \"^31.0.1\",\n        \"electron-builder\": \"^24.0.0\",\n        \"playwright\": \"^1.48.0\",\n        \"puppeteer-core\": \"^24.37.3\",\n        \"typescript\": \"^5.5.0\"\n      }\n    },\n    \"node_modules/@develar/schema-utils\": {\n      \"version\": \"2.6.5\",\n      \"resolved\": \"https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz\",\n      \"integrity\": \"sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ajv\": \"^6.12.0\",\n        \"ajv-keywords\": \"^3.4.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 8.9.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/webpack\"\n      }\n    },\n    \"node_modules/@electron/asar\": {\n      \"version\": \"3.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz\",\n      \"integrity\": \"sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"commander\": \"^5.0.0\",\n        \"glob\": \"^7.1.6\",\n        \"minimatch\": \"^3.0.4\"\n      },\n      \"bin\": {\n        \"asar\": \"bin/asar.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10.12.0\"\n      }\n    },\n    \"node_modules/@electron/asar/node_modules/brace-expansion\": {\n      \"version\": \"1.1.12\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\",\n      \"integrity\": \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^1.0.0\",\n        \"concat-map\": \"0.0.1\"\n      }\n    },\n    \"node_modules/@electron/asar/node_modules/minimatch\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz\",\n      \"integrity\": \"sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^1.1.7\"\n      },\n      \"engines\": {\n        \"node\": \"*\"\n      }\n    },\n    \"node_modules/@electron/get\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz\",\n      \"integrity\": \"sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"^4.1.1\",\n        \"env-paths\": \"^2.2.0\",\n        \"fs-extra\": \"^8.1.0\",\n        \"got\": \"^11.8.5\",\n        \"progress\": \"^2.0.3\",\n        \"semver\": \"^6.2.0\",\n        \"sumchecker\": \"^3.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"optionalDependencies\": {\n        \"global-agent\": \"^3.0.0\"\n      }\n    },\n    \"node_modules/@electron/notarize\": {\n      \"version\": \"3.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz\",\n      \"integrity\": \"sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"^4.4.0\",\n        \"promise-retry\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 22.12.0\"\n      }\n    },\n    \"node_modules/@electron/osx-sign\": {\n      \"version\": \"1.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz\",\n      \"integrity\": \"sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"compare-version\": \"^0.1.2\",\n        \"debug\": \"^4.3.4\",\n        \"fs-extra\": \"^10.0.0\",\n        \"isbinaryfile\": \"^4.0.8\",\n        \"minimist\": \"^1.2.6\",\n        \"plist\": \"^3.0.5\"\n      },\n      \"bin\": {\n        \"electron-osx-flat\": \"bin/electron-osx-flat.js\",\n        \"electron-osx-sign\": \"bin/electron-osx-sign.js\"\n      },\n      \"engines\": {\n        \"node\": \">=12.0.0\"\n      }\n    },\n    \"node_modules/@electron/osx-sign/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/@electron/osx-sign/node_modules/isbinaryfile\": {\n      \"version\": \"4.0.10\",\n      \"resolved\": \"https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz\",\n      \"integrity\": \"sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 8.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/gjtorikian/\"\n      }\n    },\n    \"node_modules/@electron/osx-sign/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/@electron/osx-sign/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/@electron/universal\": {\n      \"version\": \"1.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz\",\n      \"integrity\": \"sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@electron/asar\": \"^3.2.1\",\n        \"@malept/cross-spawn-promise\": \"^1.1.0\",\n        \"debug\": \"^4.3.1\",\n        \"dir-compare\": \"^3.0.0\",\n        \"fs-extra\": \"^9.0.1\",\n        \"minimatch\": \"^3.0.4\",\n        \"plist\": \"^3.0.4\"\n      },\n      \"engines\": {\n        \"node\": \">=8.6\"\n      }\n    },\n    \"node_modules/@electron/universal/node_modules/brace-expansion\": {\n      \"version\": \"1.1.12\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\",\n      \"integrity\": \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^1.0.0\",\n        \"concat-map\": \"0.0.1\"\n      }\n    },\n    \"node_modules/@electron/universal/node_modules/fs-extra\": {\n      \"version\": \"9.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz\",\n      \"integrity\": \"sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"at-least-node\": \"^1.0.0\",\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/@electron/universal/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/@electron/universal/node_modules/minimatch\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz\",\n      \"integrity\": \"sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^1.1.7\"\n      },\n      \"engines\": {\n        \"node\": \"*\"\n      }\n    },\n    \"node_modules/@electron/universal/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/@isaacs/cliui\": {\n      \"version\": \"8.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz\",\n      \"integrity\": \"sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"string-width\": \"^5.1.2\",\n        \"string-width-cjs\": \"npm:string-width@^4.2.0\",\n        \"strip-ansi\": \"^7.0.1\",\n        \"strip-ansi-cjs\": \"npm:strip-ansi@^6.0.1\",\n        \"wrap-ansi\": \"^8.1.0\",\n        \"wrap-ansi-cjs\": \"npm:wrap-ansi@^7.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/@isaacs/cliui/node_modules/ansi-regex\": {\n      \"version\": \"6.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz\",\n      \"integrity\": \"sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/ansi-regex?sponsor=1\"\n      }\n    },\n    \"node_modules/@isaacs/cliui/node_modules/ansi-styles\": {\n      \"version\": \"6.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz\",\n      \"integrity\": \"sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/ansi-styles?sponsor=1\"\n      }\n    },\n    \"node_modules/@isaacs/cliui/node_modules/emoji-regex\": {\n      \"version\": \"9.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz\",\n      \"integrity\": \"sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@isaacs/cliui/node_modules/string-width\": {\n      \"version\": \"5.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz\",\n      \"integrity\": \"sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"eastasianwidth\": \"^0.2.0\",\n        \"emoji-regex\": \"^9.2.2\",\n        \"strip-ansi\": \"^7.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/@isaacs/cliui/node_modules/strip-ansi\": {\n      \"version\": \"7.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz\",\n      \"integrity\": \"sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-regex\": \"^6.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/strip-ansi?sponsor=1\"\n      }\n    },\n    \"node_modules/@isaacs/cliui/node_modules/wrap-ansi\": {\n      \"version\": \"8.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz\",\n      \"integrity\": \"sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-styles\": \"^6.1.0\",\n        \"string-width\": \"^5.0.1\",\n        \"strip-ansi\": \"^7.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/wrap-ansi?sponsor=1\"\n      }\n    },\n    \"node_modules/@malept/cross-spawn-promise\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz\",\n      \"integrity\": \"sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"individual\",\n          \"url\": \"https://github.com/sponsors/malept\"\n        },\n        {\n          \"type\": \"tidelift\",\n          \"url\": \"https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund\"\n        }\n      ],\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"cross-spawn\": \"^7.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/@malept/flatpak-bundler\": {\n      \"version\": \"0.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz\",\n      \"integrity\": \"sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"^4.1.1\",\n        \"fs-extra\": \"^9.0.0\",\n        \"lodash\": \"^4.17.15\",\n        \"tmp-promise\": \"^3.0.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/@malept/flatpak-bundler/node_modules/fs-extra\": {\n      \"version\": \"9.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz\",\n      \"integrity\": \"sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"at-least-node\": \"^1.0.0\",\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/@malept/flatpak-bundler/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/@malept/flatpak-bundler/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/@pkgjs/parseargs\": {\n      \"version\": \"0.11.0\",\n      \"resolved\": \"https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz\",\n      \"integrity\": \"sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=14\"\n      }\n    },\n    \"node_modules/@playwright/test\": {\n      \"version\": \"1.59.1\",\n      \"resolved\": \"https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz\",\n      \"integrity\": \"sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"playwright\": \"1.59.1\"\n      },\n      \"bin\": {\n        \"playwright\": \"cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@puppeteer/browsers\": {\n      \"version\": \"2.13.0\",\n      \"resolved\": \"https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz\",\n      \"integrity\": \"sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"debug\": \"^4.4.3\",\n        \"extract-zip\": \"^2.0.1\",\n        \"progress\": \"^2.0.3\",\n        \"proxy-agent\": \"^6.5.0\",\n        \"semver\": \"^7.7.4\",\n        \"tar-fs\": \"^3.1.1\",\n        \"yargs\": \"^17.7.2\"\n      },\n      \"bin\": {\n        \"browsers\": \"lib/cjs/main-cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@puppeteer/browsers/node_modules/semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/@sindresorhus/is\": {\n      \"version\": \"4.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz\",\n      \"integrity\": \"sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sindresorhus/is?sponsor=1\"\n      }\n    },\n    \"node_modules/@szmarczak/http-timer\": {\n      \"version\": \"4.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz\",\n      \"integrity\": \"sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"defer-to-connect\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/@tootallnate/once\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz\",\n      \"integrity\": \"sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/@tootallnate/quickjs-emscripten\": {\n      \"version\": \"0.23.0\",\n      \"resolved\": \"https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz\",\n      \"integrity\": \"sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/cacheable-request\": {\n      \"version\": \"6.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz\",\n      \"integrity\": \"sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/http-cache-semantics\": \"*\",\n        \"@types/keyv\": \"^3.1.4\",\n        \"@types/node\": \"*\",\n        \"@types/responselike\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/@types/debug\": {\n      \"version\": \"4.1.12\",\n      \"resolved\": \"https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz\",\n      \"integrity\": \"sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/ms\": \"*\"\n      }\n    },\n    \"node_modules/@types/fs-extra\": {\n      \"version\": \"9.0.13\",\n      \"resolved\": \"https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz\",\n      \"integrity\": \"sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/http-cache-semantics\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz\",\n      \"integrity\": \"sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/keyv\": {\n      \"version\": \"3.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz\",\n      \"integrity\": \"sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/ms\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz\",\n      \"integrity\": \"sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/node\": {\n      \"version\": \"20.19.33\",\n      \"resolved\": \"https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz\",\n      \"integrity\": \"sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"undici-types\": \"~6.21.0\"\n      }\n    },\n    \"node_modules/@types/plist\": {\n      \"version\": \"3.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz\",\n      \"integrity\": \"sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@types/node\": \"*\",\n        \"xmlbuilder\": \">=11.0.1\"\n      }\n    },\n    \"node_modules/@types/responselike\": {\n      \"version\": \"1.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz\",\n      \"integrity\": \"sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/verror\": {\n      \"version\": \"1.10.11\",\n      \"resolved\": \"https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz\",\n      \"integrity\": \"sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/@types/yauzl\": {\n      \"version\": \"2.10.3\",\n      \"resolved\": \"https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz\",\n      \"integrity\": \"sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@xmldom/xmldom\": {\n      \"version\": \"0.8.11\",\n      \"resolved\": \"https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz\",\n      \"integrity\": \"sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10.0.0\"\n      }\n    },\n    \"node_modules/7zip-bin\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz\",\n      \"integrity\": \"sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/agent-base\": {\n      \"version\": \"6.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz\",\n      \"integrity\": \"sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"4\"\n      },\n      \"engines\": {\n        \"node\": \">= 6.0.0\"\n      }\n    },\n    \"node_modules/ajv\": {\n      \"version\": \"6.12.6\",\n      \"resolved\": \"https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz\",\n      \"integrity\": \"sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"fast-deep-equal\": \"^3.1.1\",\n        \"fast-json-stable-stringify\": \"^2.0.0\",\n        \"json-schema-traverse\": \"^0.4.1\",\n        \"uri-js\": \"^4.2.2\"\n      },\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/epoberezkin\"\n      }\n    },\n    \"node_modules/ajv-keywords\": {\n      \"version\": \"3.5.2\",\n      \"resolved\": \"https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz\",\n      \"integrity\": \"sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peerDependencies\": {\n        \"ajv\": \"^6.9.1\"\n      }\n    },\n    \"node_modules/ansi-regex\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz\",\n      \"integrity\": \"sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/ansi-styles\": {\n      \"version\": \"4.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz\",\n      \"integrity\": \"sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"color-convert\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/ansi-styles?sponsor=1\"\n      }\n    },\n    \"node_modules/app-builder-bin\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz\",\n      \"integrity\": \"sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/app-builder-lib\": {\n      \"version\": \"24.13.3\",\n      \"resolved\": \"https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz\",\n      \"integrity\": \"sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@develar/schema-utils\": \"~2.6.5\",\n        \"@electron/notarize\": \"2.2.1\",\n        \"@electron/osx-sign\": \"1.0.5\",\n        \"@electron/universal\": \"1.5.1\",\n        \"@malept/flatpak-bundler\": \"^0.4.0\",\n        \"@types/fs-extra\": \"9.0.13\",\n        \"async-exit-hook\": \"^2.0.1\",\n        \"bluebird-lst\": \"^1.0.9\",\n        \"builder-util\": \"24.13.1\",\n        \"builder-util-runtime\": \"9.2.4\",\n        \"chromium-pickle-js\": \"^0.2.0\",\n        \"debug\": \"^4.3.4\",\n        \"ejs\": \"^3.1.8\",\n        \"electron-publish\": \"24.13.1\",\n        \"form-data\": \"^4.0.0\",\n        \"fs-extra\": \"^10.1.0\",\n        \"hosted-git-info\": \"^4.1.0\",\n        \"is-ci\": \"^3.0.0\",\n        \"isbinaryfile\": \"^5.0.0\",\n        \"js-yaml\": \"^4.1.0\",\n        \"lazy-val\": \"^1.0.5\",\n        \"minimatch\": \"^5.1.1\",\n        \"read-config-file\": \"6.3.2\",\n        \"sanitize-filename\": \"^1.6.3\",\n        \"semver\": \"^7.3.8\",\n        \"tar\": \"^6.1.12\",\n        \"temp-file\": \"^3.4.0\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      },\n      \"peerDependencies\": {\n        \"dmg-builder\": \"24.13.3\",\n        \"electron-builder-squirrel-windows\": \"24.13.3\"\n      }\n    },\n    \"node_modules/app-builder-lib/node_modules/@electron/notarize\": {\n      \"version\": \"2.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz\",\n      \"integrity\": \"sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"^4.1.1\",\n        \"fs-extra\": \"^9.0.1\",\n        \"promise-retry\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra\": {\n      \"version\": \"9.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz\",\n      \"integrity\": \"sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"at-least-node\": \"^1.0.0\",\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/app-builder-lib/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/app-builder-lib/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/app-builder-lib/node_modules/semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/app-builder-lib/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/archiver\": {\n      \"version\": \"5.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz\",\n      \"integrity\": \"sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"archiver-utils\": \"^2.1.0\",\n        \"async\": \"^3.2.4\",\n        \"buffer-crc32\": \"^0.2.1\",\n        \"readable-stream\": \"^3.6.0\",\n        \"readdir-glob\": \"^1.1.2\",\n        \"tar-stream\": \"^2.2.0\",\n        \"zip-stream\": \"^4.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/archiver-utils\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz\",\n      \"integrity\": \"sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"glob\": \"^7.1.4\",\n        \"graceful-fs\": \"^4.2.0\",\n        \"lazystream\": \"^1.0.0\",\n        \"lodash.defaults\": \"^4.2.0\",\n        \"lodash.difference\": \"^4.5.0\",\n        \"lodash.flatten\": \"^4.4.0\",\n        \"lodash.isplainobject\": \"^4.0.6\",\n        \"lodash.union\": \"^4.6.0\",\n        \"normalize-path\": \"^3.0.0\",\n        \"readable-stream\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/archiver-utils/node_modules/readable-stream\": {\n      \"version\": \"2.3.8\",\n      \"resolved\": \"https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz\",\n      \"integrity\": \"sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"core-util-is\": \"~1.0.0\",\n        \"inherits\": \"~2.0.3\",\n        \"isarray\": \"~1.0.0\",\n        \"process-nextick-args\": \"~2.0.0\",\n        \"safe-buffer\": \"~5.1.1\",\n        \"string_decoder\": \"~1.1.1\",\n        \"util-deprecate\": \"~1.0.1\"\n      }\n    },\n    \"node_modules/archiver-utils/node_modules/safe-buffer\": {\n      \"version\": \"5.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz\",\n      \"integrity\": \"sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/archiver-utils/node_modules/string_decoder\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz\",\n      \"integrity\": \"sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"safe-buffer\": \"~5.1.0\"\n      }\n    },\n    \"node_modules/argparse\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz\",\n      \"integrity\": \"sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==\",\n      \"license\": \"Python-2.0\"\n    },\n    \"node_modules/assert-plus\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz\",\n      \"integrity\": \"sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=0.8\"\n      }\n    },\n    \"node_modules/ast-types\": {\n      \"version\": \"0.13.4\",\n      \"resolved\": \"https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz\",\n      \"integrity\": \"sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"tslib\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=4\"\n      }\n    },\n    \"node_modules/astral-regex\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz\",\n      \"integrity\": \"sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/async\": {\n      \"version\": \"3.2.6\",\n      \"resolved\": \"https://registry.npmjs.org/async/-/async-3.2.6.tgz\",\n      \"integrity\": \"sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/async-exit-hook\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz\",\n      \"integrity\": \"sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.12.0\"\n      }\n    },\n    \"node_modules/asynckit\": {\n      \"version\": \"0.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz\",\n      \"integrity\": \"sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/at-least-node\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz\",\n      \"integrity\": \"sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">= 4.0.0\"\n      }\n    },\n    \"node_modules/axios\": {\n      \"version\": \"1.13.5\",\n      \"resolved\": \"https://registry.npmjs.org/axios/-/axios-1.13.5.tgz\",\n      \"integrity\": \"sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"follow-redirects\": \"^1.15.11\",\n        \"form-data\": \"^4.0.5\",\n        \"proxy-from-env\": \"^1.1.0\"\n      }\n    },\n    \"node_modules/b4a\": {\n      \"version\": \"1.7.5\",\n      \"resolved\": \"https://registry.npmjs.org/b4a/-/b4a-1.7.5.tgz\",\n      \"integrity\": \"sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"peerDependencies\": {\n        \"react-native-b4a\": \"*\"\n      },\n      \"peerDependenciesMeta\": {\n        \"react-native-b4a\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/balanced-match\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz\",\n      \"integrity\": \"sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/bare-events\": {\n      \"version\": \"2.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz\",\n      \"integrity\": \"sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"peerDependencies\": {\n        \"bare-abort-controller\": \"*\"\n      },\n      \"peerDependenciesMeta\": {\n        \"bare-abort-controller\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/bare-fs\": {\n      \"version\": \"4.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz\",\n      \"integrity\": \"sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"bare-events\": \"^2.5.4\",\n        \"bare-path\": \"^3.0.0\",\n        \"bare-stream\": \"^2.6.4\",\n        \"bare-url\": \"^2.2.2\",\n        \"fast-fifo\": \"^1.3.2\"\n      },\n      \"engines\": {\n        \"bare\": \">=1.16.0\"\n      },\n      \"peerDependencies\": {\n        \"bare-buffer\": \"*\"\n      },\n      \"peerDependenciesMeta\": {\n        \"bare-buffer\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/bare-os\": {\n      \"version\": \"3.6.2\",\n      \"resolved\": \"https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz\",\n      \"integrity\": \"sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"engines\": {\n        \"bare\": \">=1.14.0\"\n      }\n    },\n    \"node_modules/bare-path\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz\",\n      \"integrity\": \"sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"bare-os\": \"^3.0.1\"\n      }\n    },\n    \"node_modules/bare-stream\": {\n      \"version\": \"2.8.0\",\n      \"resolved\": \"https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz\",\n      \"integrity\": \"sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"streamx\": \"^2.21.0\",\n        \"teex\": \"^1.0.1\"\n      },\n      \"peerDependencies\": {\n        \"bare-buffer\": \"*\",\n        \"bare-events\": \"*\"\n      },\n      \"peerDependenciesMeta\": {\n        \"bare-buffer\": {\n          \"optional\": true\n        },\n        \"bare-events\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/bare-url\": {\n      \"version\": \"2.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz\",\n      \"integrity\": \"sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"bare-path\": \"^3.0.0\"\n      }\n    },\n    \"node_modules/base64-js\": {\n      \"version\": \"1.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz\",\n      \"integrity\": \"sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ],\n      \"license\": \"MIT\"\n    },\n    \"node_modules/basic-ftp\": {\n      \"version\": \"5.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz\",\n      \"integrity\": \"sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10.0.0\"\n      }\n    },\n    \"node_modules/bl\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/bl/-/bl-4.1.0.tgz\",\n      \"integrity\": \"sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"buffer\": \"^5.5.0\",\n        \"inherits\": \"^2.0.4\",\n        \"readable-stream\": \"^3.4.0\"\n      }\n    },\n    \"node_modules/bluebird\": {\n      \"version\": \"3.7.2\",\n      \"resolved\": \"https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz\",\n      \"integrity\": \"sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/bluebird-lst\": {\n      \"version\": \"1.0.9\",\n      \"resolved\": \"https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz\",\n      \"integrity\": \"sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"bluebird\": \"^3.5.5\"\n      }\n    },\n    \"node_modules/boolean\": {\n      \"version\": \"3.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz\",\n      \"integrity\": \"sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==\",\n      \"deprecated\": \"Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.\",\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/brace-expansion\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz\",\n      \"integrity\": \"sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/buffer\": {\n      \"version\": \"5.7.1\",\n      \"resolved\": \"https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz\",\n      \"integrity\": \"sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"base64-js\": \"^1.3.1\",\n        \"ieee754\": \"^1.1.13\"\n      }\n    },\n    \"node_modules/buffer-crc32\": {\n      \"version\": \"0.2.13\",\n      \"resolved\": \"https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz\",\n      \"integrity\": \"sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"*\"\n      }\n    },\n    \"node_modules/buffer-equal\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz\",\n      \"integrity\": \"sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/buffer-from\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz\",\n      \"integrity\": \"sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/builder-util\": {\n      \"version\": \"24.13.1\",\n      \"resolved\": \"https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz\",\n      \"integrity\": \"sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/debug\": \"^4.1.6\",\n        \"7zip-bin\": \"~5.2.0\",\n        \"app-builder-bin\": \"4.0.0\",\n        \"bluebird-lst\": \"^1.0.9\",\n        \"builder-util-runtime\": \"9.2.4\",\n        \"chalk\": \"^4.1.2\",\n        \"cross-spawn\": \"^7.0.3\",\n        \"debug\": \"^4.3.4\",\n        \"fs-extra\": \"^10.1.0\",\n        \"http-proxy-agent\": \"^5.0.0\",\n        \"https-proxy-agent\": \"^5.0.1\",\n        \"is-ci\": \"^3.0.0\",\n        \"js-yaml\": \"^4.1.0\",\n        \"source-map-support\": \"^0.5.19\",\n        \"stat-mode\": \"^1.0.0\",\n        \"temp-file\": \"^3.4.0\"\n      }\n    },\n    \"node_modules/builder-util-runtime\": {\n      \"version\": \"9.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz\",\n      \"integrity\": \"sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"^4.3.4\",\n        \"sax\": \"^1.2.4\"\n      },\n      \"engines\": {\n        \"node\": \">=12.0.0\"\n      }\n    },\n    \"node_modules/builder-util/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/builder-util/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/builder-util/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/cacheable-lookup\": {\n      \"version\": \"5.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz\",\n      \"integrity\": \"sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10.6.0\"\n      }\n    },\n    \"node_modules/cacheable-request\": {\n      \"version\": \"7.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz\",\n      \"integrity\": \"sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"clone-response\": \"^1.0.2\",\n        \"get-stream\": \"^5.1.0\",\n        \"http-cache-semantics\": \"^4.0.0\",\n        \"keyv\": \"^4.0.0\",\n        \"lowercase-keys\": \"^2.0.0\",\n        \"normalize-url\": \"^6.0.1\",\n        \"responselike\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/call-bind-apply-helpers\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz\",\n      \"integrity\": \"sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\",\n        \"function-bind\": \"^1.1.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/chalk\": {\n      \"version\": \"4.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz\",\n      \"integrity\": \"sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-styles\": \"^4.1.0\",\n        \"supports-color\": \"^7.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/chalk?sponsor=1\"\n      }\n    },\n    \"node_modules/chownr\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz\",\n      \"integrity\": \"sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/chromium-bidi\": {\n      \"version\": \"14.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz\",\n      \"integrity\": \"sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"mitt\": \"^3.0.1\",\n        \"zod\": \"^3.24.1\"\n      },\n      \"peerDependencies\": {\n        \"devtools-protocol\": \"*\"\n      }\n    },\n    \"node_modules/chromium-pickle-js\": {\n      \"version\": \"0.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz\",\n      \"integrity\": \"sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/ci-info\": {\n      \"version\": \"3.9.0\",\n      \"resolved\": \"https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz\",\n      \"integrity\": \"sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/sibiraj-s\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/cli-truncate\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz\",\n      \"integrity\": \"sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"slice-ansi\": \"^3.0.0\",\n        \"string-width\": \"^4.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/cliui\": {\n      \"version\": \"8.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz\",\n      \"integrity\": \"sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"string-width\": \"^4.2.0\",\n        \"strip-ansi\": \"^6.0.1\",\n        \"wrap-ansi\": \"^7.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/clone-response\": {\n      \"version\": \"1.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz\",\n      \"integrity\": \"sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"mimic-response\": \"^1.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/color-convert\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz\",\n      \"integrity\": \"sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"color-name\": \"~1.1.4\"\n      },\n      \"engines\": {\n        \"node\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/color-name\": {\n      \"version\": \"1.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz\",\n      \"integrity\": \"sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/combined-stream\": {\n      \"version\": \"1.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz\",\n      \"integrity\": \"sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"delayed-stream\": \"~1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/commander\": {\n      \"version\": \"5.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/commander/-/commander-5.1.0.tgz\",\n      \"integrity\": \"sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/compare-version\": {\n      \"version\": \"0.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz\",\n      \"integrity\": \"sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/compress-commons\": {\n      \"version\": \"4.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz\",\n      \"integrity\": \"sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"buffer-crc32\": \"^0.2.13\",\n        \"crc32-stream\": \"^4.0.2\",\n        \"normalize-path\": \"^3.0.0\",\n        \"readable-stream\": \"^3.6.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/concat-map\": {\n      \"version\": \"0.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz\",\n      \"integrity\": \"sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/config-file-ts\": {\n      \"version\": \"0.2.6\",\n      \"resolved\": \"https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz\",\n      \"integrity\": \"sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"glob\": \"^10.3.10\",\n        \"typescript\": \"^5.3.3\"\n      }\n    },\n    \"node_modules/config-file-ts/node_modules/glob\": {\n      \"version\": \"10.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/glob/-/glob-10.5.0.tgz\",\n      \"integrity\": \"sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==\",\n      \"deprecated\": \"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"foreground-child\": \"^3.1.0\",\n        \"jackspeak\": \"^3.1.2\",\n        \"minimatch\": \"^9.0.4\",\n        \"minipass\": \"^7.1.2\",\n        \"package-json-from-dist\": \"^1.0.0\",\n        \"path-scurry\": \"^1.11.1\"\n      },\n      \"bin\": {\n        \"glob\": \"dist/esm/bin.mjs\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/config-file-ts/node_modules/minimatch\": {\n      \"version\": \"9.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz\",\n      \"integrity\": \"sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=16 || 14 >=14.17\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/config-file-ts/node_modules/minipass\": {\n      \"version\": \"7.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz\",\n      \"integrity\": \"sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\",\n      \"engines\": {\n        \"node\": \">=16 || 14 >=14.17\"\n      }\n    },\n    \"node_modules/core-util-is\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz\",\n      \"integrity\": \"sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/crc\": {\n      \"version\": \"3.8.0\",\n      \"resolved\": \"https://registry.npmjs.org/crc/-/crc-3.8.0.tgz\",\n      \"integrity\": \"sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"buffer\": \"^5.1.0\"\n      }\n    },\n    \"node_modules/crc-32\": {\n      \"version\": \"1.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz\",\n      \"integrity\": \"sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"peer\": true,\n      \"bin\": {\n        \"crc32\": \"bin/crc32.njs\"\n      },\n      \"engines\": {\n        \"node\": \">=0.8\"\n      }\n    },\n    \"node_modules/crc32-stream\": {\n      \"version\": \"4.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz\",\n      \"integrity\": \"sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"crc-32\": \"^1.2.0\",\n        \"readable-stream\": \"^3.4.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/cross-spawn\": {\n      \"version\": \"7.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz\",\n      \"integrity\": \"sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"path-key\": \"^3.1.0\",\n        \"shebang-command\": \"^2.0.0\",\n        \"which\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/data-uri-to-buffer\": {\n      \"version\": \"6.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz\",\n      \"integrity\": \"sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/debug\": {\n      \"version\": \"4.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/debug/-/debug-4.4.3.tgz\",\n      \"integrity\": \"sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ms\": \"^2.1.3\"\n      },\n      \"engines\": {\n        \"node\": \">=6.0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"supports-color\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/decompress-response\": {\n      \"version\": \"6.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz\",\n      \"integrity\": \"sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"mimic-response\": \"^3.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/decompress-response/node_modules/mimic-response\": {\n      \"version\": \"3.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz\",\n      \"integrity\": \"sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/defer-to-connect\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz\",\n      \"integrity\": \"sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/define-data-property\": {\n      \"version\": \"1.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz\",\n      \"integrity\": \"sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"es-define-property\": \"^1.0.0\",\n        \"es-errors\": \"^1.3.0\",\n        \"gopd\": \"^1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/define-properties\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz\",\n      \"integrity\": \"sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"define-data-property\": \"^1.0.1\",\n        \"has-property-descriptors\": \"^1.0.0\",\n        \"object-keys\": \"^1.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/degenerator\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz\",\n      \"integrity\": \"sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ast-types\": \"^0.13.4\",\n        \"escodegen\": \"^2.1.0\",\n        \"esprima\": \"^4.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/delayed-stream\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz\",\n      \"integrity\": \"sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.4.0\"\n      }\n    },\n    \"node_modules/detect-node\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz\",\n      \"integrity\": \"sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==\",\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/devtools-protocol\": {\n      \"version\": \"0.0.1566079\",\n      \"resolved\": \"https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz\",\n      \"integrity\": \"sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==\",\n      \"dev\": true,\n      \"license\": \"BSD-3-Clause\"\n    },\n    \"node_modules/dir-compare\": {\n      \"version\": \"3.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz\",\n      \"integrity\": \"sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"buffer-equal\": \"^1.0.0\",\n        \"minimatch\": \"^3.0.4\"\n      }\n    },\n    \"node_modules/dir-compare/node_modules/brace-expansion\": {\n      \"version\": \"1.1.12\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\",\n      \"integrity\": \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^1.0.0\",\n        \"concat-map\": \"0.0.1\"\n      }\n    },\n    \"node_modules/dir-compare/node_modules/minimatch\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz\",\n      \"integrity\": \"sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^1.1.7\"\n      },\n      \"engines\": {\n        \"node\": \"*\"\n      }\n    },\n    \"node_modules/dmg-builder\": {\n      \"version\": \"24.13.3\",\n      \"resolved\": \"https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz\",\n      \"integrity\": \"sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"app-builder-lib\": \"24.13.3\",\n        \"builder-util\": \"24.13.1\",\n        \"builder-util-runtime\": \"9.2.4\",\n        \"fs-extra\": \"^10.1.0\",\n        \"iconv-lite\": \"^0.6.2\",\n        \"js-yaml\": \"^4.1.0\"\n      },\n      \"optionalDependencies\": {\n        \"dmg-license\": \"^1.0.11\"\n      }\n    },\n    \"node_modules/dmg-builder/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/dmg-builder/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/dmg-builder/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/dmg-license\": {\n      \"version\": \"1.0.11\",\n      \"resolved\": \"https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz\",\n      \"integrity\": \"sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"dependencies\": {\n        \"@types/plist\": \"^3.0.1\",\n        \"@types/verror\": \"^1.10.3\",\n        \"ajv\": \"^6.10.0\",\n        \"crc\": \"^3.8.0\",\n        \"iconv-corefoundation\": \"^1.1.7\",\n        \"plist\": \"^3.0.4\",\n        \"smart-buffer\": \"^4.0.2\",\n        \"verror\": \"^1.10.0\"\n      },\n      \"bin\": {\n        \"dmg-license\": \"bin/dmg-license.js\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/dotenv\": {\n      \"version\": \"9.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz\",\n      \"integrity\": \"sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/dotenv-expand\": {\n      \"version\": \"5.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz\",\n      \"integrity\": \"sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\"\n    },\n    \"node_modules/dunder-proto\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz\",\n      \"integrity\": \"sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"call-bind-apply-helpers\": \"^1.0.1\",\n        \"es-errors\": \"^1.3.0\",\n        \"gopd\": \"^1.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/eastasianwidth\": {\n      \"version\": \"0.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz\",\n      \"integrity\": \"sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/ejs\": {\n      \"version\": \"3.1.10\",\n      \"resolved\": \"https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz\",\n      \"integrity\": \"sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"jake\": \"^10.8.5\"\n      },\n      \"bin\": {\n        \"ejs\": \"bin/cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/electron\": {\n      \"version\": \"31.7.7\",\n      \"resolved\": \"https://registry.npmjs.org/electron/-/electron-31.7.7.tgz\",\n      \"integrity\": \"sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA==\",\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@electron/get\": \"^2.0.0\",\n        \"@types/node\": \"^20.9.0\",\n        \"extract-zip\": \"^2.0.1\"\n      },\n      \"bin\": {\n        \"electron\": \"cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">= 12.20.55\"\n      }\n    },\n    \"node_modules/electron-audio-loopback\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/electron-audio-loopback/-/electron-audio-loopback-1.0.6.tgz\",\n      \"integrity\": \"sha512-QW0ogDqMpWHDAQHmQyssJ+Yh4qR3kWCP3Q4H9WuIXKwVlgkqOYGyt0v/JzbK3tBNTwfqbuHZy86kwCCajxqAdg==\",\n      \"license\": \"MIT\",\n      \"peerDependencies\": {\n        \"electron\": \">=31.0.1\"\n      }\n    },\n    \"node_modules/electron-builder\": {\n      \"version\": \"24.13.3\",\n      \"resolved\": \"https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz\",\n      \"integrity\": \"sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"app-builder-lib\": \"24.13.3\",\n        \"builder-util\": \"24.13.1\",\n        \"builder-util-runtime\": \"9.2.4\",\n        \"chalk\": \"^4.1.2\",\n        \"dmg-builder\": \"24.13.3\",\n        \"fs-extra\": \"^10.1.0\",\n        \"is-ci\": \"^3.0.0\",\n        \"lazy-val\": \"^1.0.5\",\n        \"read-config-file\": \"6.3.2\",\n        \"simple-update-notifier\": \"2.0.0\",\n        \"yargs\": \"^17.6.2\"\n      },\n      \"bin\": {\n        \"electron-builder\": \"cli.js\",\n        \"install-app-deps\": \"install-app-deps.js\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/electron-builder-squirrel-windows\": {\n      \"version\": \"24.13.3\",\n      \"resolved\": \"https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz\",\n      \"integrity\": \"sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"app-builder-lib\": \"24.13.3\",\n        \"archiver\": \"^5.3.1\",\n        \"builder-util\": \"24.13.1\",\n        \"fs-extra\": \"^10.1.0\"\n      }\n    },\n    \"node_modules/electron-builder-squirrel-windows/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/electron-builder-squirrel-windows/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/electron-builder-squirrel-windows/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/electron-builder/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/electron-builder/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/electron-builder/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/electron-publish\": {\n      \"version\": \"24.13.1\",\n      \"resolved\": \"https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz\",\n      \"integrity\": \"sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/fs-extra\": \"^9.0.11\",\n        \"builder-util\": \"24.13.1\",\n        \"builder-util-runtime\": \"9.2.4\",\n        \"chalk\": \"^4.1.2\",\n        \"fs-extra\": \"^10.1.0\",\n        \"lazy-val\": \"^1.0.5\",\n        \"mime\": \"^2.5.2\"\n      }\n    },\n    \"node_modules/electron-publish/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/electron-publish/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/electron-publish/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/electron-updater\": {\n      \"version\": \"6.8.3\",\n      \"resolved\": \"https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz\",\n      \"integrity\": \"sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"builder-util-runtime\": \"9.5.1\",\n        \"fs-extra\": \"^10.1.0\",\n        \"js-yaml\": \"^4.1.0\",\n        \"lazy-val\": \"^1.0.5\",\n        \"lodash.escaperegexp\": \"^4.1.2\",\n        \"lodash.isequal\": \"^4.5.0\",\n        \"semver\": \"~7.7.3\",\n        \"tiny-typed-emitter\": \"^2.1.0\"\n      }\n    },\n    \"node_modules/electron-updater/node_modules/builder-util-runtime\": {\n      \"version\": \"9.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz\",\n      \"integrity\": \"sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"debug\": \"^4.3.4\",\n        \"sax\": \"^1.2.4\"\n      },\n      \"engines\": {\n        \"node\": \">=12.0.0\"\n      }\n    },\n    \"node_modules/electron-updater/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/electron-updater/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/electron-updater/node_modules/semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\",\n      \"license\": \"ISC\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/electron-updater/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/emoji-regex\": {\n      \"version\": \"8.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz\",\n      \"integrity\": \"sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/end-of-stream\": {\n      \"version\": \"1.4.5\",\n      \"resolved\": \"https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz\",\n      \"integrity\": \"sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"once\": \"^1.4.0\"\n      }\n    },\n    \"node_modules/env-paths\": {\n      \"version\": \"2.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz\",\n      \"integrity\": \"sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/err-code\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz\",\n      \"integrity\": \"sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/es-define-property\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz\",\n      \"integrity\": \"sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/es-errors\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz\",\n      \"integrity\": \"sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/es-object-atoms\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz\",\n      \"integrity\": \"sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/es-set-tostringtag\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz\",\n      \"integrity\": \"sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\",\n        \"get-intrinsic\": \"^1.2.6\",\n        \"has-tostringtag\": \"^1.0.2\",\n        \"hasown\": \"^2.0.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/es6-error\": {\n      \"version\": \"4.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz\",\n      \"integrity\": \"sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==\",\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/escalade\": {\n      \"version\": \"3.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz\",\n      \"integrity\": \"sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/escape-string-regexp\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz\",\n      \"integrity\": \"sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/escodegen\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz\",\n      \"integrity\": \"sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"esprima\": \"^4.0.1\",\n        \"estraverse\": \"^5.2.0\",\n        \"esutils\": \"^2.0.2\"\n      },\n      \"bin\": {\n        \"escodegen\": \"bin/escodegen.js\",\n        \"esgenerate\": \"bin/esgenerate.js\"\n      },\n      \"engines\": {\n        \"node\": \">=6.0\"\n      },\n      \"optionalDependencies\": {\n        \"source-map\": \"~0.6.1\"\n      }\n    },\n    \"node_modules/esprima\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz\",\n      \"integrity\": \"sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"bin\": {\n        \"esparse\": \"bin/esparse.js\",\n        \"esvalidate\": \"bin/esvalidate.js\"\n      },\n      \"engines\": {\n        \"node\": \">=4\"\n      }\n    },\n    \"node_modules/estraverse\": {\n      \"version\": \"5.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz\",\n      \"integrity\": \"sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"engines\": {\n        \"node\": \">=4.0\"\n      }\n    },\n    \"node_modules/esutils\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz\",\n      \"integrity\": \"sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/events-universal\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz\",\n      \"integrity\": \"sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"bare-events\": \"^2.7.0\"\n      }\n    },\n    \"node_modules/extract-zip\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz\",\n      \"integrity\": \"sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==\",\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"debug\": \"^4.1.1\",\n        \"get-stream\": \"^5.1.0\",\n        \"yauzl\": \"^2.10.0\"\n      },\n      \"bin\": {\n        \"extract-zip\": \"cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">= 10.17.0\"\n      },\n      \"optionalDependencies\": {\n        \"@types/yauzl\": \"^2.9.1\"\n      }\n    },\n    \"node_modules/extsprintf\": {\n      \"version\": \"1.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz\",\n      \"integrity\": \"sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==\",\n      \"dev\": true,\n      \"engines\": [\n        \"node >=0.6.0\"\n      ],\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/fast-deep-equal\": {\n      \"version\": \"3.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz\",\n      \"integrity\": \"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/fast-fifo\": {\n      \"version\": \"1.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz\",\n      \"integrity\": \"sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/fast-json-stable-stringify\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz\",\n      \"integrity\": \"sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/fd-slicer\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz\",\n      \"integrity\": \"sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"pend\": \"~1.2.0\"\n      }\n    },\n    \"node_modules/filelist\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz\",\n      \"integrity\": \"sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"minimatch\": \"^5.0.1\"\n      }\n    },\n    \"node_modules/follow-redirects\": {\n      \"version\": \"1.15.11\",\n      \"resolved\": \"https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz\",\n      \"integrity\": \"sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==\",\n      \"funding\": [\n        {\n          \"type\": \"individual\",\n          \"url\": \"https://github.com/sponsors/RubenVerborgh\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=4.0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"debug\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/foreground-child\": {\n      \"version\": \"3.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz\",\n      \"integrity\": \"sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"cross-spawn\": \"^7.0.6\",\n        \"signal-exit\": \"^4.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=14\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/form-data\": {\n      \"version\": \"4.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz\",\n      \"integrity\": \"sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"asynckit\": \"^0.4.0\",\n        \"combined-stream\": \"^1.0.8\",\n        \"es-set-tostringtag\": \"^2.1.0\",\n        \"hasown\": \"^2.0.2\",\n        \"mime-types\": \"^2.1.12\"\n      },\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/fs-constants\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz\",\n      \"integrity\": \"sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/fs-extra\": {\n      \"version\": \"8.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz\",\n      \"integrity\": \"sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^4.0.0\",\n        \"universalify\": \"^0.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">=6 <7 || >=8\"\n      }\n    },\n    \"node_modules/fs-minipass\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz\",\n      \"integrity\": \"sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"minipass\": \"^3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/fs-minipass/node_modules/minipass\": {\n      \"version\": \"3.3.6\",\n      \"resolved\": \"https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz\",\n      \"integrity\": \"sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"yallist\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/fs.realpath\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz\",\n      \"integrity\": \"sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/fsevents\": {\n      \"version\": \"2.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz\",\n      \"integrity\": \"sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \"^8.16.0 || ^10.6.0 || >=11.0.0\"\n      }\n    },\n    \"node_modules/function-bind\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz\",\n      \"integrity\": \"sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==\",\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/get-caller-file\": {\n      \"version\": \"2.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz\",\n      \"integrity\": \"sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \"6.* || 8.* || >= 10.*\"\n      }\n    },\n    \"node_modules/get-intrinsic\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz\",\n      \"integrity\": \"sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"call-bind-apply-helpers\": \"^1.0.2\",\n        \"es-define-property\": \"^1.0.1\",\n        \"es-errors\": \"^1.3.0\",\n        \"es-object-atoms\": \"^1.1.1\",\n        \"function-bind\": \"^1.1.2\",\n        \"get-proto\": \"^1.0.1\",\n        \"gopd\": \"^1.2.0\",\n        \"has-symbols\": \"^1.1.0\",\n        \"hasown\": \"^2.0.2\",\n        \"math-intrinsics\": \"^1.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/get-proto\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz\",\n      \"integrity\": \"sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"dunder-proto\": \"^1.0.1\",\n        \"es-object-atoms\": \"^1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/get-stream\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz\",\n      \"integrity\": \"sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"pump\": \"^3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/get-uri\": {\n      \"version\": \"6.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz\",\n      \"integrity\": \"sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"basic-ftp\": \"^5.0.2\",\n        \"data-uri-to-buffer\": \"^6.0.2\",\n        \"debug\": \"^4.3.4\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/glob\": {\n      \"version\": \"7.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/glob/-/glob-7.2.3.tgz\",\n      \"integrity\": \"sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==\",\n      \"deprecated\": \"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"fs.realpath\": \"^1.0.0\",\n        \"inflight\": \"^1.0.4\",\n        \"inherits\": \"2\",\n        \"minimatch\": \"^3.1.1\",\n        \"once\": \"^1.3.0\",\n        \"path-is-absolute\": \"^1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \"*\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/glob/node_modules/brace-expansion\": {\n      \"version\": \"1.1.12\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\",\n      \"integrity\": \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^1.0.0\",\n        \"concat-map\": \"0.0.1\"\n      }\n    },\n    \"node_modules/glob/node_modules/minimatch\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz\",\n      \"integrity\": \"sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^1.1.7\"\n      },\n      \"engines\": {\n        \"node\": \"*\"\n      }\n    },\n    \"node_modules/global-agent\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz\",\n      \"integrity\": \"sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==\",\n      \"license\": \"BSD-3-Clause\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"boolean\": \"^3.0.1\",\n        \"es6-error\": \"^4.1.1\",\n        \"matcher\": \"^3.0.0\",\n        \"roarr\": \"^2.15.3\",\n        \"semver\": \"^7.3.2\",\n        \"serialize-error\": \"^7.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=10.0\"\n      }\n    },\n    \"node_modules/global-agent/node_modules/semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\",\n      \"license\": \"ISC\",\n      \"optional\": true,\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/globalthis\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz\",\n      \"integrity\": \"sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"define-properties\": \"^1.2.1\",\n        \"gopd\": \"^1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/gopd\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz\",\n      \"integrity\": \"sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/got\": {\n      \"version\": \"11.8.6\",\n      \"resolved\": \"https://registry.npmjs.org/got/-/got-11.8.6.tgz\",\n      \"integrity\": \"sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@sindresorhus/is\": \"^4.0.0\",\n        \"@szmarczak/http-timer\": \"^4.0.5\",\n        \"@types/cacheable-request\": \"^6.0.1\",\n        \"@types/responselike\": \"^1.0.0\",\n        \"cacheable-lookup\": \"^5.0.3\",\n        \"cacheable-request\": \"^7.0.2\",\n        \"decompress-response\": \"^6.0.0\",\n        \"http2-wrapper\": \"^1.0.0-beta.5.2\",\n        \"lowercase-keys\": \"^2.0.0\",\n        \"p-cancelable\": \"^2.0.0\",\n        \"responselike\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10.19.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sindresorhus/got?sponsor=1\"\n      }\n    },\n    \"node_modules/graceful-fs\": {\n      \"version\": \"4.2.11\",\n      \"resolved\": \"https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz\",\n      \"integrity\": \"sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==\",\n      \"license\": \"ISC\"\n    },\n    \"node_modules/has-flag\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz\",\n      \"integrity\": \"sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/has-property-descriptors\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz\",\n      \"integrity\": \"sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"es-define-property\": \"^1.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/has-symbols\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz\",\n      \"integrity\": \"sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/has-tostringtag\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz\",\n      \"integrity\": \"sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"has-symbols\": \"^1.0.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/hasown\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz\",\n      \"integrity\": \"sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"function-bind\": \"^1.1.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/hosted-git-info\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz\",\n      \"integrity\": \"sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"lru-cache\": \"^6.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/http-cache-semantics\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz\",\n      \"integrity\": \"sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==\",\n      \"license\": \"BSD-2-Clause\"\n    },\n    \"node_modules/http-proxy-agent\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz\",\n      \"integrity\": \"sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@tootallnate/once\": \"2\",\n        \"agent-base\": \"6\",\n        \"debug\": \"4\"\n      },\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/http2-wrapper\": {\n      \"version\": \"1.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz\",\n      \"integrity\": \"sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"quick-lru\": \"^5.1.1\",\n        \"resolve-alpn\": \"^1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10.19.0\"\n      }\n    },\n    \"node_modules/https-proxy-agent\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz\",\n      \"integrity\": \"sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"6\",\n        \"debug\": \"4\"\n      },\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/iconv-corefoundation\": {\n      \"version\": \"1.1.7\",\n      \"resolved\": \"https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz\",\n      \"integrity\": \"sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"dependencies\": {\n        \"cli-truncate\": \"^2.1.0\",\n        \"node-addon-api\": \"^1.6.3\"\n      },\n      \"engines\": {\n        \"node\": \"^8.11.2 || >=10\"\n      }\n    },\n    \"node_modules/iconv-lite\": {\n      \"version\": \"0.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz\",\n      \"integrity\": \"sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"safer-buffer\": \">= 2.1.2 < 3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/ieee754\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz\",\n      \"integrity\": \"sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ],\n      \"license\": \"BSD-3-Clause\"\n    },\n    \"node_modules/inflight\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz\",\n      \"integrity\": \"sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==\",\n      \"deprecated\": \"This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"once\": \"^1.3.0\",\n        \"wrappy\": \"1\"\n      }\n    },\n    \"node_modules/inherits\": {\n      \"version\": \"2.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz\",\n      \"integrity\": \"sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/ip-address\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz\",\n      \"integrity\": \"sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 12\"\n      }\n    },\n    \"node_modules/is-ci\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz\",\n      \"integrity\": \"sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ci-info\": \"^3.2.0\"\n      },\n      \"bin\": {\n        \"is-ci\": \"bin.js\"\n      }\n    },\n    \"node_modules/is-fullwidth-code-point\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz\",\n      \"integrity\": \"sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/isarray\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz\",\n      \"integrity\": \"sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/isbinaryfile\": {\n      \"version\": \"5.0.7\",\n      \"resolved\": \"https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz\",\n      \"integrity\": \"sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 18.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/gjtorikian/\"\n      }\n    },\n    \"node_modules/isexe\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz\",\n      \"integrity\": \"sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/jackspeak\": {\n      \"version\": \"3.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz\",\n      \"integrity\": \"sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\",\n      \"dependencies\": {\n        \"@isaacs/cliui\": \"^8.0.2\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      },\n      \"optionalDependencies\": {\n        \"@pkgjs/parseargs\": \"^0.11.0\"\n      }\n    },\n    \"node_modules/jake\": {\n      \"version\": \"10.9.4\",\n      \"resolved\": \"https://registry.npmjs.org/jake/-/jake-10.9.4.tgz\",\n      \"integrity\": \"sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"async\": \"^3.2.6\",\n        \"filelist\": \"^1.0.4\",\n        \"picocolors\": \"^1.1.1\"\n      },\n      \"bin\": {\n        \"jake\": \"bin/cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/js-yaml\": {\n      \"version\": \"4.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz\",\n      \"integrity\": \"sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"argparse\": \"^2.0.1\"\n      },\n      \"bin\": {\n        \"js-yaml\": \"bin/js-yaml.js\"\n      }\n    },\n    \"node_modules/json-buffer\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz\",\n      \"integrity\": \"sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/json-schema-traverse\": {\n      \"version\": \"0.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz\",\n      \"integrity\": \"sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/json-stringify-safe\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz\",\n      \"integrity\": \"sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==\",\n      \"license\": \"ISC\",\n      \"optional\": true\n    },\n    \"node_modules/json5\": {\n      \"version\": \"2.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/json5/-/json5-2.2.3.tgz\",\n      \"integrity\": \"sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"json5\": \"lib/cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/jsonfile\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz\",\n      \"integrity\": \"sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==\",\n      \"license\": \"MIT\",\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/keyv\": {\n      \"version\": \"4.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz\",\n      \"integrity\": \"sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"json-buffer\": \"3.0.1\"\n      }\n    },\n    \"node_modules/lazy-val\": {\n      \"version\": \"1.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz\",\n      \"integrity\": \"sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/lazystream\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz\",\n      \"integrity\": \"sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"readable-stream\": \"^2.0.5\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6.3\"\n      }\n    },\n    \"node_modules/lazystream/node_modules/readable-stream\": {\n      \"version\": \"2.3.8\",\n      \"resolved\": \"https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz\",\n      \"integrity\": \"sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"core-util-is\": \"~1.0.0\",\n        \"inherits\": \"~2.0.3\",\n        \"isarray\": \"~1.0.0\",\n        \"process-nextick-args\": \"~2.0.0\",\n        \"safe-buffer\": \"~5.1.1\",\n        \"string_decoder\": \"~1.1.1\",\n        \"util-deprecate\": \"~1.0.1\"\n      }\n    },\n    \"node_modules/lazystream/node_modules/safe-buffer\": {\n      \"version\": \"5.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz\",\n      \"integrity\": \"sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/lazystream/node_modules/string_decoder\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz\",\n      \"integrity\": \"sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"safe-buffer\": \"~5.1.0\"\n      }\n    },\n    \"node_modules/lodash\": {\n      \"version\": \"4.17.23\",\n      \"resolved\": \"https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz\",\n      \"integrity\": \"sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/lodash.defaults\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz\",\n      \"integrity\": \"sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/lodash.difference\": {\n      \"version\": \"4.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz\",\n      \"integrity\": \"sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/lodash.escaperegexp\": {\n      \"version\": \"4.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz\",\n      \"integrity\": \"sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/lodash.flatten\": {\n      \"version\": \"4.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz\",\n      \"integrity\": \"sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/lodash.isequal\": {\n      \"version\": \"4.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz\",\n      \"integrity\": \"sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==\",\n      \"deprecated\": \"This package is deprecated. Use require('node:util').isDeepStrictEqual instead.\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/lodash.isplainobject\": {\n      \"version\": \"4.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz\",\n      \"integrity\": \"sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/lodash.union\": {\n      \"version\": \"4.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz\",\n      \"integrity\": \"sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/lowercase-keys\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz\",\n      \"integrity\": \"sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/lru-cache\": {\n      \"version\": \"6.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz\",\n      \"integrity\": \"sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"yallist\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/matcher\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz\",\n      \"integrity\": \"sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"escape-string-regexp\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/math-intrinsics\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz\",\n      \"integrity\": \"sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/mime\": {\n      \"version\": \"2.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/mime/-/mime-2.6.0.tgz\",\n      \"integrity\": \"sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"mime\": \"cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=4.0.0\"\n      }\n    },\n    \"node_modules/mime-db\": {\n      \"version\": \"1.52.0\",\n      \"resolved\": \"https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz\",\n      \"integrity\": \"sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/mime-types\": {\n      \"version\": \"2.1.35\",\n      \"resolved\": \"https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz\",\n      \"integrity\": \"sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"mime-db\": \"1.52.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/mimic-response\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz\",\n      \"integrity\": \"sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=4\"\n      }\n    },\n    \"node_modules/minimatch\": {\n      \"version\": \"5.1.6\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz\",\n      \"integrity\": \"sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/minimist\": {\n      \"version\": \"1.2.8\",\n      \"resolved\": \"https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz\",\n      \"integrity\": \"sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/minipass\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz\",\n      \"integrity\": \"sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/minizlib\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz\",\n      \"integrity\": \"sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"minipass\": \"^3.0.0\",\n        \"yallist\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/minizlib/node_modules/minipass\": {\n      \"version\": \"3.3.6\",\n      \"resolved\": \"https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz\",\n      \"integrity\": \"sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"yallist\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/mitt\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz\",\n      \"integrity\": \"sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/mkdirp\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz\",\n      \"integrity\": \"sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"mkdirp\": \"bin/cmd.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/ms\": {\n      \"version\": \"2.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/ms/-/ms-2.1.3.tgz\",\n      \"integrity\": \"sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/netmask\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz\",\n      \"integrity\": \"sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 0.4.0\"\n      }\n    },\n    \"node_modules/node-addon-api\": {\n      \"version\": \"1.7.2\",\n      \"resolved\": \"https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz\",\n      \"integrity\": \"sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/normalize-path\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz\",\n      \"integrity\": \"sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/normalize-url\": {\n      \"version\": \"6.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz\",\n      \"integrity\": \"sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/object-keys\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz\",\n      \"integrity\": \"sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/once\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/once/-/once-1.4.0.tgz\",\n      \"integrity\": \"sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==\",\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"wrappy\": \"1\"\n      }\n    },\n    \"node_modules/p-cancelable\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz\",\n      \"integrity\": \"sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/pac-proxy-agent\": {\n      \"version\": \"7.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz\",\n      \"integrity\": \"sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@tootallnate/quickjs-emscripten\": \"^0.23.0\",\n        \"agent-base\": \"^7.1.2\",\n        \"debug\": \"^4.3.4\",\n        \"get-uri\": \"^6.0.1\",\n        \"http-proxy-agent\": \"^7.0.0\",\n        \"https-proxy-agent\": \"^7.0.6\",\n        \"pac-resolver\": \"^7.0.1\",\n        \"socks-proxy-agent\": \"^8.0.5\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/pac-proxy-agent/node_modules/agent-base\": {\n      \"version\": \"7.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz\",\n      \"integrity\": \"sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/pac-proxy-agent/node_modules/http-proxy-agent\": {\n      \"version\": \"7.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz\",\n      \"integrity\": \"sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"^7.1.0\",\n        \"debug\": \"^4.3.4\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/pac-proxy-agent/node_modules/https-proxy-agent\": {\n      \"version\": \"7.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz\",\n      \"integrity\": \"sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"^7.1.2\",\n        \"debug\": \"4\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/pac-resolver\": {\n      \"version\": \"7.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz\",\n      \"integrity\": \"sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"degenerator\": \"^5.0.0\",\n        \"netmask\": \"^2.0.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/package-json-from-dist\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz\",\n      \"integrity\": \"sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\"\n    },\n    \"node_modules/path-is-absolute\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz\",\n      \"integrity\": \"sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/path-key\": {\n      \"version\": \"3.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz\",\n      \"integrity\": \"sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/path-scurry\": {\n      \"version\": \"1.11.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz\",\n      \"integrity\": \"sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\",\n      \"dependencies\": {\n        \"lru-cache\": \"^10.2.0\",\n        \"minipass\": \"^5.0.0 || ^6.0.2 || ^7.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=16 || 14 >=14.18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/path-scurry/node_modules/lru-cache\": {\n      \"version\": \"10.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz\",\n      \"integrity\": \"sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/pend\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/pend/-/pend-1.2.0.tgz\",\n      \"integrity\": \"sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/picocolors\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz\",\n      \"integrity\": \"sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/playwright\": {\n      \"version\": \"1.59.1\",\n      \"resolved\": \"https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz\",\n      \"integrity\": \"sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"playwright-core\": \"1.59.1\"\n      },\n      \"bin\": {\n        \"playwright\": \"cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"optionalDependencies\": {\n        \"fsevents\": \"2.3.2\"\n      }\n    },\n    \"node_modules/playwright-core\": {\n      \"version\": \"1.59.1\",\n      \"resolved\": \"https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz\",\n      \"integrity\": \"sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"bin\": {\n        \"playwright-core\": \"cli.js\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/plist\": {\n      \"version\": \"3.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/plist/-/plist-3.1.0.tgz\",\n      \"integrity\": \"sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@xmldom/xmldom\": \"^0.8.8\",\n        \"base64-js\": \"^1.5.1\",\n        \"xmlbuilder\": \"^15.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">=10.4.0\"\n      }\n    },\n    \"node_modules/posthog-node\": {\n      \"version\": \"4.18.0\",\n      \"resolved\": \"https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz\",\n      \"integrity\": \"sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"axios\": \"^1.8.2\"\n      },\n      \"engines\": {\n        \"node\": \">=15.0.0\"\n      }\n    },\n    \"node_modules/process-nextick-args\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz\",\n      \"integrity\": \"sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/progress\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/progress/-/progress-2.0.3.tgz\",\n      \"integrity\": \"sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.4.0\"\n      }\n    },\n    \"node_modules/promise-retry\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz\",\n      \"integrity\": \"sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"err-code\": \"^2.0.2\",\n        \"retry\": \"^0.12.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/proxy-agent\": {\n      \"version\": \"6.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz\",\n      \"integrity\": \"sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"^7.1.2\",\n        \"debug\": \"^4.3.4\",\n        \"http-proxy-agent\": \"^7.0.1\",\n        \"https-proxy-agent\": \"^7.0.6\",\n        \"lru-cache\": \"^7.14.1\",\n        \"pac-proxy-agent\": \"^7.1.0\",\n        \"proxy-from-env\": \"^1.1.0\",\n        \"socks-proxy-agent\": \"^8.0.5\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/proxy-agent/node_modules/agent-base\": {\n      \"version\": \"7.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz\",\n      \"integrity\": \"sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/proxy-agent/node_modules/http-proxy-agent\": {\n      \"version\": \"7.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz\",\n      \"integrity\": \"sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"^7.1.0\",\n        \"debug\": \"^4.3.4\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/proxy-agent/node_modules/https-proxy-agent\": {\n      \"version\": \"7.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz\",\n      \"integrity\": \"sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"^7.1.2\",\n        \"debug\": \"4\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/proxy-agent/node_modules/lru-cache\": {\n      \"version\": \"7.18.3\",\n      \"resolved\": \"https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz\",\n      \"integrity\": \"sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/proxy-from-env\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz\",\n      \"integrity\": \"sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/pump\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/pump/-/pump-3.0.3.tgz\",\n      \"integrity\": \"sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"end-of-stream\": \"^1.1.0\",\n        \"once\": \"^1.3.1\"\n      }\n    },\n    \"node_modules/punycode\": {\n      \"version\": \"2.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz\",\n      \"integrity\": \"sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/puppeteer-core\": {\n      \"version\": \"24.37.4\",\n      \"resolved\": \"https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.4.tgz\",\n      \"integrity\": \"sha512-sQYtYgaNaLYO82k2FHmr7bR1tCmo2fBupEI7Kd0WpBlMropNcfxSTLOJXVRkhiHig0dUiMI7g0yq+HJI1IDCzg==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@puppeteer/browsers\": \"2.13.0\",\n        \"chromium-bidi\": \"14.0.0\",\n        \"debug\": \"^4.4.3\",\n        \"devtools-protocol\": \"0.0.1566079\",\n        \"typed-query-selector\": \"^2.12.0\",\n        \"webdriver-bidi-protocol\": \"0.4.1\",\n        \"ws\": \"^8.19.0\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/quick-lru\": {\n      \"version\": \"5.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz\",\n      \"integrity\": \"sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/read-config-file\": {\n      \"version\": \"6.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz\",\n      \"integrity\": \"sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"config-file-ts\": \"^0.2.4\",\n        \"dotenv\": \"^9.0.2\",\n        \"dotenv-expand\": \"^5.1.0\",\n        \"js-yaml\": \"^4.1.0\",\n        \"json5\": \"^2.2.0\",\n        \"lazy-val\": \"^1.0.4\"\n      },\n      \"engines\": {\n        \"node\": \">=12.0.0\"\n      }\n    },\n    \"node_modules/readable-stream\": {\n      \"version\": \"3.6.2\",\n      \"resolved\": \"https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz\",\n      \"integrity\": \"sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"inherits\": \"^2.0.3\",\n        \"string_decoder\": \"^1.1.1\",\n        \"util-deprecate\": \"^1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/readdir-glob\": {\n      \"version\": \"1.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz\",\n      \"integrity\": \"sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"minimatch\": \"^5.1.0\"\n      }\n    },\n    \"node_modules/require-directory\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz\",\n      \"integrity\": \"sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/resolve-alpn\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz\",\n      \"integrity\": \"sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/responselike\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz\",\n      \"integrity\": \"sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"lowercase-keys\": \"^2.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/retry\": {\n      \"version\": \"0.12.0\",\n      \"resolved\": \"https://registry.npmjs.org/retry/-/retry-0.12.0.tgz\",\n      \"integrity\": \"sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 4\"\n      }\n    },\n    \"node_modules/roarr\": {\n      \"version\": \"2.15.4\",\n      \"resolved\": \"https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz\",\n      \"integrity\": \"sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==\",\n      \"license\": \"BSD-3-Clause\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"boolean\": \"^3.0.1\",\n        \"detect-node\": \"^2.0.4\",\n        \"globalthis\": \"^1.0.1\",\n        \"json-stringify-safe\": \"^5.0.1\",\n        \"semver-compare\": \"^1.0.0\",\n        \"sprintf-js\": \"^1.1.2\"\n      },\n      \"engines\": {\n        \"node\": \">=8.0\"\n      }\n    },\n    \"node_modules/safe-buffer\": {\n      \"version\": \"5.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz\",\n      \"integrity\": \"sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/safer-buffer\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz\",\n      \"integrity\": \"sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/sanitize-filename\": {\n      \"version\": \"1.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz\",\n      \"integrity\": \"sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==\",\n      \"dev\": true,\n      \"license\": \"WTFPL OR ISC\",\n      \"dependencies\": {\n        \"truncate-utf8-bytes\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/sax\": {\n      \"version\": \"1.4.4\",\n      \"resolved\": \"https://registry.npmjs.org/sax/-/sax-1.4.4.tgz\",\n      \"integrity\": \"sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==\",\n      \"license\": \"BlueOak-1.0.0\",\n      \"engines\": {\n        \"node\": \">=11.0.0\"\n      }\n    },\n    \"node_modules/semver\": {\n      \"version\": \"6.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-6.3.1.tgz\",\n      \"integrity\": \"sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==\",\n      \"license\": \"ISC\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      }\n    },\n    \"node_modules/semver-compare\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz\",\n      \"integrity\": \"sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==\",\n      \"license\": \"MIT\",\n      \"optional\": true\n    },\n    \"node_modules/serialize-error\": {\n      \"version\": \"7.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz\",\n      \"integrity\": \"sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==\",\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"type-fest\": \"^0.13.1\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/shebang-command\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz\",\n      \"integrity\": \"sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"shebang-regex\": \"^3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/shebang-regex\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz\",\n      \"integrity\": \"sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/signal-exit\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz\",\n      \"integrity\": \"sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">=14\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/simple-update-notifier\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz\",\n      \"integrity\": \"sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"semver\": \"^7.5.3\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/simple-update-notifier/node_modules/semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/slice-ansi\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz\",\n      \"integrity\": \"sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"ansi-styles\": \"^4.0.0\",\n        \"astral-regex\": \"^2.0.0\",\n        \"is-fullwidth-code-point\": \"^3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/smart-buffer\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz\",\n      \"integrity\": \"sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 6.0.0\",\n        \"npm\": \">= 3.0.0\"\n      }\n    },\n    \"node_modules/socks\": {\n      \"version\": \"2.8.7\",\n      \"resolved\": \"https://registry.npmjs.org/socks/-/socks-2.8.7.tgz\",\n      \"integrity\": \"sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ip-address\": \"^10.0.1\",\n        \"smart-buffer\": \"^4.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10.0.0\",\n        \"npm\": \">= 3.0.0\"\n      }\n    },\n    \"node_modules/socks-proxy-agent\": {\n      \"version\": \"8.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz\",\n      \"integrity\": \"sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"agent-base\": \"^7.1.2\",\n        \"debug\": \"^4.3.4\",\n        \"socks\": \"^2.8.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/socks-proxy-agent/node_modules/agent-base\": {\n      \"version\": \"7.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz\",\n      \"integrity\": \"sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 14\"\n      }\n    },\n    \"node_modules/source-map\": {\n      \"version\": \"0.6.1\",\n      \"resolved\": \"https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz\",\n      \"integrity\": \"sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==\",\n      \"dev\": true,\n      \"license\": \"BSD-3-Clause\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/source-map-support\": {\n      \"version\": \"0.5.21\",\n      \"resolved\": \"https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz\",\n      \"integrity\": \"sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"buffer-from\": \"^1.0.0\",\n        \"source-map\": \"^0.6.0\"\n      }\n    },\n    \"node_modules/sprintf-js\": {\n      \"version\": \"1.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz\",\n      \"integrity\": \"sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==\",\n      \"license\": \"BSD-3-Clause\",\n      \"optional\": true\n    },\n    \"node_modules/stat-mode\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz\",\n      \"integrity\": \"sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/streamx\": {\n      \"version\": \"2.23.0\",\n      \"resolved\": \"https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz\",\n      \"integrity\": \"sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"events-universal\": \"^1.0.0\",\n        \"fast-fifo\": \"^1.3.2\",\n        \"text-decoder\": \"^1.1.0\"\n      }\n    },\n    \"node_modules/string_decoder\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz\",\n      \"integrity\": \"sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"safe-buffer\": \"~5.2.0\"\n      }\n    },\n    \"node_modules/string-width\": {\n      \"version\": \"4.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz\",\n      \"integrity\": \"sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"emoji-regex\": \"^8.0.0\",\n        \"is-fullwidth-code-point\": \"^3.0.0\",\n        \"strip-ansi\": \"^6.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/string-width-cjs\": {\n      \"name\": \"string-width\",\n      \"version\": \"4.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz\",\n      \"integrity\": \"sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"emoji-regex\": \"^8.0.0\",\n        \"is-fullwidth-code-point\": \"^3.0.0\",\n        \"strip-ansi\": \"^6.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/strip-ansi\": {\n      \"version\": \"6.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz\",\n      \"integrity\": \"sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-regex\": \"^5.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/strip-ansi-cjs\": {\n      \"name\": \"strip-ansi\",\n      \"version\": \"6.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz\",\n      \"integrity\": \"sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-regex\": \"^5.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/sumchecker\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz\",\n      \"integrity\": \"sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"debug\": \"^4.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 8.0\"\n      }\n    },\n    \"node_modules/supports-color\": {\n      \"version\": \"7.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz\",\n      \"integrity\": \"sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"has-flag\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/tar\": {\n      \"version\": \"6.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/tar/-/tar-6.2.1.tgz\",\n      \"integrity\": \"sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==\",\n      \"deprecated\": \"Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"chownr\": \"^2.0.0\",\n        \"fs-minipass\": \"^2.0.0\",\n        \"minipass\": \"^5.0.0\",\n        \"minizlib\": \"^2.1.1\",\n        \"mkdirp\": \"^1.0.3\",\n        \"yallist\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/tar-fs\": {\n      \"version\": \"3.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz\",\n      \"integrity\": \"sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"pump\": \"^3.0.0\",\n        \"tar-stream\": \"^3.1.5\"\n      },\n      \"optionalDependencies\": {\n        \"bare-fs\": \"^4.0.1\",\n        \"bare-path\": \"^3.0.0\"\n      }\n    },\n    \"node_modules/tar-fs/node_modules/tar-stream\": {\n      \"version\": \"3.1.7\",\n      \"resolved\": \"https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz\",\n      \"integrity\": \"sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"b4a\": \"^1.6.4\",\n        \"fast-fifo\": \"^1.2.0\",\n        \"streamx\": \"^2.15.0\"\n      }\n    },\n    \"node_modules/tar-stream\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz\",\n      \"integrity\": \"sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"bl\": \"^4.0.3\",\n        \"end-of-stream\": \"^1.4.1\",\n        \"fs-constants\": \"^1.0.0\",\n        \"inherits\": \"^2.0.3\",\n        \"readable-stream\": \"^3.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/teex\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/teex/-/teex-1.0.1.tgz\",\n      \"integrity\": \"sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"streamx\": \"^2.12.5\"\n      }\n    },\n    \"node_modules/temp-file\": {\n      \"version\": \"3.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz\",\n      \"integrity\": \"sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"async-exit-hook\": \"^2.0.1\",\n        \"fs-extra\": \"^10.0.0\"\n      }\n    },\n    \"node_modules/temp-file/node_modules/fs-extra\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz\",\n      \"integrity\": \"sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.0\",\n        \"jsonfile\": \"^6.0.1\",\n        \"universalify\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/temp-file/node_modules/jsonfile\": {\n      \"version\": \"6.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz\",\n      \"integrity\": \"sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"universalify\": \"^2.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"graceful-fs\": \"^4.1.6\"\n      }\n    },\n    \"node_modules/temp-file/node_modules/universalify\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz\",\n      \"integrity\": \"sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      }\n    },\n    \"node_modules/text-decoder\": {\n      \"version\": \"1.2.7\",\n      \"resolved\": \"https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz\",\n      \"integrity\": \"sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"b4a\": \"^1.6.4\"\n      }\n    },\n    \"node_modules/tiny-typed-emitter\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz\",\n      \"integrity\": \"sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/tmp\": {\n      \"version\": \"0.2.5\",\n      \"resolved\": \"https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz\",\n      \"integrity\": \"sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=14.14\"\n      }\n    },\n    \"node_modules/tmp-promise\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz\",\n      \"integrity\": \"sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"tmp\": \"^0.2.0\"\n      }\n    },\n    \"node_modules/truncate-utf8-bytes\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz\",\n      \"integrity\": \"sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==\",\n      \"dev\": true,\n      \"license\": \"WTFPL\",\n      \"dependencies\": {\n        \"utf8-byte-length\": \"^1.0.1\"\n      }\n    },\n    \"node_modules/tslib\": {\n      \"version\": \"2.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz\",\n      \"integrity\": \"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==\",\n      \"dev\": true,\n      \"license\": \"0BSD\"\n    },\n    \"node_modules/type-fest\": {\n      \"version\": \"0.13.1\",\n      \"resolved\": \"https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz\",\n      \"integrity\": \"sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==\",\n      \"license\": \"(MIT OR CC0-1.0)\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/typed-query-selector\": {\n      \"version\": \"2.12.0\",\n      \"resolved\": \"https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz\",\n      \"integrity\": \"sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/typescript\": {\n      \"version\": \"5.9.3\",\n      \"resolved\": \"https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz\",\n      \"integrity\": \"sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"bin\": {\n        \"tsc\": \"bin/tsc\",\n        \"tsserver\": \"bin/tsserver\"\n      },\n      \"engines\": {\n        \"node\": \">=14.17\"\n      }\n    },\n    \"node_modules/undici-types\": {\n      \"version\": \"6.21.0\",\n      \"resolved\": \"https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz\",\n      \"integrity\": \"sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/universalify\": {\n      \"version\": \"0.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz\",\n      \"integrity\": \"sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 4.0.0\"\n      }\n    },\n    \"node_modules/uri-js\": {\n      \"version\": \"4.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz\",\n      \"integrity\": \"sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"punycode\": \"^2.1.0\"\n      }\n    },\n    \"node_modules/utf8-byte-length\": {\n      \"version\": \"1.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz\",\n      \"integrity\": \"sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==\",\n      \"dev\": true,\n      \"license\": \"(WTFPL OR MIT)\"\n    },\n    \"node_modules/util-deprecate\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz\",\n      \"integrity\": \"sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/verror\": {\n      \"version\": \"1.10.1\",\n      \"resolved\": \"https://registry.npmjs.org/verror/-/verror-1.10.1.tgz\",\n      \"integrity\": \"sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"assert-plus\": \"^1.0.0\",\n        \"core-util-is\": \"1.0.2\",\n        \"extsprintf\": \"^1.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">=0.6.0\"\n      }\n    },\n    \"node_modules/webdriver-bidi-protocol\": {\n      \"version\": \"0.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz\",\n      \"integrity\": \"sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\"\n    },\n    \"node_modules/which\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/which/-/which-2.0.2.tgz\",\n      \"integrity\": \"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"isexe\": \"^2.0.0\"\n      },\n      \"bin\": {\n        \"node-which\": \"bin/node-which\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/wrap-ansi\": {\n      \"version\": \"7.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz\",\n      \"integrity\": \"sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-styles\": \"^4.0.0\",\n        \"string-width\": \"^4.1.0\",\n        \"strip-ansi\": \"^6.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/wrap-ansi?sponsor=1\"\n      }\n    },\n    \"node_modules/wrap-ansi-cjs\": {\n      \"name\": \"wrap-ansi\",\n      \"version\": \"7.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz\",\n      \"integrity\": \"sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"ansi-styles\": \"^4.0.0\",\n        \"string-width\": \"^4.1.0\",\n        \"strip-ansi\": \"^6.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/wrap-ansi?sponsor=1\"\n      }\n    },\n    \"node_modules/wrappy\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz\",\n      \"integrity\": \"sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==\",\n      \"license\": \"ISC\"\n    },\n    \"node_modules/ws\": {\n      \"version\": \"8.19.0\",\n      \"resolved\": \"https://registry.npmjs.org/ws/-/ws-8.19.0.tgz\",\n      \"integrity\": \"sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10.0.0\"\n      },\n      \"peerDependencies\": {\n        \"bufferutil\": \"^4.0.1\",\n        \"utf-8-validate\": \">=5.0.2\"\n      },\n      \"peerDependenciesMeta\": {\n        \"bufferutil\": {\n          \"optional\": true\n        },\n        \"utf-8-validate\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/xmlbuilder\": {\n      \"version\": \"15.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz\",\n      \"integrity\": \"sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8.0\"\n      }\n    },\n    \"node_modules/y18n\": {\n      \"version\": \"5.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz\",\n      \"integrity\": \"sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/yallist\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz\",\n      \"integrity\": \"sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/yargs\": {\n      \"version\": \"17.7.2\",\n      \"resolved\": \"https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz\",\n      \"integrity\": \"sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"cliui\": \"^8.0.1\",\n        \"escalade\": \"^3.1.1\",\n        \"get-caller-file\": \"^2.0.5\",\n        \"require-directory\": \"^2.1.1\",\n        \"string-width\": \"^4.2.3\",\n        \"y18n\": \"^5.0.5\",\n        \"yargs-parser\": \"^21.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/yargs-parser\": {\n      \"version\": \"21.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz\",\n      \"integrity\": \"sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/yauzl\": {\n      \"version\": \"2.10.0\",\n      \"resolved\": \"https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz\",\n      \"integrity\": \"sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"buffer-crc32\": \"~0.2.3\",\n        \"fd-slicer\": \"~1.1.0\"\n      }\n    },\n    \"node_modules/zip-stream\": {\n      \"version\": \"4.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz\",\n      \"integrity\": \"sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"archiver-utils\": \"^3.0.4\",\n        \"compress-commons\": \"^4.1.2\",\n        \"readable-stream\": \"^3.6.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/zip-stream/node_modules/archiver-utils\": {\n      \"version\": \"3.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz\",\n      \"integrity\": \"sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"dependencies\": {\n        \"glob\": \"^7.2.3\",\n        \"graceful-fs\": \"^4.2.0\",\n        \"lazystream\": \"^1.0.0\",\n        \"lodash.defaults\": \"^4.2.0\",\n        \"lodash.difference\": \"^4.5.0\",\n        \"lodash.flatten\": \"^4.4.0\",\n        \"lodash.isplainobject\": \"^4.0.6\",\n        \"lodash.union\": \"^4.6.0\",\n        \"normalize-path\": \"^3.0.0\",\n        \"readable-stream\": \"^3.6.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/zod\": {\n      \"version\": \"3.25.76\",\n      \"resolved\": \"https://registry.npmjs.org/zod/-/zod-3.25.76.tgz\",\n      \"integrity\": \"sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/colinhacks\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/package.json",
    "content": "{\n  \"name\": \"stenoai\",\n  \"version\": \"0.2.13\",\n  \"description\": \"AI-powered meeting transcription and analysis for Mac\",\n  \"main\": \"main.js\",\n  \"homepage\": \"https://github.com/ruzin/stenoai\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/ruzin/stenoai.git\"\n  },\n  \"scripts\": {\n    \"start\": \"npm run build:renderer && electron .\",\n    \"start:nobuild\": \"electron .\",\n    \"dev:renderer\": \"vite\",\n    \"build:renderer\": \"vite build\",\n    \"typecheck:renderer\": \"tsc -p renderer/tsconfig.json --noEmit\",\n    \"lint:renderer\": \"eslint renderer/src --ext .ts,.tsx\",\n    \"format:renderer\": \"prettier --write renderer/src\",\n    \"build\": \"electron-builder\",\n    \"pack:unsigned\": \"electron-builder --dir --config electron-builder.ci.yml\",\n    \"build-mac\": \"electron-builder --mac\",\n    \"build:intel\": \"electron-builder --mac --x64\",\n    \"build:arm64\": \"electron-builder --mac --arm64\",\n    \"dist\": \"electron-builder --publish=never\",\n    \"pack\": \"electron-builder --dir\",\n    \"release\": \"electron-builder --publish=always\",\n    \"version:patch\": \"npm version patch && git push && git push --tags\",\n    \"version:minor\": \"npm version minor && git push && git push --tags\",\n    \"version:major\": \"npm version major && git push && git push --tags\",\n    \"release:patch\": \"npm run version:patch && npm run build\",\n    \"release:minor\": \"npm run version:minor && npm run build\",\n    \"release:major\": \"npm run version:major && npm run build\",\n    \"test:e2e\": \"playwright test --config ../e2e/playwright.config.ts\",\n    \"test:e2e:update\": \"playwright test --config ../e2e/playwright.config.ts --update-snapshots\"\n  },\n  \"keywords\": [\n    \"audio\",\n    \"transcription\",\n    \"recording\",\n    \"whisper\",\n    \"ollama\",\n    \"ai\",\n    \"meetings\"\n  ],\n  \"author\": \"StenoAI Team\",\n  \"license\": \"MIT\",\n  \"devDependencies\": {\n    \"@electron/notarize\": \"^3.1.1\",\n    \"@playwright/test\": \"^1.48.0\",\n    \"@types/node\": \"^20.12.0\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.59.0\",\n    \"@typescript-eslint/parser\": \"^8.59.0\",\n    \"@vitejs/plugin-react\": \"^4.7.0\",\n    \"autoprefixer\": \"^10.5.0\",\n    \"electron\": \"^31.0.1\",\n    \"electron-builder\": \"^24.0.0\",\n    \"eslint\": \"^9.39.4\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-react\": \"^7.37.5\",\n    \"eslint-plugin-react-hooks\": \"^7.1.1\",\n    \"playwright\": \"^1.48.0\",\n    \"postcss\": \"^8.5.10\",\n    \"prettier\": \"^3.8.3\",\n    \"puppeteer-core\": \"^24.37.3\",\n    \"tailwindcss\": \"^3.4.19\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"typescript\": \"^5.5.0\",\n    \"vite\": \"^5.4.21\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@tanstack/react-query\": \"^5.99.2\",\n    \"@tanstack/react-virtual\": \"^3.13.24\",\n    \"axios\": \"^1.6.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"electron-audio-loopback\": \"^1.0.0\",\n    \"electron-updater\": \"^6.8.3\",\n    \"lucide-react\": \"^1.8.0\",\n    \"posthog-node\": \"^4.18.0\",\n    \"react\": \"^19.2.5\",\n    \"react-dom\": \"^19.2.5\",\n    \"react-router-dom\": \"^7.14.2\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"zustand\": \"^5.0.12\"\n  },\n  \"build\": {\n    \"appId\": \"com.stenoai.recorder\",\n    \"productName\": \"StenoAI\",\n    \"protocols\": [\n      {\n        \"name\": \"StenoAI Shortcut Links\",\n        \"schemes\": [\n          \"stenoai\"\n        ]\n      }\n    ],\n    \"directories\": {\n      \"output\": \"dist\",\n      \"buildResources\": \"build\"\n    },\n    \"files\": [\n      \"**/*\",\n      \"!dist/**/*\",\n      \"!build/**/*\",\n      \"!assets/**/*\",\n      \"!.git/**/*\",\n      \"renderer/dist/**\",\n      \"preload.js\"\n    ],\n    \"extraResources\": [\n      {\n        \"from\": \"../dist/stenoai\",\n        \"to\": \"stenoai\",\n        \"filter\": [\n          \"**/*\"\n        ]\n      },\n      {\n        \"from\": \"assets\",\n        \"to\": \"assets\",\n        \"filter\": [\n          \"trayIcon*.png\"\n        ]\n      }\n    ],\n    \"mac\": {\n      \"icon\": \"build/icon-dragonfly.icns\",\n      \"category\": \"public.app-category.productivity\",\n      \"target\": [\n        \"dmg\",\n        \"zip\"\n      ],\n      \"entitlements\": \"build/entitlements.mac.plist\",\n      \"entitlementsInherit\": \"build/entitlements.mac.plist\",\n      \"hardenedRuntime\": true,\n      \"gatekeeperAssess\": false,\n      \"identity\": \"Skrape Limited (HSDX294RG4)\",\n      \"notarize\": {\n        \"teamId\": \"HSDX294RG4\"\n      },\n      \"extendInfo\": {\n        \"NSMicrophoneUsageDescription\": \"StenoAI needs microphone access to record and transcribe meetings.\",\n        \"NSScreenCaptureUsageDescription\": \"StenoAI needs screen capture access to record system audio from virtual meetings.\"\n      }\n    },\n    \"dmg\": {\n      \"title\": \"StenoAI Installer\",\n      \"contents\": [\n        {\n          \"x\": 130,\n          \"y\": 220,\n          \"type\": \"file\"\n        },\n        {\n          \"x\": 410,\n          \"y\": 220,\n          \"type\": \"link\",\n          \"path\": \"/Applications\"\n        }\n      ],\n      \"window\": {\n        \"width\": 540,\n        \"height\": 380\n      },\n      \"artifactName\": \"stenoAI-macos-${version}-${arch}.${ext}\",\n      \"sign\": true\n    },\n    \"publish\": {\n      \"provider\": \"github\",\n      \"owner\": \"ruzin\",\n      \"repo\": \"stenoai\"\n    }\n  }\n}\n"
  },
  {
    "path": "app/preload.js",
    "content": "/**\n * Preload — contextBridge boundary for the React renderer.\n *\n * This is the only surface the renderer gets. Every function here is a thin\n * wrapper over ipcRenderer.invoke / .send / .on, whitelisted to the channels\n * listed in app/docs/ipc-contract.md. Any drift between this file and that\n * doc is a contract break — update both in the same commit.\n */\n\nconst { contextBridge, ipcRenderer } = require('electron');\n\nconst VERSION = 1;\n\nconst invoke = (channel, ...args) => ipcRenderer.invoke(channel, ...args);\nconst send = (channel, ...args) => ipcRenderer.send(channel, ...args);\n\n// Every M→R event uses the same pattern: subscribe and return an unsubscribe\n// fn. The wrapper strips the IpcRendererEvent so the renderer only sees the\n// payload — that's an intentional part of the contract (renderer code must\n// stay unaware of Electron internals).\nconst subscribe = (channel, cb) => {\n  const handler = (_event, payload) => cb(payload);\n  ipcRenderer.on(channel, handler);\n  return () => ipcRenderer.removeListener(channel, handler);\n};\n\n// Streaming helper. query-chunk + query-done are both multiplexed across\n// every in-flight query; the helper filters by queryId so the caller only\n// gets events for the stream they asked for. Returns an unsubscribe fn\n// that also sends query-cancel to the main process.\nconst subscribeQueryStream = (queryId, { onChunk, onDone, onError } = {}) => {\n  const chunkHandler = (_event, payload) => {\n    if (payload && payload.queryId === queryId && onChunk) onChunk(payload.chunk);\n  };\n  const doneHandler = (_event, payload) => {\n    if (!payload || payload.queryId !== queryId) return;\n    cleanup();\n    if (payload.success) {\n      if (onDone) onDone();\n    } else {\n      if (onError) onError(new Error(payload.error || 'query failed'));\n    }\n  };\n  const cleanup = () => {\n    ipcRenderer.removeListener('query-chunk', chunkHandler);\n    ipcRenderer.removeListener('query-done', doneHandler);\n  };\n  ipcRenderer.on('query-chunk', chunkHandler);\n  ipcRenderer.on('query-done', doneHandler);\n  return () => {\n    cleanup();\n    ipcRenderer.send('query-cancel', queryId);\n  };\n};\n\nconst stenoai = {\n  version: VERSION,\n\n  app: {\n    getVersion: () => invoke('get-app-version'),\n  },\n\n  window: {\n    focus: () => send('focus-window'),\n    readyToShow: () => send('renderer-ready-to-show'),\n  },\n\n  shell: {\n    openExternal: (url) => invoke('open-external', url),\n  },\n\n  system: {\n    getStatus: () => invoke('get-status'),\n    test: () => invoke('test-system'),\n    clearState: () => invoke('clear-state'),\n  },\n\n  setup: {\n    check: () => invoke('startup-setup-check'),\n    systemCheck: () => invoke('setup-system-check'),\n    ffmpeg: () => invoke('setup-ffmpeg'),\n    python: () => invoke('setup-python'),\n    ollamaAndModel: () => invoke('setup-ollama-and-model'),\n    whisper: () => invoke('setup-whisper'),\n    test: () => invoke('setup-test'),\n    triggerWizard: () => invoke('trigger-setup-wizard'),\n  },\n\n  perm: {\n    checkMicrophone: () => invoke('check-microphone-permission'),\n    requestMicrophone: () => invoke('request-microphone-permission'),\n  },\n\n  recording: {\n    start: (name) => invoke('start-recording-ui', name),\n    stop: () => invoke('stop-recording-ui'),\n    pause: () => invoke('pause-recording-ui'),\n    resume: () => invoke('resume-recording-ui'),\n    reportSystemAudioState: (active) => send('system-audio-recording-state', active),\n    processSystemAudio: (filePath, name) => invoke('process-system-audio-recording', filePath, name),\n    processFile: (filePath, name) => invoke('process-recording', filePath, name),\n    pickAudioFile: () => invoke('select-audio-file'),\n    getQueue: () => invoke('get-queue-status'),\n    getDir: () => invoke('get-recordings-dir'),\n  },\n\n  meetings: {\n    list: () => invoke('list-meetings'),\n    update: (summaryFile, patch) => invoke('update-meeting', summaryFile, patch),\n    revealFolder: (filePath) => invoke('reveal-meeting-folder', filePath),\n    delete: (meeting) => invoke('delete-meeting', meeting),\n    reprocess: (summaryFile, regenTitle, name) => invoke('reprocess-meeting', summaryFile, regenTitle, name),\n    regenTitle: (summaryFile, name) => invoke('regen-meeting-title', summaryFile, name),\n    saveNotes: (name, notes) => invoke('save-meeting-notes', name, notes),\n  },\n\n  query: {\n    ask: (file, q) => invoke('query-transcript', file, q),\n    askStream: (id, file, q) => send('query-transcript-stream', id, file, q),\n    chatGlobalStream: (id, q, folderId) => send('chat-global-stream', id, q, folderId ?? null),\n    cancel: (id) => send('query-cancel', id),\n  },\n\n  chat: {\n    save: (data) => invoke('save-chat-sessions', data),\n    load: () => invoke('load-chat-sessions'),\n  },\n\n  folders: {\n    list: () => invoke('list-folders'),\n    create: (name, color) => invoke('create-folder', name, color),\n    rename: (id, name) => invoke('rename-folder', id, name),\n    updateIcon: (id, icon) => invoke('update-folder-icon', id, icon),\n    delete: (id) => invoke('delete-folder', id),\n    reorder: (ids) => invoke('reorder-folders', ids),\n    addMeeting: (summaryFile, folderId) => invoke('add-meeting-to-folder', summaryFile, folderId),\n    removeMeeting: (summaryFile, folderId) => invoke('remove-meeting-from-folder', summaryFile, folderId),\n  },\n\n  models: {\n    checkOllama: () => invoke('check-ollama-installed'),\n    list: () => invoke('list-models'),\n    getCurrent: () => invoke('get-current-model'),\n    set: (name) => invoke('set-model', name),\n    checkInstalled: (name) => invoke('check-model-installed', name),\n    pull: (name) => invoke('pull-model', name),\n  },\n\n  settings: {\n    getNotifications: () => invoke('get-notifications'),\n    setNotifications: (v) => invoke('set-notifications', v),\n    getTelemetry: () => invoke('get-telemetry'),\n    setTelemetry: (v) => invoke('set-telemetry', v),\n    getDockIcon: () => invoke('get-dock-icon'),\n    setDockIcon: (v) => invoke('set-dock-icon', v),\n    getSystemAudio: () => invoke('get-system-audio'),\n    setSystemAudio: (v) => invoke('set-system-audio', v),\n    getLanguage: () => invoke('get-language'),\n    setLanguage: (code) => invoke('set-language', code),\n    getUserName: () => invoke('get-user-name'),\n    setUserName: (name) => invoke('set-user-name', name),\n    getStoragePath: () => invoke('get-storage-path'),\n    setStoragePath: (p) => invoke('set-storage-path', p),\n    pickStorageFolder: () => invoke('select-storage-folder'),\n    getAiPrompts: () => invoke('get-ai-prompts'),\n  },\n\n  ai: {\n    getProvider: () => invoke('get-ai-provider'),\n    setProvider: (p) => invoke('set-ai-provider', p),\n    setRemoteOllamaUrl: (url) => invoke('set-remote-ollama-url', url),\n    testRemoteOllama: (url) => invoke('test-remote-ollama', url),\n    setCloudApiUrl: (url) => invoke('set-cloud-api-url', url),\n    setCloudApiKey: (key) => invoke('set-cloud-api-key', key),\n    setCloudProvider: (p) => invoke('set-cloud-provider', p),\n    setCloudModel: (m) => invoke('set-cloud-model', m),\n    testCloudApi: () => invoke('test-cloud-api'),\n  },\n\n  calendar: {\n    google: {\n      connect: () => invoke('google-auth-start'),\n      status: () => invoke('google-auth-status'),\n      disconnect: () => invoke('google-auth-disconnect'),\n    },\n    outlook: {\n      connect: () => invoke('outlook-auth-start'),\n      status: () => invoke('outlook-auth-status'),\n      disconnect: () => invoke('outlook-auth-disconnect'),\n    },\n    getEvents: () => invoke('get-calendar-events'),\n  },\n\n  updates: {\n    check: () => invoke('check-for-updates'),\n    announcements: () => invoke('check-announcements'),\n    openReleasePage: (url) => invoke('open-release-page', url),\n    install: () => send('install-update'),\n  },\n\n  shortcuts: {\n    rendererReady: () => send('shortcut-renderer-ready'),\n  },\n\n  dialog: {\n    respondQuit: (confirmed) => send('quit-dialog-response', { confirmed }),\n  },\n\n  // All main-driven events. Every subscribe returns an unsubscribe fn.\n  on: {\n    debugLog: (cb) => subscribe('debug-log', cb),\n    setupFlowTriggered: (cb) => subscribe('trigger-setup-flow', cb),\n    toggleRecordingHotkey: (cb) => subscribe('toggle-recording-hotkey', cb),\n    summaryChunk: (cb) => subscribe('summary-chunk', cb),\n    summaryTitle: (cb) => subscribe('summary-title', cb),\n    summaryComplete: (cb) => subscribe('summary-complete', cb),\n    processingComplete: (cb) => subscribe('processing-complete', cb),\n    queryChunk: (cb) => subscribe('query-chunk', cb),\n    queryDone: (cb) => subscribe('query-done', cb),\n    modelPullProgress: (cb) => subscribe('model-pull-progress', cb),\n    modelPullComplete: (cb) => subscribe('model-pull-complete', cb),\n    updateAvailable: (cb) => subscribe('update-available', cb),\n    updateDownloadProgress: (cb) => subscribe('update-download-progress', cb),\n    updateDownloaded: (cb) => subscribe('update-downloaded', cb),\n    googleAuthChanged: (cb) => subscribe('google-auth-changed', cb),\n    outlookAuthChanged: (cb) => subscribe('outlook-auth-changed', cb),\n    shortcutStartRecording: (cb) => subscribe('shortcut-start-recording', cb),\n    shortcutStopRecording: (cb) => subscribe('shortcut-stop-recording', cb),\n    trayStartRecording: (cb) => subscribe('tray-start-recording', cb),\n    trayStopRecording: (cb) => subscribe('tray-stop-recording', cb),\n    trayOpenSettings: (cb) => subscribe('tray-open-settings', cb),\n    showQuitDialog: (cb) => subscribe('show-quit-dialog', cb),\n  },\n\n  subscribeQueryStream,\n};\n\ncontextBridge.exposeInMainWorld('stenoai', stenoai);\n"
  },
  {
    "path": "app/renderer/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: { ecmaVersion: 2022, sourceType: 'module', ecmaFeatures: { jsx: true } },\n  env: { browser: true, es2022: true },\n  plugins: ['@typescript-eslint', 'react', 'react-hooks'],\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react/recommended',\n    'plugin:react-hooks/recommended',\n    'prettier',\n  ],\n  settings: { react: { version: 'detect' } },\n  rules: {\n    'react/react-in-jsx-scope': 'off',\n    'react/prop-types': 'off',\n    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],\n  },\n  ignorePatterns: ['dist', 'node_modules'],\n};\n"
  },
  {
    "path": "app/renderer/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"arrowParens\": \"always\"\n}\n"
  },
  {
    "path": "app/renderer/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.ts\",\n    \"css\": \"src/globals.css\",\n    \"baseColor\": \"stone\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "app/renderer/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';\" />\n    <title>StenoAI</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/renderer/postcss.config.cjs",
    "content": "const path = require('node:path');\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: { config: path.join(__dirname, 'tailwind.config.cjs') },\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "app/renderer/src/App.tsx",
    "content": "import * as React from 'react';\nimport { Sandbox } from '@/routes/Sandbox';\nimport { Settings } from '@/routes/Settings';\nimport { Setup } from '@/routes/Setup';\nimport { Chat } from '@/routes/Chat';\nimport { ChatConversation } from '@/routes/ChatConversation';\nimport { StreamingProvider } from '@/hooks/useStreamingQuery';\nimport { Home } from '@/routes/Home';\nimport { MeetingDetail } from '@/routes/MeetingDetail';\nimport { FolderDetail } from '@/routes/FolderDetail';\nimport { Recording } from '@/routes/Recording';\nimport { Processing, ProcessingDock } from '@/routes/Processing';\nimport { AskBar, TranscriptBar } from '@/components/AskBar';\nimport { BottomDockSlot } from '@/components/BottomDockSlot';\nimport { LiveDock } from '@/components/LiveDock';\nimport { QuitDialog } from '@/components/QuitDialog';\nimport { AskBarProvider } from '@/lib/askBarContext';\nimport {\n  useRecording,\n  useRecordingEvents,\n  useRecordingProcessingEffects,\n} from '@/hooks/useRecording';\nimport { navigate, useRoute, rememberNonSettingsRoute } from '@/lib/router';\nimport { ipc } from '@/lib/ipc';\nimport { primeDebugLogs } from '@/lib/debugLogs';\n\nexport function App() {\n  const route = useRoute();\n\n  React.useLayoutEffect(() => {\n    if (typeof window !== 'undefined' && window.stenoai) {\n      ipc().window.readyToShow();\n    }\n  }, []);\n\n  React.useEffect(() => {\n    if (typeof window === 'undefined' || !window.stenoai) return;\n    const off = [\n      ipc().on.trayOpenSettings(() => navigate('/settings')),\n      ipc().on.setupFlowTriggered(() => navigate('/setup')),\n      // Capture backend debug-log lines from app start (not just when Settings\n      // → Developer is open) so the console always has the full session.\n      primeDebugLogs((cb) => ipc().on.debugLog(cb)),\n    ];\n    return () => off.forEach((fn) => fn());\n  }, []);\n\n  useRecordingEvents();\n  useRecordingProcessingEffects();\n\n  // Track the last non-settings route so the sidebar Settings toggle and the\n  // Settings page's Back button can return the user to where they came from\n  // (e.g. a meeting they were viewing) instead of dropping them on Home.\n  React.useEffect(() => {\n    rememberNonSettingsRoute(route);\n  }, [route]);\n\n  // Cold-reload mid-processing: if we restart the app while the backend is\n  // still summarizing, drop the user on /meetings/processing so they don't\n  // sit on Home wondering what happened. Only fires once on first render.\n  const recording = useRecording();\n  const didAutoRouteRef = React.useRef(false);\n  React.useEffect(() => {\n    if (didAutoRouteRef.current) return;\n    if (recording.isLoading) return;\n    didAutoRouteRef.current = true;\n    if (\n      recording.status === 'processing' &&\n      (route === '/' || route === '' || route === '/meetings')\n    ) {\n      navigate('/meetings/processing');\n    } else if (\n      (recording.status === 'recording' || recording.status === 'paused') &&\n      (route === '/' || route === '' || route === '/meetings')\n    ) {\n      navigate('/recording');\n    }\n  }, [recording.isLoading, recording.status, route]);\n\n  // ⌘K — focus sidebar search. Capture-phase listener so it wins over nested\n  // handlers; fires even when focus is in a form control (the search input\n  // itself is exempt by the data-sidebar-search check).\n  React.useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return;\n      const search = document.querySelector<HTMLInputElement>(\n        '[data-sidebar-search]',\n      );\n      if (search) {\n        e.preventDefault();\n        search.focus();\n        search.select();\n      }\n    };\n    document.addEventListener('keydown', onKey, true);\n    return () => document.removeEventListener('keydown', onKey, true);\n  }, []);\n\n  const isRecordingRoute = route === '/recording';\n  const isProcessingRoute = route === '/meetings/processing';\n  // The /chat page has its own large composer, so the floating AskBar dock\n  // would just stack a second redundant input below the same page. The\n  // sub-route /chat/<id> (conversation view) also owns its own composer.\n  const isChatRoute = route === '/chat' || route.startsWith('/chat/');\n  const showAskBar = !isRecordingRoute && !isProcessingRoute && !isChatRoute;\n\n  return (\n    <StreamingProvider>\n      <AskBarProvider>\n        <RouteView route={route} />\n        <QuitDialog />\n\n        {/* Bottom dock — shared anchor across recording → processing → meeting. */}\n        <BottomDockSlot>\n          {isRecordingRoute && <LiveDock />}\n          {isProcessingRoute && <ProcessingDock />}\n          {showAskBar && <AskBar />}\n        </BottomDockSlot>\n\n        {/* Transcript — floats above the chat bar (only on real meeting routes). */}\n        {showAskBar && (\n          <BottomDockSlot bottomOffset={72}>\n            <TranscriptBar />\n          </BottomDockSlot>\n        )}\n      </AskBarProvider>\n    </StreamingProvider>\n  );\n}\n\nfunction RouteView({ route }: { route: string }) {\n  if (route === '/dev' || route.startsWith('/dev/')) return <Sandbox />;\n  if (route === '/settings') return <Settings />;\n  if (route === '/setup') return <Setup />;\n  if (route === '/recording') return <Recording />;\n  if (route === '/chat') return <Chat />;\n  if (route.startsWith('/chat/')) {\n    const sessionId = safeDecode(route.slice('/chat/'.length));\n    return <ChatConversation sessionId={sessionId} />;\n  }\n  if (route === '/meetings/processing') return <Processing />;\n  if (route.startsWith('/meetings/')) {\n    const summaryFile = safeDecode(route.slice('/meetings/'.length));\n    return <MeetingDetail summaryFile={summaryFile} />;\n  }\n  if (route.startsWith('/folders/')) {\n    const folderId = safeDecode(route.slice('/folders/'.length));\n    return <FolderDetail folderId={folderId} />;\n  }\n  if (route === '/meetings') return <Home mode=\"meetings\" />;\n  return <Home mode=\"home\" />;\n}\n\n// Tolerate malformed % escapes — a bad route shouldn't crash the renderer.\nfunction safeDecode(s: string): string {\n  try {\n    return decodeURIComponent(s);\n  } catch {\n    return s;\n  }\n}\n"
  },
  {
    "path": "app/renderer/src/components/AppShell.tsx",
    "content": "import * as React from 'react';\nimport { MainToolbar } from '@/components/MainToolbar';\nimport { cn } from '@/lib/utils';\nimport type { RecordingStatus } from '@/hooks/useRecording';\n\ninterface AppShellProps {\n  recordingStatus: RecordingStatus;\n  recordingElapsed?: number;\n  onToggleRecording: () => void;\n  onToggleSidebar: () => void;\n  sidebar: React.ReactNode;\n  sidebarWidth: number;\n  sidebarCollapsed: boolean;\n  askBarSlot?: React.ReactNode;\n  contentAlign?: 'top' | 'center';\n  /**\n   * When true, omits the MainToolbar (record button). Implies `bleed`.\n   * Used by /recording where the LiveDock owns recording controls.\n   */\n  hideToolbar?: boolean;\n  /**\n   * When true, renders children directly inside the main pane without the\n   * centered max-w-[820px] content wrapper. Use for routes that own their\n   * own header/scroll layout (e.g. Settings). Implied by hideToolbar.\n   */\n  bleed?: boolean;\n  children: React.ReactNode;\n}\n\nexport function AppShell({\n  recordingStatus,\n  recordingElapsed,\n  onToggleRecording,\n  onToggleSidebar,\n  sidebar,\n  sidebarWidth,\n  sidebarCollapsed,\n  askBarSlot,\n  contentAlign = 'top',\n  hideToolbar = false,\n  bleed = false,\n  children,\n}: AppShellProps) {\n  const effectiveWidth = sidebarCollapsed ? 0 : sidebarWidth;\n  const useBleed = hideToolbar || bleed;\n\n  return (\n    <div\n      className=\"flex h-screen w-full overflow-hidden\"\n      style={{ background: 'var(--page)', color: 'var(--fg-1)' }}\n    >\n      {sidebar}\n\n      <main\n        className=\"relative flex min-h-0 flex-1 flex-col overflow-hidden\"\n        style={{ marginLeft: effectiveWidth, transition: 'margin-left 180ms ease' }}\n      >\n        {!hideToolbar && (\n          <MainToolbar\n            recordingStatus={recordingStatus}\n            elapsedSeconds={recordingElapsed}\n            onToggleRecording={onToggleRecording}\n            sidebarCollapsed={sidebarCollapsed}\n            onToggleSidebar={onToggleSidebar}\n          />\n        )}\n\n        {useBleed ? (\n          children\n        ) : (\n          <div\n            className={cn(\n              'scrollbar-clean flex-1 min-h-0 overflow-auto',\n              contentAlign === 'center' && 'flex items-center justify-center',\n            )}\n            /* scrollbar-gutter: stable reserves space for the scrollbar even\n               when content fits, so the notes column's centerline is the same\n               whether or not the scrollbar is visible. The dock below mirrors\n               this (no asymmetric padding needed) and stays aligned with the\n               notes column. */\n            style={{ scrollbarGutter: 'stable' }}\n          >\n            <div className=\"mx-auto w-full max-w-[720px] px-10 pb-36 pt-7\">\n              {children}\n            </div>\n          </div>\n        )}\n\n        {askBarSlot && (\n          <div\n            id=\"ask-bar-slot\"\n            className=\"mv-dock\"\n            /* Mirror the 10px scrollbar gutter reserved by the sibling scroll\n               container so the dock's centerline matches the notes column's\n               centerline. Without this, the dock sits 5px right of the notes. */\n            style={{ paddingRight: 10 }}\n          >\n            <div\n              className=\"mv-dock-inner\"\n              /* Match the notes column: same max-width and side padding so the\n                 composer pill is centered to the notes content. */\n              style={{ maxWidth: 720, width: '100%', margin: '0 auto', padding: '0 40px' }}\n            >\n              {askBarSlot}\n            </div>\n          </div>\n        )}\n      </main>\n\n      <div id=\"dialog-host\" />\n      <div\n        id=\"toast-host\"\n        className=\"pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/AskBar.tsx",
    "content": "import * as React from 'react';\nimport {\n  ArrowUp,\n  Check,\n  ChevronDown,\n  ChevronUp,\n  Copy,\n  Square,\n  X,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils';\nimport { renderMarkdown } from '@/lib/markdown';\nimport { useAskBar } from '@/lib/askBarContext';\nimport {\n  useChatSessions,\n  type ChatMessage,\n  type ChatSession,\n} from '@/hooks/useChatSessions';\nimport { useGlobalStreaming } from '@/hooks/useStreamingQuery';\nimport { TranscriptPanelContent } from '@/components/TranscriptPanel';\nimport { useMeeting } from '@/hooks/useMeetings';\n\n// ---------------------------------------------------------------------------\n// Transcript bar — rendered separately above the chat bar\n// ---------------------------------------------------------------------------\n\nexport function TranscriptBar() {\n  const { activeSummaryFile, transcriptOpen, setTranscriptOpen } = useAskBar();\n  const meeting = useMeeting(activeSummaryFile ?? undefined);\n  const [copied, setCopied] = React.useState(false);\n\n  const copyTranscript = async () => {\n    if (!meeting.data) return;\n    const text = (meeting.data.transcript ?? '').trim();\n    if (!text) return;\n    await navigator.clipboard.writeText(text);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 1500);\n  };\n\n  if (!transcriptOpen || !activeSummaryFile) return null;\n\n  return (\n    <div\n      data-transcript-bar\n      className=\"mv-transcript open\"\n      style={{ pointerEvents: 'auto', boxShadow: 'var(--shadow-lg)' }}\n      // Stop mousedown bubbling so the AskBar click-outside listener treats\n      // interactions inside this panel (search input, copy button, scroll)\n      // as in-bounds. Without this, the panel closes the instant you click\n      // anywhere inside it.\n      onMouseDown={(e) => e.stopPropagation()}\n    >\n      <div className=\"mv-transcript-head\">\n        <span className=\"mv-transcript-wave mv-transcript-wave-static\" aria-hidden=\"true\">\n          <span /><span /><span /><span /><span /><span /><span />\n        </span>\n        <span className=\"mv-transcript-label\">Transcript</span>\n        <button\n          type=\"button\"\n          className=\"mv-chat-tool\"\n          onClick={() => void copyTranscript()}\n          aria-label=\"Copy transcript\"\n          title=\"Copy transcript\"\n        >\n          {copied ? <Check size={13} /> : <Copy size={13} />}\n        </button>\n        <button\n          type=\"button\"\n          className=\"mv-chat-tool\"\n          onClick={() => setTranscriptOpen(false)}\n          aria-label=\"Hide transcript\"\n          title=\"Hide transcript\"\n        >\n          <ChevronUp size={13} style={{ color: 'var(--fg-2)', flexShrink: 0 }} />\n        </button>\n      </div>\n      <div style={{ height: 260, display: 'flex', flexDirection: 'column', borderTop: '1px solid var(--border-subtle)' }}>\n        <TranscriptPanelContent summaryFile={activeSummaryFile} />\n      </div>\n    </div>\n  );\n}\n\nexport function AskBar() {\n  const { activeSummaryFile, activeMeetingName, transcriptOpen, setTranscriptOpen } = useAskBar();\n  const chat = useChatSessions(activeSummaryFile, activeMeetingName);\n  const streaming = useGlobalStreaming();\n\n  const [expanded, setExpanded] = React.useState(false);\n  const [sessionMenuOpen, setSessionMenuOpen] = React.useState(false);\n  const [input, setInput] = React.useState('');\n  const [activeStreamId, setActiveStreamId] = React.useState<string | null>(null);\n  const pendingPersistRef = React.useRef<string | null>(null);\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  const activeStream = activeStreamId ? streaming.streams[activeStreamId] : null;\n  const isStreaming = activeStream?.status === 'streaming';\n  const session = chat.activeSession;\n  const hasMessages = (session?.messages.length ?? 0) > 0;\n  const hidden = !activeSummaryFile;\n  const canSend = input.trim().length > 0 && !isStreaming;\n\n  const cancelStreamRef = React.useRef(streaming.cancelStream);\n  cancelStreamRef.current = streaming.cancelStream;\n\n  React.useEffect(() => {\n    setExpanded(false);\n    setSessionMenuOpen(false);\n    setTranscriptOpen(false);\n    setActiveStreamId((prev) => {\n      if (prev) {\n        cancelStreamRef.current(prev);\n        pendingPersistRef.current = null;\n      }\n      return null;\n    });\n  }, [activeSummaryFile, setTranscriptOpen]);\n\n  React.useEffect(() => {\n    if (!expanded && !transcriptOpen) return;\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Element | null;\n      // Treat the AskBar container AND the floating TranscriptBar as in-bounds.\n      // Without the transcript check, clicks inside the transcript's search\n      // input or copy button would close the panel before the click resolves.\n      const inside =\n        (containerRef.current && containerRef.current.contains(target as Node)) ||\n        (target && target.closest?.('[data-transcript-bar]'));\n      if (!inside) {\n        setExpanded(false);\n        setSessionMenuOpen(false);\n        setTranscriptOpen(false);\n      }\n    };\n    document.addEventListener('mousedown', handler);\n    return () => document.removeEventListener('mousedown', handler);\n  }, [expanded, transcriptOpen, setTranscriptOpen]);\n\n  React.useEffect(() => {\n    if (!scrollRef.current) return;\n    scrollRef.current.scrollTop = scrollRef.current.scrollHeight;\n  }, [session?.messages.length, activeStream?.text, expanded]);\n\n  React.useEffect(() => {\n    if (!activeStreamId) return;\n    const stream = streaming.streams[activeStreamId];\n    if (!stream) return;\n    const sessionId = pendingPersistRef.current;\n    if (!sessionId) return;\n    if (stream.status === 'streaming') return;\n\n    const content =\n      stream.text.trim() ||\n      (stream.status === 'error'\n        ? `Error: ${stream.error ?? 'query failed'}`\n        : '(empty response)');\n    const message: ChatMessage = { role: 'assistant', content, ts: Date.now() };\n    void chat.appendMessage(sessionId, message);\n    pendingPersistRef.current = null;\n    streaming.clearStream(activeStreamId);\n    setActiveStreamId(null);\n  }, [activeStreamId, streaming, chat]);\n\n  // Re-entrancy guard. submitPrompt awaits createSession/appendMessage; rapid\n  // suggestion-chip clicks (or Enter) before those resolve would otherwise\n  // create duplicate sessions and clobber the persistence ref.\n  const submittingRef = React.useRef(false);\n\n  const submitPrompt = async (raw: string) => {\n    const q = raw.trim();\n    if (!q || !activeSummaryFile || isStreaming) return;\n    if (submittingRef.current) return;\n    submittingRef.current = true;\n\n    try {\n      let sessionId = session?.id ?? null;\n      if (!sessionId) {\n        sessionId = await chat.createSession(deriveSessionName(q));\n      }\n\n      const userMsg: ChatMessage = { role: 'user', content: q, ts: Date.now() };\n      await chat.appendMessage(sessionId, userMsg);\n      setInput('');\n\n      const streamId = streaming.startStream(activeSummaryFile, q);\n      pendingPersistRef.current = sessionId;\n      setActiveStreamId(streamId);\n\n      setExpanded(true);\n      setTranscriptOpen(false);\n    } finally {\n      submittingRef.current = false;\n    }\n  };\n\n  const submit = () => submitPrompt(input);\n\n  const stop = () => {\n    if (!activeStreamId) return;\n    streaming.cancelStream(activeStreamId);\n  };\n\n  const onPickSession = (id: string) => {\n    chat.setActiveId(id);\n    setSessionMenuOpen(false);\n    setExpanded(true);\n  };\n\n  const onNewSession = async () => {\n    setSessionMenuOpen(false);\n    if (session && session.messages.length === 0) {\n      setExpanded(true);\n      return;\n    }\n    await chat.createSession();\n    setExpanded(true);\n  };\n\n  const handleTranscriptToggle = () => {\n    if (transcriptOpen) {\n      setTranscriptOpen(false);\n    } else {\n      setTranscriptOpen(true);\n      setExpanded(false);\n      setSessionMenuOpen(false);\n    }\n  };\n\n  const handleInputFocus = () => {\n    setExpanded(true);\n    if (transcriptOpen) setTranscriptOpen(false);\n  };\n\n  const handleCollapse = () => {\n    setExpanded(false);\n    setSessionMenuOpen(false);\n  };\n\n  if (hidden) return null;\n\n  const showChatPanel = expanded && (hasMessages || isStreaming);\n\n  return (\n    <div ref={containerRef} data-ask-bar className=\"flex w-full flex-col gap-2.5\" style={{ pointerEvents: 'auto' }}>\n\n      {/* Chat message panel */}\n      {showChatPanel && (\n        <div className=\"mv-transcript open\" style={{ maxHeight: 360 }}>\n          <ChatHeader\n            session={session}\n            meetingName={activeMeetingName}\n            sessions={chat.sessions}\n            activeId={chat.activeId}\n            sessionMenuOpen={sessionMenuOpen}\n            onOpenSessions={() => setSessionMenuOpen((v) => !v)}\n            onPickSession={onPickSession}\n            onDeleteSession={(id) => void chat.deleteSession(id)}\n            onNewSession={() => void onNewSession()}\n            onCollapse={handleCollapse}\n          />\n          <div\n            ref={scrollRef}\n            className=\"scrollbar-clean overflow-y-auto px-4 py-3\"\n            style={{ maxHeight: 300 }}\n          >\n            <MessageList\n              messages={session?.messages ?? []}\n              liveText={isStreaming ? (activeStream?.text ?? '') : ''}\n              streaming={isStreaming}\n            />\n          </div>\n        </div>\n      )}\n\n      {/* Suggestion chips — appear when ask bar is focused with empty conversation */}\n      {expanded && !hasMessages && !isStreaming && (\n        <div\n          className=\"mv-chat flex flex-wrap items-center gap-2\"\n          style={{ padding: '10px 14px' }}\n        >\n          {SUGGESTION_CHIPS.map((chip) => (\n            <button\n              key={chip.label}\n              type=\"button\"\n              onClick={() => void submitPrompt(chip.prompt)}\n              className=\"rounded-lg border px-2.5 py-1 text-xs transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]\"\n              style={{ borderColor: 'var(--border-subtle)', color: 'var(--fg-2)' }}\n            >\n              {chip.label}\n            </button>\n          ))}\n        </div>\n      )}\n\n      {/* Chat composer */}\n      <form\n        className=\"mv-chat\"\n        onSubmit={(e) => { e.preventDefault(); void submit(); }}\n      >\n        {/* Transcript toggle */}\n        <button\n          type=\"button\"\n          className={cn('mv-chat-tool', transcriptOpen && 'active')}\n          onClick={handleTranscriptToggle}\n          aria-label={transcriptOpen ? 'Hide transcript' : 'Show transcript'}\n          aria-pressed={transcriptOpen}\n          title=\"Transcript\"\n        >\n          {transcriptOpen ? (\n            <span className=\"mv-transcript-wave\" aria-hidden=\"true\" style={{ width: 16, height: 12 }}>\n              <span /><span /><span /><span /><span /><span /><span />\n            </span>\n          ) : (\n            <span className=\"mv-transcript-wave\" aria-hidden=\"true\" style={{ width: 16, height: 12, opacity: 0.5, animation: 'none' }}>\n              <span style={{ height: '40%', animation: 'none' }} />\n              <span style={{ height: '70%', animation: 'none' }} />\n              <span style={{ height: '100%', animation: 'none' }} />\n              <span style={{ height: '60%', animation: 'none' }} />\n              <span style={{ height: '90%', animation: 'none' }} />\n              <span style={{ height: '50%', animation: 'none' }} />\n              <span style={{ height: '30%', animation: 'none' }} />\n            </span>\n          )}\n        </button>\n\n        {/* Text input */}\n        <input\n          ref={inputRef}\n          className=\"mv-chat-input\"\n          value={input}\n          onChange={(e) => setInput(e.target.value)}\n          onFocus={handleInputFocus}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' && !e.shiftKey) {\n              e.preventDefault();\n              if (isStreaming) stop();\n              else void submit();\n            }\n            if (e.key === 'Escape') {\n              handleCollapse();\n              (e.target as HTMLElement).blur();\n            }\n          }}\n          placeholder={hasMessages ? 'Continue chat…' : 'Ask anything about this meeting…'}\n          aria-label=\"Ask about this meeting\"\n        />\n\n        {/* Send / stop */}\n        {isStreaming ? (\n          <button\n            type=\"button\"\n            className=\"mv-chat-send active\"\n            onClick={stop}\n            aria-label=\"Stop\"\n          >\n            <Square size={12} />\n          </button>\n        ) : (\n          <button\n            type=\"submit\"\n            className={cn('mv-chat-send', canSend && 'active')}\n            disabled={!canSend}\n            aria-label=\"Send\"\n          >\n            <ArrowUp size={14} />\n          </button>\n        )}\n      </form>\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Chat header with floating session dropdown\n// ---------------------------------------------------------------------------\n\ninterface ChatHeaderProps {\n  session: ChatSession | null;\n  meetingName: string | null;\n  sessions: ChatSession[];\n  activeId: string | null;\n  sessionMenuOpen: boolean;\n  onOpenSessions: () => void;\n  onPickSession: (id: string) => void;\n  onDeleteSession: (id: string) => void;\n  onNewSession: () => void;\n  onCollapse: () => void;\n}\n\nfunction ChatHeader({\n  session,\n  meetingName,\n  sessions,\n  activeId,\n  sessionMenuOpen,\n  onOpenSessions,\n  onPickSession,\n  onDeleteSession,\n  onNewSession,\n  onCollapse,\n}: ChatHeaderProps) {\n  return (\n    <div className=\"relative flex flex-shrink-0 items-center justify-between border-b px-3 py-2\" style={{ borderColor: 'var(--border-subtle)' }}>\n      <div className=\"relative\">\n        <button\n          type=\"button\"\n          onClick={onOpenSessions}\n          className={cn(\n            'flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-semibold transition-colors hover:bg-muted',\n            sessionMenuOpen && 'bg-muted',\n          )}\n          style={{ color: 'var(--fg-1)' }}\n        >\n          <span className=\"max-w-[340px] truncate\">\n            {session?.name ?? (meetingName ? `Ask about ${meetingName}` : 'Ask AI')}\n          </span>\n          <ChevronDown\n            className={cn('size-3.5 flex-shrink-0 transition-transform duration-150', sessionMenuOpen && 'rotate-180')}\n            style={{ color: 'var(--fg-2)' }}\n          />\n        </button>\n\n        {sessionMenuOpen && (\n          <SessionDropdown\n            sessions={sessions}\n            activeId={activeId}\n            onPick={onPickSession}\n            onDelete={onDeleteSession}\n          />\n        )}\n      </div>\n\n      <div className=\"flex items-center gap-1.5\">\n        <button\n          type=\"button\"\n          onClick={onNewSession}\n          className=\"rounded-md border px-2.5 py-1 text-xs transition-colors hover:bg-muted\"\n          style={{ borderColor: 'var(--border-subtle)', color: 'var(--fg-2)' }}\n        >\n          New chat\n        </button>\n        <button\n          type=\"button\"\n          onClick={onCollapse}\n          title=\"Collapse\"\n          aria-label=\"Collapse\"\n          className=\"mv-chat-tool\"\n        >\n          <X size={14} />\n        </button>\n      </div>\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Session dropdown\n// ---------------------------------------------------------------------------\n\ninterface SessionDropdownProps {\n  sessions: ChatSession[];\n  activeId: string | null;\n  onPick: (id: string) => void;\n  onDelete: (id: string) => void;\n}\n\nfunction SessionDropdown({ sessions, activeId, onPick, onDelete }: SessionDropdownProps) {\n  return (\n    <div\n      role=\"menu\"\n      data-ask-bar-sessions\n      className=\"absolute left-0 top-[calc(100%+4px)] z-30 min-w-[240px] overflow-hidden rounded-xl border p-1.5 shadow-lg\"\n      style={{ background: 'var(--surface-raised)', borderColor: 'var(--border-subtle)' }}\n    >\n      {sessions.length === 0 ? (\n        <p className=\"px-3 py-2 text-xs\" style={{ color: 'var(--fg-muted)' }}>No saved chats yet.</p>\n      ) : (\n        sessions.map((s) => {\n          const isActive = s.id === activeId;\n          return (\n            <div\n              key={s.id}\n              className={cn(\n                'group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-muted',\n                isActive && 'bg-muted font-medium',\n              )}\n            >\n              <button\n                type=\"button\"\n                onClick={() => onPick(s.id)}\n                className=\"flex-1 truncate text-left\"\n                style={{ color: 'var(--fg-1)' }}\n              >\n                {s.name}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => onDelete(s.id)}\n                aria-label={`Delete chat ${s.name}`}\n                className=\"rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100\"\n                style={{ color: 'var(--fg-muted)' }}\n              >\n                <X size={12} />\n              </button>\n            </div>\n          );\n        })\n      )}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Message list + bubbles\n// ---------------------------------------------------------------------------\n\ninterface MessageListProps {\n  messages: ChatMessage[];\n  liveText: string;\n  streaming: boolean;\n}\n\nfunction MessageList({ messages, liveText, streaming }: MessageListProps) {\n  return (\n    <div className=\"flex flex-col gap-3\">\n      {messages.map((m, i) => (\n        <MessageBubble key={i} message={m} />\n      ))}\n      {streaming && (\n        <div className=\"flex justify-start\">\n          {liveText ? (\n            <div className=\"max-w-[90%] text-sm leading-[1.7]\" style={{ color: 'var(--fg-1)' }}>\n              {renderMarkdown(liveText)}\n              <span className=\"ml-0.5 inline-block h-3.5 w-0.5 animate-pulse align-text-bottom\" style={{ background: 'var(--fg-2)' }} />\n            </div>\n          ) : (\n            <div className=\"flex items-center gap-1.5 py-1\" style={{ color: 'var(--fg-muted)' }}>\n              <span className=\"text-[13px]\">Thinking</span>\n              <span className=\"thinking-dot\" />\n              <span className=\"thinking-dot\" />\n              <span className=\"thinking-dot\" />\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction MessageBubble({ message }: { message: ChatMessage }) {\n  const isUser = message.role === 'user';\n  return (\n    <div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>\n      {isUser ? (\n        <div\n          className=\"max-w-[75%] rounded-[18px_18px_4px_18px] border px-3.5 py-2 text-sm\"\n          style={{\n            background: 'var(--surface-hover)',\n            borderColor: 'var(--border-subtle)',\n            color: 'var(--fg-1)',\n          }}\n        >\n          {message.content}\n        </div>\n      ) : (\n        <div className=\"max-w-[90%] text-sm leading-[1.7]\" style={{ color: 'var(--fg-1)' }}>\n          {renderMarkdown(message.content)}\n        </div>\n      )}\n    </div>\n  );\n}\n\n// Markdown rendering moved to lib/markdown.tsx so the Chat tab can share it.\n\nconst SUGGESTION_CHIPS: { label: string; prompt: string }[] = [\n  { label: 'Summarize key decisions', prompt: 'Summarize the key decisions made' },\n  { label: 'Action items', prompt: 'What action items were discussed?' },\n  { label: 'Main topics', prompt: 'What were the main topics covered?' },\n];\n\nfunction deriveSessionName(prompt: string): string {\n  const trimmed = prompt.trim().replace(/\\s+/g, ' ');\n  if (trimmed.length <= 40) return trimmed;\n  return `${trimmed.slice(0, 40).trimEnd()}…`;\n}\n"
  },
  {
    "path": "app/renderer/src/components/AudioWave.tsx",
    "content": "import { useAudioLevel } from '@/hooks/useAudioLevel';\n\ninterface AudioWaveProps {\n  /** True while a recording is active and unpaused. */\n  active: boolean;\n  /** Paused recordings keep the bars but stop reading the mic. */\n  paused?: boolean;\n  /** Number of bars to render. */\n  bars?: number;\n  /** Total height in px. */\n  height?: number;\n  /** Bar width in px. */\n  barWidth?: number;\n  /** Gap between bars in px. */\n  gap?: number;\n  /** CSS color for the bars. */\n  color?: string;\n}\n\n/**\n * Speech-reactive bar-graph driven by useAudioLevel. Falls back to a flat\n * shimmer if mic permission is denied. Used inside the recording pill on\n * /recording and the MainToolbar record button.\n */\nexport function AudioWave({\n  active,\n  paused = false,\n  bars = 7,\n  height = 16,\n  barWidth = 2,\n  gap = 2,\n  color = 'currentColor',\n}: AudioWaveProps) {\n  const levels = useAudioLevel({ enabled: active && !paused, bars });\n\n  return (\n    <span\n      aria-hidden\n      className=\"inline-flex items-center\"\n      style={{ height, gap }}\n    >\n      {levels.map((lvl, i) => (\n        <span\n          key={i}\n          style={{\n            display: 'inline-block',\n            width: barWidth,\n            height: `${Math.round(lvl * 100)}%`,\n            minHeight: 2,\n            background: color,\n            borderRadius: barWidth,\n            transition: 'height 80ms linear',\n            opacity: paused ? 0.45 : 1,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/BottomDockSlot.tsx",
    "content": "import * as React from 'react';\nimport { useSidebarCollapsed, useSidebarWidth } from '@/components/Sidebar';\n\ninterface BottomDockSlotProps {\n  children: React.ReactNode;\n  /** Distance from bottom in px. 0 for the primary dock, 72 for the floater above it. */\n  bottomOffset?: number;\n}\n\n/**\n * Canonical fixed-bottom anchor used by AskBar, LiveDock, and ProcessingDock.\n * Ensures all three states sit in the exact same screen slot so transitions\n * between recording → processing → meeting feel like a content swap, not a\n * layout reshuffle. Width tracks the sidebar like AskBar does today.\n */\nexport function BottomDockSlot({ children, bottomOffset = 0 }: BottomDockSlotProps) {\n  const { sidebarCollapsed } = useSidebarCollapsed();\n  const { width: sidebarWidth } = useSidebarWidth();\n  // Match AppShell's main-pane left edge: 0 when sidebar is collapsed (main\n  // has marginLeft:0), sidebarWidth when expanded. Anything else shifts the\n  // dock's centerline off the notes column's centerline.\n  const left = sidebarCollapsed ? 0 : sidebarWidth;\n\n  // Only the primary dock (bottomOffset === 0, sitting at the screen bottom)\n  // gets the fade backdrop — the floater above it would double-stack.\n  const showFade = bottomOffset === 0;\n\n  return (\n    <div\n      className=\"pointer-events-none fixed z-40\"\n      style={{\n        left,\n        // Mirror the 10px scrollbar gutter the sibling scroll container reserves\n        // (scrollbar-gutter: stable in AppShell) so the dock's centerline matches\n        // the notes column's centerline regardless of overflow.\n        right: 10,\n        bottom: bottomOffset,\n        paddingBottom: bottomOffset === 0 ? 16 : 0,\n        transition: 'left 180ms ease',\n      }}\n    >\n      {showFade && (\n        // Narrower, lighter fade — just enough to soften scrolling content as\n        // it approaches the pill. The solid band below keeps content from\n        // peeking out around the pill. The pill sits on its own opaque raised\n        // surface and renders above both layers.\n        <>\n          <div\n            aria-hidden\n            className=\"absolute inset-x-0\"\n            style={{\n              top: -80,\n              height: 80,\n              background:\n                'linear-gradient(to bottom, ' +\n                'color-mix(in srgb, var(--page) 0%, transparent) 0%, ' +\n                'color-mix(in srgb, var(--page) 35%, transparent) 60%, ' +\n                'var(--page) 100%)',\n              pointerEvents: 'none',\n              zIndex: -1,\n            }}\n          />\n          <div\n            aria-hidden\n            className=\"absolute inset-x-0 bottom-0\"\n            style={{\n              top: 0,\n              background: 'var(--page)',\n              pointerEvents: 'none',\n              zIndex: -1,\n            }}\n          />\n        </>\n      )}\n      <div className=\"mx-auto w-full max-w-[720px] px-10\">{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/ChatHistoryRow.tsx",
    "content": "import * as React from 'react';\nimport { MessageSquare, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\nimport { relativeTime } from '@/lib/chat';\nimport { navigate } from '@/lib/router';\n\nexport interface ChatHistoryRowSession {\n  id: string;\n  name: string;\n  updatedAt: number;\n}\n\ninterface ChatHistoryRowProps {\n  session: ChatHistoryRowSession;\n  /** Pass the route's current sessionId to highlight the active row. */\n  activeId?: string | null;\n  /** Show a relative-time chip on the right. Used on the /chat entry page;\n   *  the dropdown variant hides it because group headers carry the time. */\n  showTime?: boolean;\n  /** Fires after a successful navigate so the parent (e.g. a History\n   *  popover) can close itself. No-op for non-dismissible parents. */\n  onSelect?: () => void;\n  onRename: (name: string) => void;\n  onDelete: () => void | Promise<void>;\n}\n\n/**\n * Single row used by both the Chat entry page's Recents list and the\n * conversation page's History dropdown. Shared so the rename/delete\n * affordance behaves identically in both places.\n *\n * Hover surfaces a \"...\" button that opens a secondary menu with Rename\n * (pencil) and Delete (trash, --danger). Rename swaps the title for an\n * inline input — Enter saves, Escape cancels, blur auto-commits.\n */\nexport function ChatHistoryRow({\n  session,\n  activeId,\n  showTime = false,\n  onSelect,\n  onRename,\n  onDelete,\n}: ChatHistoryRowProps) {\n  const [menuOpen, setMenuOpen] = React.useState(false);\n  const [renaming, setRenaming] = React.useState(false);\n  const [draft, setDraft] = React.useState(session.name);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  // Tracks whether the user pressed Escape so the imminent blur cancels\n  // instead of committing the (possibly edited) draft.\n  const cancelRef = React.useRef(false);\n\n  React.useEffect(() => {\n    if (!renaming) return;\n    inputRef.current?.focus();\n    inputRef.current?.select();\n  }, [renaming]);\n\n  const startRename = () => {\n    setDraft(session.name);\n    setRenaming(true);\n    setMenuOpen(false);\n  };\n\n  const commitRename = () => {\n    const next = draft.trim();\n    if (next && next !== session.name) onRename(next);\n    setRenaming(false);\n  };\n\n  const isActive = activeId === session.id;\n  const navigateToChat = () => {\n    navigate(`/chat/${encodeURIComponent(session.id)}`);\n    onSelect?.();\n  };\n\n  return (\n    <div\n      className=\"group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n      style={{\n        color: isActive ? 'var(--fg-1)' : 'var(--fg-1)',\n        background: isActive ? 'var(--surface-active)' : undefined,\n      }}\n    >\n      <MessageSquare\n        className=\"size-[13px] flex-shrink-0\"\n        style={{ color: 'var(--fg-muted)' }}\n      />\n      {renaming ? (\n        <input\n          ref={inputRef}\n          value={draft}\n          onChange={(e) => setDraft(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              e.preventDefault();\n              commitRename();\n            } else if (e.key === 'Escape') {\n              e.preventDefault();\n              cancelRef.current = true;\n              setRenaming(false);\n            }\n          }}\n          onBlur={() => {\n            if (cancelRef.current) {\n              cancelRef.current = false;\n              return;\n            }\n            commitRename();\n          }}\n          className=\"flex-1 min-w-0 rounded border-0 bg-transparent px-1 py-0 text-[13px] outline-none focus:shadow-[inset_0_0_0_1px_hsl(var(--border))]\"\n          style={{ color: 'var(--fg-1)' }}\n        />\n      ) : (\n        <button\n          type=\"button\"\n          onClick={navigateToChat}\n          className=\"flex-1 truncate text-left\"\n        >\n          {session.name || 'Untitled chat'}\n        </button>\n      )}\n      {showTime && !renaming && (\n        <span\n          className=\"shrink-0 text-[11.5px] tabular-nums opacity-100 transition-opacity group-hover:opacity-0\"\n          style={{ color: 'var(--fg-muted)' }}\n          aria-hidden\n        >\n          {relativeTime(session.updatedAt)}\n        </span>\n      )}\n      <Popover open={menuOpen} onOpenChange={setMenuOpen}>\n        <PopoverTrigger asChild>\n          <button\n            type=\"button\"\n            onClick={(e) => e.stopPropagation()}\n            aria-label=\"Chat actions\"\n            title=\"Actions\"\n            className={cn(\n              'inline-flex size-6 shrink-0 items-center justify-center rounded transition-opacity hover:bg-[color:var(--surface-active)]',\n              menuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus:opacity-100',\n              // When showing time, the menu replaces the time on hover so\n              // the row width stays stable.\n              showTime && '-ml-1',\n            )}\n            style={{ color: 'var(--fg-2)' }}\n          >\n            <MoreHorizontal className=\"size-[14px]\" />\n          </button>\n        </PopoverTrigger>\n        <PopoverContent\n          align=\"end\"\n          side=\"right\"\n          className=\"w-[140px] p-1\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              startRename();\n            }}\n            className=\"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n            style={{ color: 'var(--fg-1)' }}\n          >\n            <Pencil className=\"size-[13px]\" style={{ color: 'var(--fg-2)' }} />\n            Rename\n          </button>\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              setMenuOpen(false);\n              void onDelete();\n            }}\n            className=\"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n            style={{ color: 'var(--danger)' }}\n          >\n            <Trash2 className=\"size-[13px]\" />\n            Delete\n          </button>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/FolderScopePicker.tsx",
    "content": "import * as React from 'react';\nimport { ChevronDown, Folder as FolderIcon, Inbox } from 'lucide-react';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { useFolders } from '@/hooks/useFolders';\nimport type { Folder } from '@/lib/ipc';\n\ninterface FolderScopePickerProps {\n  /** Selected folder ID. null = all notes. */\n  value: string | null;\n  onChange: (folderId: string | null) => void;\n}\n\n/**\n * Compact \"scope\" chip used inside chat composers. Lets the user limit a\n * cross-note query to a single folder instead of asking across everything.\n * Backend filter happens server-side; this just persists the choice and\n * passes it to startGlobalStream.\n */\nexport function FolderScopePicker({ value, onChange }: FolderScopePickerProps) {\n  const folders = useFolders();\n  const [open, setOpen] = React.useState(false);\n\n  const folder = React.useMemo<Folder | null>(() => {\n    if (!value) return null;\n    return folders.data?.find((f) => f.id === value) ?? null;\n  }, [folders.data, value]);\n\n  // If the scoped folder was deleted out from under us, drop the scope so we\n  // don't keep filtering against a dead id (and so the chip stops lying about\n  // what's selected).\n  React.useEffect(() => {\n    if (value && folders.data && !folder) {\n      onChange(null);\n    }\n  }, [value, folders.data, folder, onChange]);\n\n  const label = folder ? folder.name : 'All notes';\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          aria-label={`Scope: ${label}`}\n          title={`Scope: ${label}`}\n          className=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[12px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n          style={{ color: 'var(--fg-2)' }}\n        >\n          {folder ? (\n            <FolderIcon className=\"size-[12px]\" />\n          ) : (\n            <Inbox className=\"size-[12px]\" />\n          )}\n          <span className=\"max-w-[140px] truncate\">{label}</span>\n          <ChevronDown className=\"size-[11px] opacity-60\" />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent align=\"start\" className=\"w-[220px] p-1\">\n        <div className=\"px-2 pb-1 pt-0.5 text-[11px] font-medium\" style={{ color: 'var(--fg-muted)' }}>\n          Ask across…\n        </div>\n        <button\n          type=\"button\"\n          onClick={() => {\n            onChange(null);\n            setOpen(false);\n          }}\n          className=\"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n          style={{\n            color: 'var(--fg-1)',\n            background: value === null ? 'var(--surface-active)' : undefined,\n          }}\n        >\n          <Inbox className=\"size-[13px]\" style={{ color: 'var(--fg-2)' }} />\n          All notes\n        </button>\n        {(folders.data ?? []).length > 0 && (\n          <div\n            className=\"mx-2 my-1 h-px\"\n            style={{ background: 'var(--border-subtle)' }}\n            aria-hidden\n          />\n        )}\n        {(folders.data ?? []).map((f) => (\n          <button\n            key={f.id}\n            type=\"button\"\n            onClick={() => {\n              onChange(f.id);\n              setOpen(false);\n            }}\n            className=\"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n            style={{\n              color: 'var(--fg-1)',\n              background: value === f.id ? 'var(--surface-active)' : undefined,\n            }}\n          >\n            <FolderIcon className=\"size-[13px]\" style={{ color: 'var(--fg-2)' }} />\n            <span className=\"truncate\">{f.name}</span>\n          </button>\n        ))}\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/IconPicker.tsx",
    "content": "import * as React from 'react';\nimport * as ReactDOM from 'react-dom';\nimport * as LucideIcons from 'lucide-react';\nimport { Search, X } from 'lucide-react';\n\n// ---------------------------------------------------------------------------\n// Icon catalogue — name (kebab-case lucide icon) + searchable tags\n// ---------------------------------------------------------------------------\n\nconst ICON_LIST = [\n  // Work & org\n  { name: 'briefcase',      tags: ['work','job','office','bag'] },\n  { name: 'building-2',     tags: ['office','company','building','org'] },\n  { name: 'users',          tags: ['team','people','group'] },\n  { name: 'user',           tags: ['person','profile','account'] },\n  { name: 'user-check',     tags: ['person','verified','profile'] },\n  { name: 'presentation',   tags: ['slides','talk','pitch'] },\n  { name: 'chart-bar',      tags: ['stats','data','analytics','graph'] },\n  { name: 'chart-line',     tags: ['trend','graph','analytics'] },\n  { name: 'chart-pie',      tags: ['breakdown','analytics','chart'] },\n  { name: 'target',         tags: ['goal','aim','objective'] },\n  { name: 'trophy',         tags: ['win','award','achievement'] },\n  { name: 'handshake',      tags: ['deal','partner','agreement'] },\n  { name: 'calendar',       tags: ['date','schedule','event'] },\n  { name: 'calendar-days',  tags: ['date','schedule','meeting'] },\n  { name: 'clock',          tags: ['time','schedule','timer'] },\n  { name: 'mail',           tags: ['email','message','inbox'] },\n  { name: 'inbox',          tags: ['email','messages','all'] },\n  { name: 'send',           tags: ['email','message','send'] },\n  { name: 'phone',          tags: ['call','contact','mobile'] },\n  { name: 'video',          tags: ['meeting','call','zoom'] },\n  { name: 'laptop',         tags: ['computer','work','device'] },\n  { name: 'monitor',        tags: ['desktop','screen','computer'] },\n  { name: 'pen-line',       tags: ['write','edit','notes'] },\n  { name: 'pencil',         tags: ['write','edit','notes'] },\n  { name: 'clipboard',      tags: ['notes','list','task'] },\n  { name: 'clipboard-list', tags: ['tasks','checklist','todo'] },\n  { name: 'list-checks',    tags: ['done','checklist','todo'] },\n  { name: 'check-square',   tags: ['done','complete','task'] },\n  { name: 'file-text',      tags: ['document','notes','file'] },\n  { name: 'file',           tags: ['document','file','generic'] },\n  { name: 'files',          tags: ['documents','multiple','files'] },\n  { name: 'folder',         tags: ['folder','directory','files'] },\n  { name: 'folder-open',    tags: ['folder','open','files'] },\n  { name: 'archive',        tags: ['store','archive','box'] },\n  { name: 'box',            tags: ['storage','package','box'] },\n  { name: 'package',        tags: ['delivery','product','box'] },\n  { name: 'bookmark',       tags: ['save','mark','favourite'] },\n  { name: 'tag',            tags: ['label','category','tag'] },\n  { name: 'tags',           tags: ['labels','categories','tags'] },\n  { name: 'layers',         tags: ['stack','categories','layers'] },\n  { name: 'flag',           tags: ['priority','flag','mark'] },\n  { name: 'link',           tags: ['url','link','connect'] },\n  { name: 'search',         tags: ['find','search','magnify'] },\n  { name: 'filter',         tags: ['sort','filter','refine'] },\n  // Health & medical\n  { name: 'stethoscope',    tags: ['health','medical','doctor','clinic'] },\n  { name: 'heart-pulse',    tags: ['health','medical','heart','vital'] },\n  { name: 'heart',          tags: ['health','favourite','love','care'] },\n  { name: 'activity',       tags: ['health','pulse','monitor','vital'] },\n  { name: 'pill',           tags: ['medicine','pharmacy','drug'] },\n  { name: 'hospital',       tags: ['health','building','medical'] },\n  { name: 'thermometer',    tags: ['health','temperature','medical'] },\n  { name: 'eye',            tags: ['vision','view','health'] },\n  { name: 'brain',          tags: ['mind','neuro','health','thinking'] },\n  { name: 'microscope',     tags: ['science','lab','research','health'] },\n  // Legal & finance\n  { name: 'scale',          tags: ['legal','law','justice','balance'] },\n  { name: 'gavel',          tags: ['legal','court','ruling','law'] },\n  { name: 'scroll',         tags: ['legal','document','contract'] },\n  { name: 'shield',         tags: ['protection','security','privacy','legal'] },\n  { name: 'shield-check',   tags: ['safe','verified','security','legal'] },\n  { name: 'lock',           tags: ['private','secure','lock'] },\n  { name: 'key',            tags: ['access','key','unlock','security'] },\n  { name: 'landmark',       tags: ['bank','government','institution'] },\n  { name: 'banknote',       tags: ['money','finance','payment','cash'] },\n  { name: 'coins',          tags: ['money','finance','currency'] },\n  { name: 'wallet',         tags: ['payment','money','finance'] },\n  { name: 'credit-card',    tags: ['payment','finance','card'] },\n  { name: 'trending-up',    tags: ['growth','finance','increase'] },\n  { name: 'trending-down',  tags: ['decline','finance','decrease'] },\n  // Property & home\n  { name: 'home',           tags: ['house','property','home','real estate'] },\n  { name: 'map-pin',        tags: ['location','place','address','property'] },\n  { name: 'map',            tags: ['location','navigation','area'] },\n  { name: 'compass',        tags: ['navigate','direction','explore'] },\n  { name: 'door-open',      tags: ['entry','access','property','door'] },\n  { name: 'sofa',           tags: ['interior','home','living','furniture'] },\n  { name: 'bed',            tags: ['bedroom','property','sleep'] },\n  // Education & research\n  { name: 'graduation-cap', tags: ['education','study','university','degree'] },\n  { name: 'book',           tags: ['read','study','learn','book'] },\n  { name: 'book-open',      tags: ['read','study','content','open'] },\n  { name: 'library',        tags: ['books','research','archive','library'] },\n  { name: 'lightbulb',      tags: ['idea','inspiration','creative'] },\n  { name: 'flask-conical',  tags: ['science','research','lab','experiment'] },\n  { name: 'test-tube',      tags: ['science','lab','chemistry','test'] },\n  { name: 'telescope',      tags: ['research','discovery','explore'] },\n  // Tech & dev\n  { name: 'code-2',         tags: ['code','developer','programming','tech'] },\n  { name: 'terminal',       tags: ['code','shell','dev','console'] },\n  { name: 'git-branch',     tags: ['code','version control','dev','git'] },\n  { name: 'database',       tags: ['data','storage','tech','db'] },\n  { name: 'server',         tags: ['infrastructure','tech','cloud','server'] },\n  { name: 'cloud',          tags: ['cloud','storage','tech','backup'] },\n  { name: 'cpu',            tags: ['hardware','tech','compute','processor'] },\n  { name: 'wifi',           tags: ['network','internet','connection'] },\n  { name: 'settings',       tags: ['config','preferences','settings'] },\n  { name: 'wrench',         tags: ['tool','fix','maintenance','dev'] },\n  { name: 'bug',            tags: ['error','debug','issue','dev'] },\n  { name: 'zap',            tags: ['fast','power','lightning','automation'] },\n  { name: 'rocket',         tags: ['launch','startup','fast','deploy'] },\n  // Creative & media\n  { name: 'mic',            tags: ['audio','record','voice','meeting'] },\n  { name: 'headphones',     tags: ['audio','listen','music','media'] },\n  { name: 'music',          tags: ['audio','song','playlist','media'] },\n  { name: 'camera',         tags: ['photo','image','capture','media'] },\n  { name: 'image',          tags: ['photo','picture','gallery','media'] },\n  { name: 'film',           tags: ['video','movie','media','film'] },\n  { name: 'play-circle',    tags: ['video','play','media','watch'] },\n  { name: 'pen-tool',       tags: ['design','draw','creative','pen'] },\n  { name: 'palette',        tags: ['design','colour','creative','art'] },\n  { name: 'layout-grid',    tags: ['design','grid','layout','ui'] },\n  { name: 'sparkles',       tags: ['ai','magic','new','creative','feature'] },\n  // Nature & misc\n  { name: 'sun',            tags: ['morning','bright','day','energy'] },\n  { name: 'moon',           tags: ['night','dark','evening','rest'] },\n  { name: 'star',           tags: ['favourite','important','rate','star'] },\n  { name: 'globe',          tags: ['world','international','web','global'] },\n  { name: 'leaf',           tags: ['nature','green','environment','eco'] },\n  { name: 'tree-pine',      tags: ['nature','forest','environment'] },\n  { name: 'mountain',       tags: ['landscape','outdoors','nature'] },\n  { name: 'flame',          tags: ['hot','urgent','fire','energy'] },\n  { name: 'coffee',         tags: ['break','morning','casual','relax'] },\n  { name: 'smile',          tags: ['happy','personal','casual','mood'] },\n];\n\n// Deduplicate by name\nconst ICONS = Array.from(new Map(ICON_LIST.map((i) => [i.name, i])).values());\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Convert kebab-case to PascalCase for lucide-react named exports. */\nfunction toPascalCase(name: string): string {\n  return name\n    .split('-')\n    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n    .join('');\n}\n\n/** Render a lucide icon by its kebab-case name. Falls back to Folder icon. */\nexport function LucideIcon({\n  name,\n  size = 16,\n  className,\n  style,\n}: {\n  name: string;\n  size?: number;\n  className?: string;\n  style?: React.CSSProperties;\n}) {\n  const pascal = toPascalCase(name);\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const icons = LucideIcons as Record<string, any>;\n  const Comp = icons[pascal] ?? icons['Folder'];\n  return <Comp size={size} strokeWidth={1.5} className={className} style={style} />;\n}\n\n// ---------------------------------------------------------------------------\n// IconPicker\n// ---------------------------------------------------------------------------\n\nconst PANEL_W = 276;\nconst PANEL_MAX_H = 320;\nconst SEARCH_H = 52;\nconst GAP = 6;\n\ninterface IconPickerProps {\n  anchorRect: DOMRect;\n  onSelect: (iconName: string) => void;\n  onClose: () => void;\n}\n\nexport function IconPicker({ anchorRect, onSelect, onClose }: IconPickerProps) {\n  const [query, setQuery] = React.useState('');\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n\n  const filtered = React.useMemo(() => {\n    const q = query.trim().toLowerCase();\n    if (!q) return ICONS;\n    return ICONS.filter(\n      (i) => i.name.includes(q) || i.tags.some((t) => t.includes(q)),\n    );\n  }, [query]);\n\n  // Focus search on mount\n  React.useEffect(() => {\n    inputRef.current?.focus();\n  }, []);\n\n  // Click-outside to close (slight delay so the originating click doesn't immediately close)\n  React.useEffect(() => {\n    const handler = (e: MouseEvent) => {\n      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {\n        onClose();\n      }\n    };\n    const t = setTimeout(() => document.addEventListener('mousedown', handler), 50);\n    return () => {\n      clearTimeout(t);\n      document.removeEventListener('mousedown', handler);\n    };\n  }, [onClose]);\n\n  // Escape to close\n  React.useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    document.addEventListener('keydown', handler);\n    return () => document.removeEventListener('keydown', handler);\n  }, [onClose]);\n\n  // Position: below anchor, horizontally centred; flip above if not enough space\n  const vw = window.innerWidth;\n  const vh = window.innerHeight;\n\n  let left = anchorRect.left + anchorRect.width / 2 - PANEL_W / 2;\n  let top = anchorRect.bottom + GAP;\n\n  if (left < 8) left = 8;\n  if (left + PANEL_W > vw - 8) left = vw - PANEL_W - 8;\n  if (top + PANEL_MAX_H > vh - 8) top = Math.max(8, anchorRect.top - PANEL_MAX_H - GAP);\n\n  const portal = document.getElementById('dialog-host') ?? document.body;\n\n  return ReactDOM.createPortal(\n    <div\n      ref={panelRef}\n      style={{\n        position: 'fixed',\n        top,\n        left,\n        width: PANEL_W,\n        background: 'var(--surface-raised)',\n        border: '1px solid var(--border-subtle)',\n        borderRadius: 12,\n        boxShadow: 'var(--shadow-md, 0 8px 32px rgba(0,0,0,0.12))',\n        zIndex: 9999,\n        overflow: 'hidden',\n        display: 'flex',\n        flexDirection: 'column',\n      }}\n    >\n      {/* Search bar */}\n      <div\n        style={{\n          padding: '10px 10px 8px',\n          borderBottom: '1px solid var(--border-subtle)',\n          flexShrink: 0,\n        }}\n      >\n        <div\n          style={{\n            display: 'flex',\n            alignItems: 'center',\n            gap: 7,\n            background: 'var(--surface-hover)',\n            borderRadius: 7,\n            padding: '5px 9px',\n          }}\n        >\n          <Search size={13} strokeWidth={1.5} style={{ flexShrink: 0, color: 'var(--fg-2)' }} />\n          <input\n            ref={inputRef}\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder=\"Search icons…\"\n            style={{\n              flex: 1,\n              background: 'transparent',\n              border: 'none',\n              outline: 'none',\n              fontSize: 13,\n              color: 'var(--fg-1)',\n              fontFamily: 'var(--font-sans)',\n            }}\n          />\n          {query && (\n            <button\n              type=\"button\"\n              onClick={() => setQuery('')}\n              style={{\n                background: 'none',\n                border: 'none',\n                cursor: 'pointer',\n                padding: 0,\n                color: 'var(--fg-2)',\n                display: 'flex',\n              }}\n            >\n              <X size={12} strokeWidth={1.5} />\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* Icon grid */}\n      <div\n        className=\"scrollbar-clean\"\n        style={{ overflowY: 'auto', maxHeight: PANEL_MAX_H - SEARCH_H, padding: '6px 8px 8px' }}\n      >\n        {filtered.length === 0 ? (\n          <div\n            style={{\n              padding: '24px 0',\n              textAlign: 'center',\n              color: 'var(--fg-muted)',\n              fontSize: 13,\n              fontFamily: 'var(--font-sans)',\n            }}\n          >\n            No icons match \"{query}\"\n          </div>\n        ) : (\n          <div\n            style={{\n              display: 'grid',\n              gridTemplateColumns: 'repeat(7, 1fr)',\n              gap: 2,\n              padding: '2px 0',\n            }}\n          >\n            {filtered.map((icon) => (\n              <IconButton\n                key={icon.name}\n                name={icon.name}\n                onSelect={() => { onSelect(icon.name); onClose(); }}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n    </div>,\n    portal,\n  );\n}\n\nfunction IconButton({ name, onSelect }: { name: string; onSelect: () => void }) {\n  const [hovered, setHovered] = React.useState(false);\n  return (\n    <button\n      type=\"button\"\n      title={name.replace(/-/g, ' ')}\n      onClick={onSelect}\n      onMouseEnter={() => setHovered(true)}\n      onMouseLeave={() => setHovered(false)}\n      style={{\n        width: 36,\n        height: 36,\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        background: hovered ? 'var(--surface-hover)' : 'transparent',\n        border: 'none',\n        borderRadius: 6,\n        cursor: 'pointer',\n        color: hovered ? 'var(--fg-1)' : 'var(--fg-2)',\n        transition: 'background 120ms, color 120ms',\n      }}\n    >\n      <LucideIcon name={name} size={16} />\n    </button>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/LiveDock.tsx",
    "content": "import { Pause, Play, Square } from 'lucide-react';\nimport { AudioWave } from '@/components/AudioWave';\nimport { useRecording } from '@/hooks/useRecording';\n\n/**\n * Recording-state dock for the /recording route. Mounted at App level inside\n * BottomDockSlot so it shares the same screen slot as AskBar + ProcessingDock\n * — when the user stops, the visual frame stays put while the contents swap.\n */\nexport function LiveDock() {\n  const recording = useRecording();\n  const paused = recording.status === 'paused';\n  const isRecording = recording.status === 'recording';\n  const stopped = !paused && !isRecording;\n\n  const onPauseToggle = () => {\n    if (paused) void recording.resumeRecording();\n    else if (isRecording) void recording.pauseRecording();\n  };\n\n  const onStop = () => {\n    void recording.stopRecording();\n  };\n\n  return (\n    <div className=\"flex justify-center pointer-events-none\">\n      <div\n        className=\"pointer-events-auto flex items-center gap-3 rounded-full px-3 py-2\"\n        style={{\n          background: 'var(--surface-raised)',\n          border: '1px solid var(--border-subtle)',\n          boxShadow: 'var(--shadow-md)',\n        }}\n      >\n        <RecordingPill\n          paused={paused}\n          stopped={stopped}\n          elapsedSeconds={recording.elapsed}\n        />\n        <button\n          type=\"button\"\n          onClick={onPauseToggle}\n          disabled={stopped}\n          aria-label={paused ? 'Resume recording' : 'Pause recording'}\n          title={paused ? 'Resume recording' : 'Pause recording'}\n          className=\"inline-flex size-8 cursor-pointer items-center justify-center rounded-full border-0 transition-colors hover:bg-[color:var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50\"\n          style={{ background: 'transparent', color: 'var(--fg-1)' }}\n        >\n          {paused ? <Play size={14} /> : <Pause size={14} />}\n        </button>\n        <button\n          type=\"button\"\n          onClick={onStop}\n          disabled={stopped}\n          aria-label=\"Stop recording\"\n          title=\"Stop recording\"\n          className=\"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border-0 px-3 text-[13px] font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50\"\n          style={{ background: 'var(--recording)', color: '#FFFFFF' }}\n        >\n          <Square size={12} fill=\"currentColor\" stroke=\"currentColor\" />\n          Stop\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction RecordingPill({\n  paused,\n  stopped,\n  elapsedSeconds,\n}: {\n  paused: boolean;\n  stopped: boolean;\n  elapsedSeconds: number;\n}) {\n  const label = stopped ? 'Processing' : paused ? 'Paused' : 'Recording';\n  const active = !stopped;\n  return (\n    <span\n      className=\"inline-flex items-center gap-2 px-2 text-[13px]\"\n      style={{ color: 'var(--fg-1)' }}\n    >\n      <span style={{ color: 'var(--recording)' }}>\n        <AudioWave\n          active={active}\n          paused={paused}\n          bars={7}\n          height={14}\n          barWidth={2}\n          gap={2}\n        />\n      </span>\n      <span style={{ color: 'var(--fg-2)' }}>{label}</span>\n      <span\n        className=\"tabular-nums\"\n        style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--fg-1)' }}\n      >\n        {formatElapsed(elapsedSeconds)}\n      </span>\n    </span>\n  );\n}\n\nfunction formatElapsed(seconds: number): string {\n  const s = Math.max(0, seconds | 0);\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const rem = s % 60;\n  const pad = (n: number) => n.toString().padStart(2, '0');\n  if (h > 0) return `${h}:${pad(m)}:${pad(rem)}`;\n  return `${pad(m)}:${pad(rem)}`;\n}\n"
  },
  {
    "path": "app/renderer/src/components/MainToolbar.tsx",
    "content": "import * as React from 'react';\nimport { MessageSquare, Moon, MoreHorizontal, Monitor, PanelLeftClose, PanelLeftOpen, PencilLine, Sun } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Switch } from '@/components/ui/switch';\nimport { AudioWave } from '@/components/AudioWave';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport {\n  useSetSystemAudio,\n  useSystemAudioSetting,\n} from '@/hooks/useSettings';\nimport type { RecordingStatus } from '@/hooks/useRecording';\nimport { useTheme } from '@/hooks/useTheme';\nimport { useRoute, navigate } from '@/lib/router';\nimport { cn } from '@/lib/utils';\n\ninterface MainToolbarProps {\n  recordingStatus: RecordingStatus;\n  elapsedSeconds?: number;\n  onToggleRecording: () => void;\n  sidebarCollapsed: boolean;\n  onToggleSidebar: () => void;\n}\n\nexport function MainToolbar({\n  recordingStatus,\n  elapsedSeconds = 0,\n  onToggleRecording,\n  sidebarCollapsed,\n  onToggleSidebar,\n}: MainToolbarProps) {\n  const isRecording =\n    recordingStatus === 'recording' || recordingStatus === 'paused';\n  const isPaused = recordingStatus === 'paused';\n  const isProcessing = recordingStatus === 'processing';\n  const { resolved: resolvedTheme, setTheme } = useTheme();\n  // Route-aware primary action. On chat routes the \"+ New\" affordance maps\n  // to a new chat (navigates back to /chat entry). Everywhere else it's\n  // the recording button. Recording always wins if a session is active —\n  // we don't want a navigation to silently swallow a stop-recording click.\n  const route = useRoute();\n  const isChatRoute = route === '/chat' || route.startsWith('/chat/');\n  const showChatPrimary = isChatRoute && !isRecording && !isProcessing;\n\n  // Matches sb-top padding-left (82px clears macOS traffic lights)\n  const toggleLeft = 82;\n\n  return (\n    <div\n      className=\"flex h-10 items-center justify-between gap-2 px-5 pt-2.5\"\n      style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}\n    >\n      <div\n        className=\"ml-auto flex items-center gap-1.5\"\n        style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}\n      >\n        {/* Toggle button lives here (inside a no-drag child of a drag ancestor)\n            so Electron correctly computes the no-drag region even when the\n            sidebar aside has pointer-events:none. position:fixed keeps it at\n            the same screen coords as the sb-top button position. */}\n        <button\n          type=\"button\"\n          onClick={onToggleSidebar}\n          aria-label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}\n          title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}\n          style={{\n            position: 'fixed',\n            top: 14,\n            left: toggleLeft,\n            zIndex: 30,\n            WebkitAppRegion: 'no-drag',\n          } as React.CSSProperties}\n          className=\"inline-flex h-[26px] w-7 items-center justify-center rounded-md text-[color:var(--fg-2)] transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]\"\n        >\n          {sidebarCollapsed ? (\n            <PanelLeftOpen className=\"size-[15px]\" />\n          ) : (\n            <PanelLeftClose className=\"size-[15px]\" />\n          )}\n        </button>\n        <RecordingOptionsPopover />\n        <button\n          type=\"button\"\n          onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}\n          aria-label={\n            resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'\n          }\n          title={\n            resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'\n          }\n          className=\"inline-flex h-[26px] w-7 items-center justify-center rounded-md text-[color:var(--fg-2)] transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]\"\n        >\n          {resolvedTheme === 'dark' ? (\n            <Sun className=\"size-[15px]\" />\n          ) : (\n            <Moon className=\"size-[15px]\" />\n          )}\n        </button>\n        <button\n          type=\"button\"\n          onClick={showChatPrimary ? () => navigate('/chat') : onToggleRecording}\n          disabled={isProcessing}\n          className={cn('record-btn', isRecording && 'is-recording')}\n          aria-label={\n            isProcessing\n              ? 'Processing previous recording'\n              : isRecording\n                ? 'Open recording in progress'\n                : showChatPrimary\n                  ? 'New chat'\n                  : 'New note'\n          }\n          title={\n            isProcessing\n              ? 'Processing previous recording'\n              : isRecording\n                ? 'Open recording in progress'\n                : showChatPrimary\n                  ? 'New chat'\n                  : 'New note'\n          }\n        >\n          {isProcessing ? (\n            <>\n              <span style={{ color: '#FFFFFF', display: 'inline-flex' }}>\n                <AudioWave active={false} paused bars={5} height={12} barWidth={2} gap={2} />\n              </span>\n              <span>Processing</span>\n            </>\n          ) : isRecording ? (\n            <>\n              <span style={{ color: '#FFFFFF', display: 'inline-flex' }}>\n                <AudioWave\n                  active={!isPaused}\n                  paused={isPaused}\n                  bars={5}\n                  height={12}\n                  barWidth={2}\n                  gap={2}\n                />\n              </span>\n              <span\n                className=\"tabular-nums\"\n                style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}\n              >\n                {formatElapsed(elapsedSeconds)}\n              </span>\n              <span>{isPaused ? 'Paused' : 'Recording'}</span>\n            </>\n          ) : showChatPrimary ? (\n            <>\n              <MessageSquare className=\"size-[13px]\" />\n              New chat\n            </>\n          ) : (\n            <>\n              <PencilLine className=\"size-[13px]\" />\n              New note\n            </>\n          )}\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction RecordingOptionsPopover() {\n  const systemAudio = useSystemAudioSetting();\n  const setSystemAudio = useSetSystemAudio();\n  const enabled = systemAudio.data ?? false;\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"size-8\"\n          aria-label=\"Recording options\"\n          title=\"Recording options\"\n        >\n          <MoreHorizontal className=\"size-4\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent align=\"end\" className=\"w-72\" data-recording-options>\n        <div className=\"space-y-3\">\n          <div className=\"space-y-0.5\">\n            <p className=\"text-sm font-medium\">Recording options</p>\n            <p className=\"text-xs text-muted-foreground\">\n              Deep links and the tray menu also start and stop recording.\n            </p>\n          </div>\n\n          <div\n            className=\"flex items-start gap-3 rounded-md border p-3\"\n            style={{ borderColor: 'var(--border-subtle)' }}\n          >\n            <Monitor className=\"mt-0.5 size-4 flex-shrink-0 text-muted-foreground\" />\n            <div className=\"flex-1 space-y-0.5\">\n              <div className=\"flex items-center justify-between gap-2\">\n                <label\n                  htmlFor=\"maintoolbar-system-audio\"\n                  className=\"text-sm font-medium\"\n                >\n                  Record system audio\n                </label>\n                <Switch\n                  id=\"maintoolbar-system-audio\"\n                  checked={enabled}\n                  disabled={systemAudio.isLoading || setSystemAudio.isPending}\n                  onCheckedChange={(v) => setSystemAudio.mutate(v)}\n                />\n              </div>\n              <p className=\"text-xs text-muted-foreground\">\n                Capture both sides of calls on macOS. Grants microphone\n                permission on first use.\n              </p>\n            </div>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nfunction formatElapsed(seconds: number): string {\n  const s = Math.max(0, seconds | 0);\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const rem = s % 60;\n  const pad = (n: number) => n.toString().padStart(2, '0');\n  if (h > 0) return `${h}:${pad(m)}:${pad(rem)}`;\n  return `${pad(m)}:${pad(rem)}`;\n}\n"
  },
  {
    "path": "app/renderer/src/components/MeetingsShell.tsx",
    "content": "import * as React from 'react';\nimport { AppShell } from '@/components/AppShell';\nimport {\n  Sidebar,\n  useSidebarCollapsed,\n  useSidebarWidth,\n  type SidebarContextAction,\n  type SidebarFolder,\n  type SidebarMeeting,\n} from '@/components/Sidebar';\nimport { MeetingsListProvider } from '@/lib/meetingsListContext';\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { ConfirmDialog } from '@/components/ui/confirm-dialog';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useMeetings, useDeleteMeeting, useUpdateMeeting } from '@/hooks/useMeetings';\nimport {\n  useAddMeetingToFolder,\n  useCreateFolder,\n  useDeleteFolder,\n  useFolders,\n  useRemoveMeetingFromFolder,\n  useRenameFolder,\n} from '@/hooks/useFolders';\nimport { useRecording } from '@/hooks/useRecording';\nimport { navigate, useRoute } from '@/lib/router';\nimport type { Meeting } from '@/lib/ipc';\n\ninterface MeetingsShellProps {\n  activeSummaryFile: string | null;\n  contentAlign?: 'top' | 'center';\n  askBarSlot?: React.ReactNode;\n  /**\n   * When true, the main toolbar (record button) is hidden AND the centered\n   * content wrapper is omitted. Used by /recording where the LiveDock owns\n   * recording controls.\n   */\n  hideToolbar?: boolean;\n  /**\n   * When true, omits the centered content wrapper but keeps the toolbar\n   * visible. Used by /settings, which has its own full-viewport layout.\n   */\n  bleed?: boolean;\n  children: React.ReactNode;\n}\n\nexport function MeetingsShell({\n  activeSummaryFile,\n  contentAlign = 'top',\n  askBarSlot,\n  hideToolbar = false,\n  bleed = false,\n  children,\n}: MeetingsShellProps) {\n  const meetings = useMeetings();\n  const folders = useFolders();\n  const recording = useRecording();\n  const route = useRoute();\n\n  const createFolder = useCreateFolder();\n  const renameFolder = useRenameFolder();\n  const deleteFolder = useDeleteFolder();\n  const addToFolder = useAddMeetingToFolder();\n  const removeFromFolder = useRemoveMeetingFromFolder();\n  const updateMeeting = useUpdateMeeting();\n  const deleteMeeting = useDeleteMeeting();\n\n  const { sidebarCollapsed, toggleSidebar } = useSidebarCollapsed();\n  const { width: sidebarWidth, setWidth: setSidebarWidth } = useSidebarWidth();\n\n  const [search, setSearch] = React.useState('');\n  const [newFolderOpen, setNewFolderOpen] = React.useState(false);\n  const [newFolderName, setNewFolderName] = React.useState('');\n  const [renameTarget, setRenameTarget] = React.useState<\n    { type: 'folder' | 'meeting'; id: string; current: string; itemRect: DOMRectReadOnly } | null\n  >(null);\n  const [context, setContext] = React.useState<SidebarContextAction | null>(null);\n  const [deleteTarget, setDeleteTarget] = React.useState<\n    | { type: 'folder'; id: string; name: string; meetingCount: number }\n    | { type: 'meeting'; id: string; name: string }\n    | null\n  >(null);\n\n  // Sidebar shows only folder rows + counts. buildSidebar gives us folder\n  // metadata (name + meeting count); the per-folder meetings array is unused.\n  const { sidebarFolders } = React.useMemo(\n    () =>\n      buildSidebar({\n        meetings: meetings.data ?? [],\n        folders: folders.data ?? [],\n        search: '',\n        activeSummaryFile,\n      }),\n    [meetings.data, folders.data, activeSummaryFile],\n  );\n\n  const totalMeetings = meetings.data?.length ?? 0;\n\n  const isRecording = recording.status === 'recording' || recording.status === 'paused';\n  // Toolbar button behaviour: idle → start (which auto-navigates to /recording);\n  // recording or paused → navigate back to /recording instead of stopping. Stop\n  // is intentionally only available from the LiveDock on the /recording route.\n  const onToggleRecording = () => {\n    if (recording.status === 'idle') {\n      void recording.startRecording();\n    } else if (isRecording) {\n      navigate('/recording');\n    }\n  };\n\n  const onDropMeetingOnFolder = async (summaryFile: string, folderId: string | null) => {\n    const meeting = meetings.data?.find((m) => m.session_info.summary_file === summaryFile);\n    if (!meeting) return;\n    const current = meeting.folders ?? [];\n    const currentFolderId = current[0] ?? null;\n    if (currentFolderId === folderId) return;\n    if (currentFolderId) {\n      await removeFromFolder.mutateAsync({ summaryFile, folderId: currentFolderId });\n    }\n    if (folderId) {\n      await addToFolder.mutateAsync({ summaryFile, folderId });\n    }\n  };\n\n  const handleCreateFolder = async () => {\n    const name = newFolderName.trim();\n    if (!name) return;\n    await createFolder.mutateAsync({ name });\n    setNewFolderName('');\n    setNewFolderOpen(false);\n  };\n\n  const openRename = (\n    type: 'folder' | 'meeting',\n    id: string,\n    current: string,\n    itemRect: DOMRectReadOnly,\n  ) => {\n    setRenameTarget({ type, id, current, itemRect });\n    setContext(null);\n  };\n\n  const commitRename = async (type: 'folder' | 'meeting', id: string, value: string) => {\n    try {\n      if (type === 'folder') {\n        await renameFolder.mutateAsync({ id, name: value });\n      } else {\n        await updateMeeting.mutateAsync({ summaryFile: id, patch: { name: value } });\n      }\n    } catch (err) {\n      console.error('Rename failed:', err);\n    }\n  };\n\n  const openDeleteConfirm = () => {\n    if (!context) return;\n    if (context.type === 'folder') {\n      const folder = folders.data?.find((f) => f.id === context.id);\n      if (!folder) return setContext(null);\n      const meetingCount =\n        meetings.data?.filter((m) => (m.folders ?? []).includes(context.id)).length ?? 0;\n      setDeleteTarget({ type: 'folder', id: context.id, name: folder.name, meetingCount });\n    } else {\n      const target = meetings.data?.find((m) => m.session_info.summary_file === context.id);\n      if (!target) return setContext(null);\n      setDeleteTarget({\n        type: 'meeting',\n        id: context.id,\n        name: target.session_info.name || 'Untitled Meeting',\n      });\n    }\n    setContext(null);\n  };\n\n  const handleConfirmDelete = async () => {\n    if (!deleteTarget) return;\n    if (deleteTarget.type === 'folder') {\n      await deleteFolder.mutateAsync(deleteTarget.id);\n    } else {\n      const target = meetings.data?.find(\n        (m) => m.session_info.summary_file === deleteTarget.id,\n      );\n      if (target) {\n        await deleteMeeting.mutateAsync(target);\n        if (activeSummaryFile === deleteTarget.id) navigate('/');\n      }\n    }\n    setDeleteTarget(null);\n  };\n\n  return (\n    <>\n      <AppShell\n        recordingStatus={recording.status}\n        recordingElapsed={recording.elapsed}\n        onToggleRecording={onToggleRecording}\n        sidebarWidth={sidebarWidth}\n        sidebarCollapsed={sidebarCollapsed}\n        askBarSlot={askBarSlot}\n        contentAlign={contentAlign}\n        hideToolbar={hideToolbar}\n        bleed={bleed}\n        onToggleSidebar={toggleSidebar}\n        sidebar={\n          <Sidebar\n            collapsed={sidebarCollapsed}\n            onToggleCollapsed={toggleSidebar}\n            width={sidebarWidth}\n            onWidthChange={setSidebarWidth}\n            search={search}\n            onSearchChange={setSearch}\n            folders={sidebarFolders}\n            totalMeetings={totalMeetings}\n            onNewFolder={() => setNewFolderOpen(true)}\n            onDropMeetingOnFolder={onDropMeetingOnFolder}\n            onContextAction={setContext}\n            currentRoute={route}\n          />\n        }\n      >\n        <MeetingsListProvider onContextAction={setContext}>\n          {children}\n        </MeetingsListProvider>\n      </AppShell>\n\n      {context && (\n        <ContextMenu\n          action={context}\n          onClose={() => setContext(null)}\n          onRename={(label) => openRename(context.type, context.id, label, context.itemRect)}\n          onDelete={openDeleteConfirm}\n          folders={folders.data ?? []}\n          meetings={meetings.data ?? []}\n        />\n      )}\n\n      <ConfirmDialog\n        open={!!deleteTarget}\n        onOpenChange={(o) => !o && setDeleteTarget(null)}\n        title={\n          deleteTarget?.type === 'folder'\n            ? `Delete folder \"${deleteTarget.name}\"?`\n            : deleteTarget\n              ? `Delete note \"${deleteTarget.name}\"?`\n              : ''\n        }\n        description={\n          deleteTarget?.type === 'folder' ? (\n            deleteTarget.meetingCount > 0 ? (\n              <>\n                {deleteTarget.meetingCount} meeting\n                {deleteTarget.meetingCount === 1 ? '' : 's'} will be moved back to All Notes. No\n                recordings or transcripts will be deleted.\n              </>\n            ) : (\n              <>No recordings or transcripts will be deleted.</>\n            )\n          ) : (\n            <>This will delete the transcript, summary, and all associated files.</>\n          )\n        }\n        confirmLabel=\"Delete\"\n        destructive\n        onConfirm={handleConfirmDelete}\n        isPending={deleteFolder.isPending || deleteMeeting.isPending}\n      />\n\n      <Dialog open={newFolderOpen} onOpenChange={setNewFolderOpen}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>New folder</DialogTitle>\n            <DialogDescription>\n              Group related meetings together. Folder names are only visible to you.\n            </DialogDescription>\n          </DialogHeader>\n          <Input\n            value={newFolderName}\n            onChange={(e) => setNewFolderName(e.target.value)}\n            placeholder=\"e.g. Acme Corp\"\n            autoFocus\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') void handleCreateFolder();\n            }}\n          />\n          <DialogFooter>\n            <DialogClose asChild>\n              <Button variant=\"outline\">Cancel</Button>\n            </DialogClose>\n            <Button onClick={() => void handleCreateFolder()}>Create folder</Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {renameTarget && (\n        <RenamePopover\n          key={renameTarget.id}\n          initialValue={renameTarget.current}\n          itemRect={renameTarget.itemRect}\n          onSave={(value) => {\n            void commitRename(renameTarget.type, renameTarget.id, value);\n            setRenameTarget(null);\n          }}\n          onCancel={() => setRenameTarget(null)}\n        />\n      )}\n    </>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Helpers (formerly in classic MeetingsShell — inlined since we now ship one\n// React UI). Kept exported because FolderDetail + Home reuse formatDateLabel.\n// ---------------------------------------------------------------------------\n\ninterface ContextMenuProps {\n  action: SidebarContextAction;\n  onClose: () => void;\n  onRename: (currentLabel: string) => void;\n  onDelete: () => void;\n  folders: Array<{ id: string; name: string }>;\n  meetings: Meeting[];\n}\n\nfunction ContextMenu({\n  action,\n  onClose,\n  onRename,\n  onDelete,\n  folders,\n  meetings,\n}: ContextMenuProps) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useEffect(() => {\n    const onDoc = (e: MouseEvent) => {\n      if (ref.current && !ref.current.contains(e.target as Node)) onClose();\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    document.addEventListener('mousedown', onDoc);\n    document.addEventListener('keydown', onKey);\n    return () => {\n      document.removeEventListener('mousedown', onDoc);\n      document.removeEventListener('keydown', onKey);\n    };\n  }, [onClose]);\n\n  const currentLabel =\n    action.type === 'folder'\n      ? (folders.find((f) => f.id === action.id)?.name ?? '')\n      : (meetings.find((m) => m.session_info.summary_file === action.id)?.session_info.name ?? '');\n\n  return (\n    <div\n      ref={ref}\n      role=\"menu\"\n      style={{ top: action.clientY, left: action.clientX }}\n      className=\"fixed z-50 min-w-[160px] rounded-md border border-border bg-popover p-1 shadow-lg\"\n    >\n      <button\n        type=\"button\"\n        className=\"flex w-full items-center rounded px-2 py-1.5 text-left text-sm hover:bg-muted\"\n        onClick={() => onRename(currentLabel)}\n      >\n        Rename\n      </button>\n      <button\n        type=\"button\"\n        className=\"flex w-full items-center rounded px-2 py-1.5 text-left text-sm text-destructive hover:bg-destructive/10\"\n        onClick={onDelete}\n      >\n        Delete\n      </button>\n    </div>\n  );\n}\n\nfunction RenamePopover({\n  initialValue,\n  itemRect,\n  onSave,\n  onCancel,\n}: {\n  initialValue: string;\n  itemRect: DOMRectReadOnly;\n  onSave: (value: string) => void;\n  onCancel: () => void;\n}) {\n  const [value, setValue] = React.useState(initialValue);\n  const [visible, setVisible] = React.useState(false);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const closingRef = React.useRef(false);\n  const valueRef = React.useRef(value);\n  valueRef.current = value;\n\n  React.useEffect(() => {\n    const id = requestAnimationFrame(() => setVisible(true));\n    inputRef.current?.select();\n    return () => cancelAnimationFrame(id);\n  }, []);\n\n  const close = React.useCallback((save: boolean) => {\n    if (closingRef.current) return;\n    closingRef.current = true;\n    setVisible(false);\n    setTimeout(() => {\n      if (save) {\n        const trimmed = valueRef.current.trim();\n        if (trimmed && trimmed !== initialValue) {\n          onSave(trimmed);\n        } else {\n          onCancel();\n        }\n      } else {\n        onCancel();\n      }\n    }, 120);\n  }, [initialValue, onSave, onCancel]);\n\n  return (\n    <div\n      className=\"fixed z-50 rounded-lg border border-border bg-card p-2 shadow-lg\"\n      style={{\n        top: itemRect.bottom + 4,\n        left: itemRect.left,\n        width: Math.max(itemRect.width, 200),\n        opacity: visible ? 1 : 0,\n        transition: 'opacity 120ms ease',\n      }}\n    >\n      <Input\n        ref={inputRef}\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n        onBlur={() => close(true)}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter') { e.preventDefault(); close(true); }\n          if (e.key === 'Escape') { e.preventDefault(); close(false); }\n        }}\n      />\n    </div>\n  );\n}\n\ninterface BuildArgs {\n  meetings: Meeting[];\n  folders: Array<{ id: string; name: string; icon?: string; color?: string; order: number }>;\n  search: string;\n  activeSummaryFile: string | null;\n}\n\nfunction buildSidebar({ meetings, folders, search, activeSummaryFile }: BuildArgs) {\n  const needle = search.trim().toLowerCase();\n  const match = (m: Meeting) =>\n    !needle || m.session_info.name.toLowerCase().includes(needle);\n\n  const foldered = new Set<string>();\n  const sidebarFolders: SidebarFolder[] = [...folders]\n    .sort((a, b) => a.order - b.order)\n    .map((f) => {\n      const folderMeetings = meetings.filter((m) => (m.folders ?? []).includes(f.id));\n      folderMeetings.forEach((m) => foldered.add(m.session_info.summary_file));\n      return {\n        id: f.id,\n        name: f.name,\n        icon: f.icon,\n        color: f.color,\n        meetings: folderMeetings\n          .filter(match)\n          .map((m) => meetingToSidebar(m, activeSummaryFile)),\n      };\n    });\n\n  const sidebarUnfiled = meetings\n    .filter((m) => !foldered.has(m.session_info.summary_file))\n    .filter(match)\n    .map((m) => meetingToSidebar(m, activeSummaryFile));\n\n  return { sidebarUnfiled, sidebarFolders };\n}\n\nfunction meetingToSidebar(meeting: Meeting, activeSummaryFile: string | null): SidebarMeeting {\n  return {\n    summaryFile: meeting.session_info.summary_file,\n    title: meeting.session_info.name,\n    dateLabel: formatDateLabel(meeting.session_info),\n    active: meeting.session_info.summary_file === activeSummaryFile,\n  };\n}\n\nexport function formatDateLabel(info: Meeting['session_info']): string | undefined {\n  const raw = info.processed_at ?? info.updated_at;\n  if (!raw) return undefined;\n  const d = new Date(raw);\n  if (Number.isNaN(d.getTime())) return undefined;\n  const now = new Date();\n  const sameDay =\n    d.getFullYear() === now.getFullYear() &&\n    d.getMonth() === now.getMonth() &&\n    d.getDate() === now.getDate();\n  if (sameDay) return 'Today';\n  const yesterday = new Date(now);\n  yesterday.setDate(now.getDate() - 1);\n  const wasYesterday =\n    d.getFullYear() === yesterday.getFullYear() &&\n    d.getMonth() === yesterday.getMonth() &&\n    d.getDate() === yesterday.getDate();\n  if (wasYesterday) return 'Yesterday';\n  if (now.getTime() - d.getTime() < 7 * 24 * 60 * 60 * 1000) {\n    return d.toLocaleDateString(undefined, { weekday: 'short' });\n  }\n  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });\n}\n"
  },
  {
    "path": "app/renderer/src/components/QuitDialog.tsx",
    "content": "import * as React from 'react';\nimport { createPortal } from 'react-dom';\nimport { CircleAlert } from 'lucide-react';\nimport { ipc } from '@/lib/ipc';\n\ninterface DialogState {\n  type: 'recording' | 'processing';\n  jobCount?: number;\n}\n\nexport function QuitDialog() {\n  const [mounted, setMounted] = React.useState(false);\n  const [visible, setVisible] = React.useState(false);\n  const [state, setState] = React.useState<DialogState>({ type: 'recording' });\n\n  React.useEffect(() => {\n    if (typeof window === 'undefined' || !window.stenoai) return;\n    return ipc().on.showQuitDialog((payload) => {\n      setState({ type: payload.type, jobCount: payload.jobCount });\n      setMounted(true);\n      requestAnimationFrame(() => {\n        requestAnimationFrame(() => setVisible(true));\n      });\n    });\n  }, []);\n\n  React.useEffect(() => {\n    if (!mounted) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') handleCancel();\n    };\n    document.addEventListener('keydown', onKey);\n    return () => document.removeEventListener('keydown', onKey);\n  }, [mounted]);\n\n  const dismiss = (confirmed: boolean) => {\n    setVisible(false);\n    setTimeout(() => setMounted(false), 200);\n    ipc().dialog.respondQuit(confirmed);\n  };\n\n  const handleCancel = () => dismiss(false);\n  const handleConfirm = () => dismiss(true);\n\n  if (!mounted) return null;\n\n  const host = document.getElementById('dialog-host');\n  if (!host) return null;\n\n  const isRecording = state.type === 'recording';\n  const title = isRecording ? 'Recording in progress' : 'Processing in progress';\n  const count = state.jobCount ?? 1;\n  const body = isRecording\n    ? 'Quitting will stop and save the current recording.'\n    : `${count} recording${count !== 1 ? 's are' : ' is'} still being processed. Quitting will cancel processing.`;\n  const confirmLabel = isRecording ? 'Stop & quit' : 'Quit anyway';\n\n  return createPortal(\n    <div\n      onClick={(e) => { if (e.target === e.currentTarget) handleCancel(); }}\n      style={{\n        position: 'fixed',\n        inset: 0,\n        zIndex: 100,\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        background: 'rgba(0,0,0,0.25)',\n        opacity: visible ? 1 : 0,\n        transition: 'opacity 200ms cubic-bezier(0.2,0,0,1)',\n      }}\n    >\n      <div\n        style={{\n          background: 'var(--surface-raised)',\n          borderRadius: 'var(--radius-xl)',\n          boxShadow: 'var(--shadow-lg)',\n          border: '1px solid var(--border-subtle)',\n          width: 360,\n          padding: '24px',\n          display: 'flex',\n          flexDirection: 'column',\n          gap: 20,\n          transform: visible ? 'translateY(0)' : 'translateY(8px)',\n          transition: 'transform 200ms cubic-bezier(0.2,0,0,1)',\n        }}\n      >\n        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>\n          <CircleAlert\n            size={18}\n            strokeWidth={1.5}\n            style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }}\n          />\n          <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>\n            <p style={{ margin: 0, fontSize: 15, fontWeight: 500, color: 'var(--fg-1)', lineHeight: 1.3 }}>\n              {title}\n            </p>\n            <p style={{ margin: 0, fontSize: 14, color: 'var(--fg-2)', lineHeight: 1.5 }}>\n              {body}\n            </p>\n          </div>\n        </div>\n\n        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>\n          <CancelButton onClick={handleCancel} />\n          <ConfirmButton onClick={handleConfirm} label={confirmLabel} />\n        </div>\n      </div>\n    </div>,\n    host,\n  );\n}\n\nfunction CancelButton({ onClick }: { onClick: () => void }) {\n  const [hovered, setHovered] = React.useState(false);\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      onMouseEnter={() => setHovered(true)}\n      onMouseLeave={() => setHovered(false)}\n      style={{\n        height: 32,\n        padding: '0 14px',\n        borderRadius: 8,\n        border: '1px solid var(--border-strong)',\n        background: hovered ? 'var(--surface-hover)' : 'transparent',\n        color: 'var(--fg-1)',\n        fontSize: 13,\n        fontWeight: 500,\n        cursor: 'pointer',\n        fontFamily: 'var(--font-sans)',\n        transition: 'background 120ms cubic-bezier(0.2,0,0,1)',\n      }}\n    >\n      Cancel\n    </button>\n  );\n}\n\nfunction ConfirmButton({ onClick, label }: { onClick: () => void; label: string }) {\n  const [hovered, setHovered] = React.useState(false);\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      onMouseEnter={() => setHovered(true)}\n      onMouseLeave={() => setHovered(false)}\n      style={{\n        height: 32,\n        padding: '0 14px',\n        borderRadius: 8,\n        border: 0,\n        background: 'var(--danger)',\n        color: '#fff',\n        fontSize: 13,\n        fontWeight: 500,\n        cursor: 'pointer',\n        fontFamily: 'var(--font-sans)',\n        filter: hovered ? 'brightness(0.9)' : 'none',\n        transition: 'filter 120ms cubic-bezier(0.2,0,0,1)',\n      }}\n    >\n      {label}\n    </button>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/Sidebar.tsx",
    "content": "import * as React from 'react';\nimport {\n  ChevronDown,\n  Home as HomeIcon,\n  Inbox,\n  MessageSquare,\n  Plus,\n  Search,\n  Settings as SettingsIcon,\n} from 'lucide-react';\nimport { navigate, toggleSettings } from '@/lib/router';\nimport { cn, shortcut } from '@/lib/utils';\nimport { LucideIcon, IconPicker } from '@/components/IconPicker';\nimport { useUpdateFolderIcon } from '@/hooks/useFolders';\n\nexport interface SidebarMeeting {\n  summaryFile: string;\n  title: string;\n  dateLabel?: string;\n  active?: boolean;\n  folderId?: string | null;\n}\n\nexport interface SidebarFolder {\n  id: string;\n  name: string;\n  icon?: string;\n  /** User-chosen folder color. Used to tint the sidebar icon so it\n   *  matches the chip in the FolderScopePicker / FolderDetail header. */\n  color?: string;\n  meetings: SidebarMeeting[];\n}\n\nexport interface SidebarContextAction {\n  type: 'folder' | 'meeting';\n  id: string;\n  clientX: number;\n  clientY: number;\n  itemRect: DOMRectReadOnly;\n}\n\n// sessionStorage so collapsed state resets to open on every app restart\nconst COLLAPSED_KEY = 'steno-sidebar-collapsed';\nconst WIDTH_KEY = 'steno-sidebar-width';\nconst DEFAULT_WIDTH = 270;\nconst MIN_WIDTH = 220;\nconst MAX_WIDTH = 480;\n\n// Module-level singleton store. useState hooks on these values are not enough:\n// MeetingsShell and BottomDockSlot need to share a single source of truth, or\n// the dock and the main pane drift out of sync (one collapses, the other still\n// thinks the sidebar is open) and the chat bar stops aligning with the notes.\ntype Listener = () => void;\n\nconst collapsedStore = (() => {\n  let value =\n    typeof sessionStorage !== 'undefined' &&\n    sessionStorage.getItem(COLLAPSED_KEY) === 'true';\n  const listeners = new Set<Listener>();\n  return {\n    get: () => value,\n    set: (next: boolean) => {\n      if (value === next) return;\n      value = next;\n      try {\n        sessionStorage.setItem(COLLAPSED_KEY, String(next));\n      } catch (_) {}\n      listeners.forEach((l) => l());\n    },\n    subscribe: (l: Listener) => {\n      listeners.add(l);\n      return () => listeners.delete(l);\n    },\n  };\n})();\n\nconst widthStore = (() => {\n  let value = DEFAULT_WIDTH;\n  if (typeof localStorage !== 'undefined') {\n    const stored = localStorage.getItem(WIDTH_KEY);\n    if (stored) {\n      const parsed = parseInt(stored, 10);\n      if (!isNaN(parsed)) {\n        value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, parsed));\n      }\n    }\n  }\n  const listeners = new Set<Listener>();\n  return {\n    get: () => value,\n    set: (next: number) => {\n      const clamped = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, next));\n      if (value === clamped) return;\n      value = clamped;\n      try {\n        localStorage.setItem(WIDTH_KEY, String(clamped));\n      } catch (_) {}\n      listeners.forEach((l) => l());\n    },\n    subscribe: (l: Listener) => {\n      listeners.add(l);\n      return () => listeners.delete(l);\n    },\n  };\n})();\n\nexport function useSidebarCollapsed() {\n  const sidebarCollapsed = React.useSyncExternalStore(\n    collapsedStore.subscribe,\n    collapsedStore.get,\n    collapsedStore.get,\n  );\n  const toggleSidebar = React.useCallback(() => {\n    collapsedStore.set(!collapsedStore.get());\n  }, []);\n  return { sidebarCollapsed, toggleSidebar };\n}\n\nexport function useSidebarWidth() {\n  const width = React.useSyncExternalStore(\n    widthStore.subscribe,\n    widthStore.get,\n    widthStore.get,\n  );\n  const setWidth = React.useCallback((w: number) => widthStore.set(w), []);\n  return { width, setWidth };\n}\n\ninterface SidebarProps {\n  collapsed: boolean;\n  onToggleCollapsed: () => void;\n  width: number;\n  onWidthChange: (w: number) => void;\n  search: string;\n  onSearchChange: (value: string) => void;\n  folders: SidebarFolder[];\n  totalMeetings: number;\n  onNewFolder: () => void;\n  onDropMeetingOnFolder?: (summaryFile: string, folderId: string | null) => void;\n  onContextAction?: (action: SidebarContextAction) => void;\n  currentRoute: string;\n}\n\nexport function Sidebar({\n  collapsed,\n  onToggleCollapsed: _onToggleCollapsed,\n  width,\n  onWidthChange,\n  search,\n  onSearchChange,\n  folders,\n  totalMeetings,\n  onNewFolder,\n  onDropMeetingOnFolder,\n  onContextAction,\n  currentRoute,\n}: SidebarProps) {\n  const [foldersOpen, setFoldersOpen] = React.useState(true);\n  const [dragOverFolder, setDragOverFolder] = React.useState<string | null>(null);\n  const [dragOverAllMeetings, setDragOverAllMeetings] = React.useState(false);\n  const isDraggingRef = React.useRef(false);\n  const [iconPicker, setIconPicker] = React.useState<{ id: string; anchorRect: DOMRect } | null>(null);\n  const updateIcon = useUpdateFolderIcon();\n\n  const isHomeActive = currentRoute === '/' || currentRoute === '';\n  const isAllMeetingsActive = currentRoute === '/meetings';\n  // Match /chat as well as any /chat/<id> conversation route — the same Chat\n  // tab item should stay highlighted when drilling into a session.\n  const isChatActive = currentRoute === '/chat' || currentRoute.startsWith('/chat/');\n  // Malformed % escapes throw URIError. Guard so a bad route can't crash\n  // the entire sidebar render.\n  const activeFolderId = React.useMemo<string | null>(() => {\n    if (!currentRoute.startsWith('/folders/')) return null;\n    const raw = currentRoute.slice('/folders/'.length);\n    try {\n      return decodeURIComponent(raw);\n    } catch {\n      return raw;\n    }\n  }, [currentRoute]);\n\n  const handleFolderDrop = (e: React.DragEvent, folderId: string | null) => {\n    e.preventDefault();\n    const file = e.dataTransfer.getData('application/x-steno-meeting');\n    if (file && onDropMeetingOnFolder) onDropMeetingOnFolder(file, folderId);\n    setDragOverFolder(null);\n    setDragOverAllMeetings(false);\n  };\n\n  const handleFolderContext = (e: React.MouseEvent, id: string) => {\n    if (!onContextAction) return;\n    e.preventDefault();\n    const itemRect = e.currentTarget.getBoundingClientRect();\n    onContextAction({ type: 'folder', id, clientX: e.clientX, clientY: e.clientY, itemRect });\n  };\n\n  const onResizeMouseDown = React.useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault();\n      const startX = e.clientX;\n      const startWidth = width;\n      isDraggingRef.current = true;\n      document.body.style.cursor = 'col-resize';\n      document.body.style.userSelect = 'none';\n\n      const onMove = (ev: MouseEvent) => {\n        onWidthChange(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth + ev.clientX - startX)));\n      };\n      const onUp = () => {\n        isDraggingRef.current = false;\n        document.body.style.cursor = '';\n        document.body.style.userSelect = '';\n        document.removeEventListener('mousemove', onMove);\n        document.removeEventListener('mouseup', onUp);\n      };\n      document.addEventListener('mousemove', onMove);\n      document.addEventListener('mouseup', onUp);\n    },\n    [width, onWidthChange],\n  );\n\n  const filteredFolders = React.useMemo(() => {\n    const needle = search.trim().toLowerCase();\n    if (!needle) return folders;\n    return folders.filter((f) => f.name.toLowerCase().includes(needle));\n  }, [folders, search]);\n\n  return (\n    <aside\n      data-sidebar\n      className=\"fixed inset-y-0 left-0 z-20 flex flex-col\"\n      style={{\n        width,\n        // Disable pointer events on the collapsed aside so clicks reach the\n        // content behind it. sb-top overrides this below to stay interactive.\n        pointerEvents: collapsed ? 'none' : undefined,\n      }}\n    >\n      {/* Full-sidebar background + right border — fades when collapsed.\n          zIndex:-1 keeps it behind sb-top and content inside the aside's\n          stacking context (position:fixed creates one). */}\n      <div\n        aria-hidden\n        style={{\n          position: 'absolute',\n          inset: 0,\n          zIndex: -1,\n          background: 'var(--surface-sunken)',\n          borderRight: '1px solid var(--border-subtle)',\n          opacity: collapsed ? 0 : 1,\n          transition: 'opacity 180ms ease',\n          pointerEvents: 'none',\n        }}\n      />\n\n      {/* Top band — drag region for macOS traffic lights.\n          The toggle button is rendered in MainToolbar instead (position:fixed,\n          inside a no-drag DOM branch) so Electron's app-region logic reliably\n          registers the no-drag exclusion. Spacer preserves the visual gap. */}\n      {/* sb-top: drag region for traffic lights. Height spacer preserves the\n          original 46px row height (14px padding-top + 26px + 6px padding-bottom)\n          so the brand section clears the traffic lights. */}\n      <div className=\"sb-top\">\n        <div style={{ height: 26 }} aria-hidden />\n      </div>\n\n      {/* Sidebar content — fades with the background. No explicit pointer-events\n          needed: inherits none from aside when collapsed, auto when expanded. */}\n      <div\n        className=\"flex min-h-0 flex-1 flex-col overflow-hidden\"\n        style={{\n          opacity: collapsed ? 0 : 1,\n          transition: 'opacity 180ms ease',\n        }}\n      >\n        {/* Brand */}\n        <div className=\"flex items-center gap-[9px] px-4 pb-2.5 pt-3.5\">\n          <span\n            aria-hidden=\"true\"\n            className=\"inline-flex h-[22px] w-[22px] items-center justify-center\"\n            style={{ color: 'var(--fg-1)' }}\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 64 64\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path d=\"M28 7 Q29 9.5 30 12.5\" />\n              <path d=\"M36 7 Q35 9.5 34 12.5\" />\n              <circle cx=\"32\" cy=\"15\" r=\"3.8\" />\n              <circle cx=\"30.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n              <circle cx=\"33.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n              <path d=\"M30 19 Q28 19 28 21 L28 50 L32 60 L36 50 L36 21 Q36 19 34 19 Z\" />\n              <line x1=\"28\" y1=\"32\" x2=\"36\" y2=\"32\" />\n              <line x1=\"28\" y1=\"38\" x2=\"36\" y2=\"38\" />\n              <line x1=\"28\" y1=\"44\" x2=\"36\" y2=\"44\" />\n              <line x1=\"28\" y1=\"50\" x2=\"36\" y2=\"50\" />\n              <path d=\"M28 22 C18 15 8 17 4 22 C10 28 20 28 28 27 Z\" />\n              <path d=\"M36 22 C46 15 56 17 60 22 C50 28 44 28 36 27 Z\" />\n              <path d=\"M28 28 C18 30 10 35 6 40 C14 39 22 36 28 33 Z\" />\n              <path d=\"M36 28 C46 30 54 35 58 40 C50 39 42 36 36 33 Z\" />\n            </svg>\n          </span>\n          <span\n            className=\"text-[18px] font-normal\"\n            style={{ fontFamily: 'var(--font-serif)', letterSpacing: '-0.02em', color: 'var(--fg-1)' }}\n          >\n            Steno<span style={{ color: 'var(--fg-muted)' }}>.</span>\n          </span>\n        </div>\n\n        {/* Search */}\n        <div className=\"px-3 pb-2.5\">\n          <div className=\"relative\">\n            <Search\n              className=\"pointer-events-none absolute left-[9px] top-1/2 -translate-y-1/2 size-[13px]\"\n              style={{ color: 'var(--fg-2)' }}\n            />\n            <input\n              data-sidebar-search\n              className=\"h-[30px] w-full rounded-md border-0 px-[10px] pl-[30px] text-[13px] outline-none transition-colors focus:shadow-[inset_0_0_0_1px_hsl(var(--border))]\"\n              style={{ background: 'rgba(27,27,25,0.04)', color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n              placeholder=\"Search\"\n              value={search}\n              onChange={(e) => onSearchChange(e.target.value)}\n              aria-label=\"Search folders\"\n            />\n            <span\n              className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-px text-[11px] tabular-nums tracking-[0.02em]\"\n              style={{ color: 'var(--fg-muted)', background: 'rgba(27,27,25,0.04)', fontFamily: 'var(--font-sans)' }}\n            >\n              {shortcut('⌘K', 'Ctrl+K')}\n            </span>\n          </div>\n        </div>\n\n        <div className=\"mx-3 h-px\" style={{ background: 'var(--border-subtle)' }} />\n\n        {/* Nav */}\n        <nav className=\"scrollbar-clean flex min-h-0 flex-1 flex-col gap-px overflow-auto px-2 pb-2 pt-2\">\n          <button\n            type=\"button\"\n            className={cn('sb-row', isHomeActive && 'active')}\n            onClick={() => navigate('/')}\n          >\n            <HomeIcon className=\"size-[14px]\" />\n            <span className=\"flex-1 truncate\">Home</span>\n          </button>\n\n          <div\n            className={cn(dragOverAllMeetings && 'rounded bg-[color:var(--surface-hover)]')}\n            onDragOver={(e) => {\n              if (e.dataTransfer.types.includes('application/x-steno-meeting')) {\n                e.preventDefault();\n                e.dataTransfer.dropEffect = 'move';\n                setDragOverAllMeetings(true);\n              }\n            }}\n            onDragLeave={(e) => {\n              if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n              setDragOverAllMeetings(false);\n            }}\n            onDrop={(e) => handleFolderDrop(e, null)}\n          >\n            <button\n              type=\"button\"\n              className={cn('sb-row', isAllMeetingsActive && 'active')}\n              onClick={() => navigate('/meetings')}\n            >\n              <Inbox className=\"size-[14px]\" />\n              <span className=\"flex-1 truncate\">All notes</span>\n              {totalMeetings > 0 && (\n                <span className=\"text-xs tabular-nums\" style={{ color: 'var(--fg-muted)' }}>\n                  {totalMeetings}\n                </span>\n              )}\n            </button>\n          </div>\n\n          <button\n            type=\"button\"\n            className={cn('sb-row', isChatActive && 'active')}\n            onClick={() => navigate('/chat')}\n          >\n            <MessageSquare className=\"size-[14px]\" />\n            <span className=\"flex-1 truncate\">Chat</span>\n          </button>\n\n          {/* Folders group */}\n          <div className=\"mt-3.5\">\n            <div\n              className=\"sb-group-head flex cursor-pointer select-none items-center justify-between px-2.5 py-1.5 text-[11.5px] font-medium tracking-[0.02em] transition-colors hover:text-[color:var(--fg-1)]\"\n              style={{ color: 'var(--fg-2)' }}\n              onClick={() => setFoldersOpen((o) => !o)}\n            >\n              <span className=\"flex items-center gap-1.5\">\n                <ChevronDown className={cn('size-3 transition-transform', !foldersOpen && '-rotate-90')} />\n                <span>Folders</span>\n              </span>\n              <button\n                type=\"button\"\n                className=\"inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity hover:bg-[color:var(--surface-active)] [.sb-group-head:hover_&]:opacity-100\"\n                onClick={(e) => { e.stopPropagation(); onNewFolder(); }}\n                aria-label=\"New folder\"\n                style={{ color: 'var(--fg-2)' }}\n              >\n                <Plus className=\"size-3\" />\n              </button>\n            </div>\n\n            {foldersOpen &&\n              filteredFolders.map((folder) => {\n                const isOver = dragOverFolder === folder.id;\n                const isActive = activeFolderId === folder.id;\n                return (\n                  <div\n                    key={folder.id}\n                    className={cn('rounded', isOver && 'bg-[color:var(--surface-hover)] ring-1 ring-[color:var(--focus-ring)]')}\n                    onDragOver={(e) => {\n                      if (e.dataTransfer.types.includes('application/x-steno-meeting')) {\n                        e.preventDefault();\n                        e.dataTransfer.dropEffect = 'move';\n                        setDragOverFolder(folder.id);\n                      }\n                    }}\n                    onDragLeave={(e) => {\n                      if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n                      setDragOverFolder(null);\n                    }}\n                    onDrop={(e) => handleFolderDrop(e, folder.id)}\n                    onContextMenu={(e) => handleFolderContext(e, folder.id)}\n                  >\n                    <button\n                      type=\"button\"\n                      data-testid=\"sidebar-folder\"\n                      className={cn('sb-row', isActive && 'active')}\n                      style={{ paddingLeft: 12 }}\n                      onClick={() => navigate(`/folders/${encodeURIComponent(folder.id)}`)}\n                    >\n                      <span\n                        role=\"button\"\n                        tabIndex={0}\n                        aria-label=\"Change folder icon\"\n                        className=\"flex-shrink-0 rounded p-0.5 hover:bg-[color:var(--surface-active)]\"\n                        style={{ color: 'var(--fg-2)' }}\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          setIconPicker({ id: folder.id, anchorRect: e.currentTarget.getBoundingClientRect() });\n                        }}\n                        onKeyDown={(e) => {\n                          if (e.key === 'Enter' || e.key === ' ') {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            setIconPicker({ id: folder.id, anchorRect: e.currentTarget.getBoundingClientRect() });\n                          }\n                        }}\n                      >\n                        <LucideIcon name={folder.icon ?? 'folder'} size={14} />\n                      </span>\n                      <span className=\"flex-1 truncate\">{folder.name}</span>\n                      <span className=\"text-xs tabular-nums\" style={{ color: 'var(--fg-muted)' }}>\n                        {folder.meetings.length}\n                      </span>\n                    </button>\n                  </div>\n                );\n              })}\n          </div>\n        </nav>\n\n        {/* Pinned to the bottom of the sidebar — small icon button like it\n            was in the toolbar, not a full nav row. Toggles open/closed: a\n            second click while already on /settings returns the user to the\n            route they were viewing before. */}\n        <div className=\"px-3 py-2\">\n          <button\n            type=\"button\"\n            onClick={() => toggleSettings(currentRoute)}\n            aria-label=\"Settings\"\n            title=\"Settings\"\n            aria-pressed={currentRoute === '/settings'}\n            className={cn(\n              'inline-flex h-[26px] w-7 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]',\n              currentRoute === '/settings'\n                ? 'bg-[color:var(--surface-active)] text-[color:var(--fg-1)]'\n                : 'text-[color:var(--fg-2)]',\n            )}\n          >\n            <SettingsIcon className=\"size-[15px]\" />\n          </button>\n        </div>\n\n        {iconPicker && (\n          <IconPicker\n            anchorRect={iconPicker.anchorRect}\n            onSelect={(icon) => updateIcon.mutate({ id: iconPicker.id, icon })}\n            onClose={() => setIconPicker(null)}\n          />\n        )}\n      </div>\n\n      {/* Resize handle */}\n      {!collapsed && (\n        <div\n          onMouseDown={onResizeMouseDown}\n          aria-hidden\n          className=\"absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize hover:bg-[hsl(var(--border))]\"\n          style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}\n        />\n      )}\n    </aside>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/TranscriptPanel.tsx",
    "content": "import * as React from 'react';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport { Search as SearchIcon } from 'lucide-react';\nimport { Input } from '@/components/ui/input';\nimport { cn } from '@/lib/utils';\nimport { useMeeting } from '@/hooks/useMeetings';\nimport type { Meeting } from '@/lib/ipc';\n\ninterface Segment {\n  speaker: 'You' | 'Others' | null;\n  text: string;\n}\n\n/** Bare transcript content — no outer card or header. Used inside the dock's mv-transcript panel. */\nexport function TranscriptPanelContent({\n  summaryFile,\n}: {\n  summaryFile: string;\n  onClose?: () => void;\n}) {\n  const meeting = useMeeting(summaryFile);\n\n  if (meeting.isLoading) {\n    return <div className=\"px-4 py-3 text-sm text-muted-foreground\">Loading…</div>;\n  }\n  if (!meeting.data) {\n    return <div className=\"px-4 py-3 text-sm text-muted-foreground\">No transcript available.</div>;\n  }\n  return <TranscriptBody meeting={meeting.data} />;\n}\n\n\nfunction TranscriptBody({ meeting }: { meeting: Meeting }) {\n  const segments = React.useMemo(() => parseTranscript(meeting), [meeting]);\n  const [query, setQuery] = React.useState('');\n\n  const filtered = React.useMemo(() => {\n    if (!query.trim()) return segments;\n    const needle = query.trim().toLowerCase();\n    return segments.filter((s) => s.text.toLowerCase().includes(needle));\n  }, [segments, query]);\n\n  const parentRef = React.useRef<HTMLDivElement>(null);\n  const rowVirtualizer = useVirtualizer({\n    count: filtered.length,\n    getScrollElement: () => parentRef.current,\n    estimateSize: () => 80,\n    overscan: 8,\n  });\n\n  if (segments.length === 0) {\n    return (\n      <div className=\"px-4 py-3 text-sm text-muted-foreground\">No transcript available.</div>\n    );\n  }\n\n  return (\n    <div className=\"flex min-h-0 flex-1 flex-col\">\n      <div className=\"flex flex-shrink-0 items-center px-3 py-1.5\">\n        <Input\n          variant=\"sunken\"\n          size=\"sm\"\n          iconStart={<SearchIcon className=\"size-3.5\" />}\n          placeholder=\"Search transcript\"\n          value={query}\n          onChange={(e) => setQuery(e.target.value)}\n          className=\"flex-1\"\n        />\n      </div>\n\n      <div ref={parentRef} className=\"min-h-0 flex-1 overflow-auto px-3 pb-3\">\n        <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>\n          {rowVirtualizer.getVirtualItems().map((virtualRow) => {\n            const segment = filtered[virtualRow.index];\n            return (\n              <div\n                key={virtualRow.key}\n                data-index={virtualRow.index}\n                ref={rowVirtualizer.measureElement}\n                style={{\n                  position: 'absolute',\n                  top: 0,\n                  left: 0,\n                  right: 0,\n                  transform: `translateY(${virtualRow.start}px)`,\n                }}\n                className=\"py-1\"\n              >\n                <TranscriptRow segment={segment} highlight={query} />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction TranscriptRow({ segment, highlight }: { segment: Segment; highlight: string }) {\n  return (\n    <div\n      className={cn(\n        'flex gap-3 rounded-md px-2 py-1.5',\n        segment.speaker === 'You' && 'border-l-2 border-accent-primary/40 bg-accent-primary/5 pl-3',\n        segment.speaker === 'Others' && 'border-l-2 border-border bg-muted/20 pl-3',\n      )}\n    >\n      {segment.speaker && (\n        <span\n          className={cn(\n            'inline-flex h-5 flex-shrink-0 items-center rounded px-1.5 text-[11px] font-semibold uppercase tracking-wide',\n            segment.speaker === 'You'\n              ? 'bg-accent-primary/10 text-accent-primary'\n              : 'bg-muted text-muted-foreground',\n          )}\n        >\n          {segment.speaker}\n        </span>\n      )}\n      <p className=\"text-sm leading-[1.65] text-foreground/90\">\n        {renderHighlighted(segment.text, highlight)}\n      </p>\n    </div>\n  );\n}\n\nfunction renderHighlighted(text: string, highlight: string): React.ReactNode {\n  const needle = highlight.trim();\n  if (!needle) return text;\n  const lower = text.toLowerCase();\n  const ln = needle.toLowerCase();\n  const parts: React.ReactNode[] = [];\n  let cursor = 0;\n  let idx = lower.indexOf(ln, cursor);\n  let key = 0;\n  while (idx !== -1) {\n    if (idx > cursor) parts.push(text.slice(cursor, idx));\n    parts.push(\n      <mark\n        key={key++}\n        className=\"rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30\"\n      >\n        {text.slice(idx, idx + needle.length)}\n      </mark>,\n    );\n    cursor = idx + needle.length;\n    idx = lower.indexOf(ln, cursor);\n  }\n  if (cursor < text.length) parts.push(text.slice(cursor));\n  return parts;\n}\n\nfunction parseTranscript(meeting: Meeting): Segment[] {\n  if (meeting.is_diarised && meeting.diarised_text) {\n    const blocks = meeting.diarised_text.split(/(?=\\[You\\]|\\[Others\\])/);\n    return blocks\n      .map((b) => b.trim())\n      .filter(Boolean)\n      .map((b): Segment => {\n        if (b.startsWith('[You]')) return { speaker: 'You', text: b.replace('[You]', '').trim() };\n        if (b.startsWith('[Others]'))\n          return { speaker: 'Others', text: b.replace('[Others]', '').trim() };\n        return { speaker: null, text: b };\n      });\n  }\n  const text = (meeting.transcript ?? '').trim();\n  if (!text) return [];\n  const sentences = text\n    .split(/(?<=[.!?])\\s+(?=[A-Z\"'(\\[])/)\n    .map((s) => s.trim())\n    .filter(Boolean);\n  return (sentences.length > 1 ? sentences : [text]).map((s) => ({ speaker: null, text: s }));\n}\n\n"
  },
  {
    "path": "app/renderer/src/components/home/PreviousRow.tsx",
    "content": "import { Folder as FolderIcon, Loader2 } from 'lucide-react';\nimport type { Meeting } from '@/lib/ipc';\nimport { navigate } from '@/lib/router';\nimport { useMeetingsList } from '@/lib/meetingsListContext';\n\ninterface PreviousRowProps {\n  meeting: Meeting;\n  folderName?: string;\n}\n\nexport function PreviousRow({ meeting, folderName }: PreviousRowProps) {\n  const info = meeting.session_info;\n  const when = formatTime(info.processed_at ?? info.updated_at);\n  const duration = formatDuration(info.duration_seconds);\n  const preview = previewText(meeting);\n  const participants = Array.isArray(meeting.participants)\n    ? meeting.participants.length\n    : 0;\n  const list = useMeetingsList();\n  const isLive = meeting.is_recording;\n  const isProcessing = meeting.is_processing;\n  const isSynthetic = isLive || isProcessing;\n\n  // Synthetic rows route to the live or processing screen instead of trying\n  // to open the sentinel summary_file (which doesn't exist on disk yet).\n  const targetPath = isLive\n    ? '/recording'\n    : isProcessing\n      ? '/meetings/processing'\n      : `/meetings/${encodeURIComponent(info.summary_file)}`;\n\n  return (\n    <div\n      className=\"previous-row\"\n      data-testid=\"previous-row\"\n      data-recording={isLive ? 'true' : undefined}\n      data-processing={isProcessing ? 'true' : undefined}\n      role=\"button\"\n      tabIndex={0}\n      draggable={!isSynthetic && !!list}\n      onDragStart={(e) =>\n        !isSynthetic && list?.startMeetingDrag(info.summary_file, e)\n      }\n      onContextMenu={(e) =>\n        !isSynthetic && list?.openMeetingContextMenu(info.summary_file, e)\n      }\n      onClick={() => navigate(targetPath)}\n      onKeyDown={(e) => {\n        if (e.key === 'Enter' || e.key === ' ') {\n          e.preventDefault();\n          navigate(targetPath);\n        }\n      }}\n    >\n      <div\n        className=\"pt-0.5 text-[12.5px] tabular-nums\"\n        style={{ color: 'var(--fg-2)' }}\n      >\n        {isSynthetic ? 'Now' : (when ?? '')}\n      </div>\n      <div className=\"flex min-w-0 flex-col gap-1\">\n        <div className=\"flex items-center gap-2\">\n          <div\n            className=\"truncate text-sm font-medium tracking-[-0.005em]\"\n            style={{ color: 'var(--fg-1)' }}\n          >\n            {info.name || 'Untitled note'}\n          </div>\n          {isLive && <LiveBadge />}\n          {isProcessing && <ProcessingBadge />}\n        </div>\n        {preview && !isSynthetic && (\n          <div\n            className=\"line-clamp-1 text-[13px] leading-[1.5]\"\n            style={{ color: 'var(--fg-2)' }}\n          >\n            {preview}\n          </div>\n        )}\n        {(folderName || participants > 0) && (\n          <div\n            className=\"mt-0.5 flex items-center gap-2.5 text-xs\"\n            style={{ color: 'var(--fg-muted)' }}\n          >\n            {folderName && (\n              <span\n                className=\"inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11.5px]\"\n                style={{ background: 'var(--surface-hover)', color: 'var(--fg-2)' }}\n              >\n                <FolderIcon className=\"size-[11px]\" />\n                {folderName}\n              </span>\n            )}\n            {participants > 0 && (\n              <>\n                {folderName && <span className=\"opacity-50\">·</span>}\n                <span>\n                  {participants} {participants === 1 ? 'person' : 'people'}\n                </span>\n              </>\n            )}\n          </div>\n        )}\n      </div>\n      <div\n        className=\"flex flex-col items-end gap-1.5 pt-0.5 text-xs tabular-nums\"\n        style={{ color: 'var(--fg-2)' }}\n      >\n        {duration && <span>{duration}</span>}\n      </div>\n    </div>\n  );\n}\n\nfunction LiveBadge() {\n  return (\n    <span\n      className=\"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium\"\n      style={{\n        background: 'var(--recording)',\n        color: '#FFFFFF',\n      }}\n    >\n      <span\n        aria-hidden\n        className=\"inline-block size-1.5 rounded-full bg-white\"\n        style={{ animation: 'pulse 1.4s ease-in-out infinite' }}\n      />\n      Recording\n    </span>\n  );\n}\n\nfunction ProcessingBadge() {\n  return (\n    <span\n      className=\"inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium\"\n      style={{\n        background: 'var(--surface-sunken)',\n        color: 'var(--fg-2)',\n        borderRadius: 'var(--radius-sm)',\n      }}\n    >\n      <Loader2 className=\"size-[10px] animate-spin\" aria-hidden />\n      Processing\n    </span>\n  );\n}\n\nfunction formatTime(iso?: string): string | undefined {\n  if (!iso) return undefined;\n  const d = new Date(iso);\n  if (Number.isNaN(d.getTime())) return undefined;\n  const pad = (n: number) => n.toString().padStart(2, '0');\n  return `${pad(d.getHours())}:${pad(d.getMinutes())}`;\n}\n\nfunction formatDuration(seconds?: number): string | undefined {\n  if (!seconds || seconds <= 0) return undefined;\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  const s = seconds % 60;\n  if (h > 0) return `${h}h ${m}m`;\n  if (m > 0) return `${m}m`;\n  return `${s}s`;\n}\n\nfunction previewText(meeting: Meeting): string | undefined {\n  const summary = meeting.summary?.trim();\n  if (summary) return summary;\n  const kp = meeting.key_points?.[0];\n  if (typeof kp === 'string' && kp.trim()) return kp.trim();\n  return undefined;\n}\n"
  },
  {
    "path": "app/renderer/src/components/home/UpcomingCard.tsx",
    "content": "import * as React from 'react';\nimport { Video } from 'lucide-react';\nimport type { CalendarEvent } from '@/lib/ipc';\nimport { ipc } from '@/lib/ipc';\nimport { cn } from '@/lib/utils';\nimport { useRecording } from '@/hooks/useRecording';\n\ninterface UpcomingCardProps {\n  event: CalendarEvent;\n}\n\nexport function UpcomingCard({ event }: UpcomingCardProps) {\n  const relative = relativeLabel(event.start);\n  const { dayLabel, clock, end } = formatStartEnd(event.start, event.end);\n  const meetingUrl = event.meeting_url?.trim();\n  const recording = useRecording();\n\n  // Click the card → start a new recording titled after this event. The\n  // event title becomes the note's session name (instead of the auto\n  // 'Note' placeholder), so the AI rename step skips it and the user\n  // gets the meeting they expected. Doesn't open the join URL — the\n  // Join / Start now buttons on the right own that action.\n  const onStart = () => {\n    if (recording.status !== 'idle') return;\n    void recording.startRecording(event.title);\n  };\n\n  // Open the meeting URL externally. Used by the inner Join button only.\n  const onJoin = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (!meetingUrl) return;\n    void ipc().shell.openExternal(meetingUrl);\n  };\n\n  // Start recording AND open the URL — used by the urgent \"Start now\"\n  // button when the meeting is imminent.\n  const onStartAndJoin = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    onStart();\n    if (meetingUrl) void ipc().shell.openExternal(meetingUrl);\n  };\n\n  return (\n    <div\n      className={cn('upcoming-card', relative.urgent && 'upcoming-card-live')}\n      role=\"button\"\n      tabIndex={0}\n      onClick={onStart}\n      onKeyDown={(e) => {\n        // Only handle Enter/Space when the card itself has focus — inner\n        // buttons (Join, Start now) own their own keyboard activation, and\n        // we don't want to double-fire them.\n        if (e.target !== e.currentTarget) return;\n        if (e.key === 'Enter' || e.key === ' ') {\n          e.preventDefault();\n          onStart();\n        }\n      }}\n    >\n      {/* Relative time block */}\n      <div\n        className={cn(\n          'flex flex-col items-start gap-0 border-r pr-4',\n          relative.urgent && 'text-[color:var(--fg-1)]',\n        )}\n        style={{ borderRightColor: 'var(--border-subtle)' }}\n      >\n        {relative.prefix && (\n          <span\n            className=\"text-[11px] font-medium tracking-[0.01em] lowercase\"\n            style={{ color: relative.urgent ? 'var(--fg-1)' : 'var(--fg-2)', opacity: relative.urgent ? 0.7 : 1 }}\n          >\n            {relative.prefix}\n          </span>\n        )}\n        <span\n          className=\"whitespace-nowrap text-sm font-semibold leading-[1.2] tracking-[-0.01em]\"\n          style={{ color: 'var(--fg-1)' }}\n        >\n          {relative.value}\n        </span>\n      </div>\n\n      {/* Meta + title */}\n      <div className=\"flex min-w-0 flex-col gap-[3px]\">\n        <div\n          className=\"flex items-center gap-1.5 text-xs\"\n          style={{ color: 'var(--fg-2)' }}\n        >\n          <span className=\"font-medium\">{dayLabel}</span>\n          <span className=\"opacity-40\">·</span>\n          <span className=\"tabular-nums\">\n            {end ? `${clock} – ${end}` : clock}\n          </span>\n        </div>\n        <div\n          className=\"truncate text-sm font-medium tracking-[-0.005em]\"\n          style={{ color: 'var(--fg-1)' }}\n        >\n          {event.title}\n        </div>\n      </div>\n\n      {/* CTA */}\n      <div className=\"flex flex-shrink-0 flex-col items-end gap-2\">\n        {meetingUrl ? (\n          relative.urgent ? (\n            <button\n              type=\"button\"\n              onClick={onStartAndJoin}\n              className=\"inline-flex h-7 items-center gap-[7px] rounded-full px-3 text-xs font-medium\"\n              style={{\n                background: 'var(--fg-1)',\n                color: 'var(--primary-fg)',\n                fontFamily: 'var(--font-sans)',\n              }}\n            >\n              <span\n                className=\"size-[7px] rounded-full\"\n                style={{\n                  background: 'var(--recording)',\n                  animation: 'record-pulse 1.6s ease-out infinite',\n                }}\n              />\n              Start now\n            </button>\n          ) : (\n            <button\n              type=\"button\"\n              onClick={onJoin}\n              className=\"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors\"\n              style={{\n                background: 'var(--surface-hover)',\n                color: 'var(--fg-1)',\n                fontFamily: 'var(--font-sans)',\n              }}\n            >\n              <Video className=\"size-[13px]\" />\n              Join\n            </button>\n          )\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nfunction relativeLabel(startIso: string): { prefix: string | null; value: string; urgent: boolean } {\n  const start = new Date(startIso);\n  if (Number.isNaN(start.getTime())) return { prefix: null, value: '—', urgent: false };\n  const diffMs = start.getTime() - Date.now();\n  const diffMins = Math.round(diffMs / 60000);\n  if (diffMins <= 0) return { prefix: null, value: 'Now', urgent: true };\n  if (diffMins < 60) return { prefix: 'In', value: `${diffMins} mins`, urgent: diffMins <= 15 };\n  const hrs = Math.round(diffMins / 60);\n  if (hrs < 24) return { prefix: 'In', value: `${hrs} hrs`, urgent: false };\n  const days = Math.round(hrs / 24);\n  return { prefix: 'In', value: `${days} day${days === 1 ? '' : 's'}`, urgent: false };\n}\n\nfunction formatStartEnd(startIso: string, endIso: string) {\n  const start = new Date(startIso);\n  const now = new Date();\n  const sameDay = (a: Date, b: Date) =>\n    a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n\n  const tomorrow = new Date(now);\n  tomorrow.setDate(now.getDate() + 1);\n\n  const dayLabel = sameDay(start, now)\n    ? 'Today'\n    : sameDay(start, tomorrow)\n      ? 'Tomorrow'\n      : start.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });\n\n  const pad = (n: number) => n.toString().padStart(2, '0');\n  const clock = Number.isNaN(start.getTime())\n    ? ''\n    : `${pad(start.getHours())}:${pad(start.getMinutes())}`;\n\n  const endDate = endIso ? new Date(endIso) : null;\n  const endClock =\n    endDate && !Number.isNaN(endDate.getTime())\n      ? `${pad(endDate.getHours())}:${pad(endDate.getMinutes())}`\n      : null;\n\n  return { dayLabel, clock, end: endClock };\n}\n"
  },
  {
    "path": "app/renderer/src/components/ui/app-icon.tsx",
    "content": "import { cn } from '@/lib/utils';\n\ninterface AppIconProps {\n  size?: number;\n  className?: string;\n}\n\nexport function AppIcon({ size = 80, className }: AppIconProps) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 64 64\"\n      width={size}\n      height={size}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-label=\"StenoAI\"\n      role=\"img\"\n      className={cn('shrink-0', className)}\n      style={{ color: 'var(--fg-1)' }}\n    >\n      <path d=\"M28 7 Q29 9.5 30 12.5\" />\n      <path d=\"M36 7 Q35 9.5 34 12.5\" />\n      <circle cx=\"32\" cy=\"15\" r=\"3.8\" />\n      <circle cx=\"30.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n      <circle cx=\"33.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n      <path d=\"M30 19 Q28 19 28 21 L28 50 L32 60 L36 50 L36 21 Q36 19 34 19 Z\" />\n      <line x1=\"28\" y1=\"32\" x2=\"36\" y2=\"32\" />\n      <line x1=\"28\" y1=\"38\" x2=\"36\" y2=\"38\" />\n      <line x1=\"28\" y1=\"44\" x2=\"36\" y2=\"44\" />\n      <line x1=\"28\" y1=\"50\" x2=\"36\" y2=\"50\" />\n      <path d=\"M28 22 C18 15 8 17 4 22 C10 28 20 28 28 27 Z\" />\n      <path d=\"M36 22 C46 15 56 17 60 22 C50 28 44 28 36 27 Z\" />\n      <path d=\"M28 28 C18 30 10 35 6 40 C14 39 22 36 28 33 Z\" />\n      <path d=\"M36 28 C46 30 54 35 58 40 C50 39 42 36 36 33 Z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/ui/button.tsx",
    "content": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@/lib/utils';\n\nconst buttonVariants = cva(\n  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors duration-fast ease-steno focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:shrink-0 [&_svg]:size-4',\n  {\n    variants: {\n      variant: {\n        default:\n          'bg-primary text-primary-foreground hover:bg-ink-700 dark:hover:bg-white',\n        outline:\n          'border border-border bg-transparent hover:bg-muted',\n        secondary:\n          'bg-secondary text-secondary-foreground hover:bg-paper-2 dark:hover:bg-[hsl(54,7%,18%)]',\n        ghost:\n          'hover:bg-muted',\n        destructive:\n          'bg-destructive text-destructive-foreground hover:opacity-90',\n        link:\n          'text-foreground underline-offset-4 hover:underline decoration-border',\n      },\n      size: {\n        default: 'h-9 px-4',\n        sm: 'h-8 px-3 text-xs',\n        lg: 'h-11 px-6 text-base',\n        icon: 'h-9 w-9',\n      },\n    },\n    defaultVariants: { variant: 'default', size: 'default' },\n  }\n);\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nexport const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : 'button';\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    );\n  }\n);\nButton.displayName = 'Button';\n\nexport { buttonVariants };\n"
  },
  {
    "path": "app/renderer/src/components/ui/card.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@/lib/utils';\n\nconst cardVariants = cva('rounded-lg', {\n  variants: {\n    raised: {\n      true: 'border border-border bg-card shadow-sm',\n      false: 'bg-transparent',\n    },\n    padded: {\n      true: 'p-6',\n      false: '',\n    },\n  },\n  defaultVariants: { raised: false, padded: false },\n});\n\nexport interface CardProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof cardVariants> {}\n\nexport const Card = React.forwardRef<HTMLDivElement, CardProps>(\n  ({ className, raised, padded, ...props }, ref) => (\n    <div\n      ref={ref}\n      className={cn(cardVariants({ raised, padded, className }))}\n      {...props}\n    />\n  )\n);\nCard.displayName = 'Card';\n\nexport const CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('flex flex-col gap-1.5 pb-4', className)}\n    {...props}\n  />\n));\nCardHeader.displayName = 'CardHeader';\n\nexport const CardTitle = React.forwardRef<\n  HTMLHeadingElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h3\n    ref={ref}\n    className={cn('font-serif text-xl leading-[1.25] tracking-[-0.01em]', className)}\n    {...props}\n  />\n));\nCardTitle.displayName = 'CardTitle';\n\nexport const CardDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <p\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nCardDescription.displayName = 'CardDescription';\n\nexport const CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('', className)} {...props} />\n));\nCardContent.displayName = 'CardContent';\n\nexport const CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('flex items-center gap-2 pt-4', className)}\n    {...props}\n  />\n));\nCardFooter.displayName = 'CardFooter';\n\nexport { cardVariants };\n"
  },
  {
    "path": "app/renderer/src/components/ui/chip.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@/lib/utils';\n\nconst chipVariants = cva(\n  'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors duration-fast ease-steno focus:outline-none [&_svg]:size-3 [&_svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default:\n          'border-border bg-transparent text-foreground hover:bg-muted',\n        muted:\n          'border-transparent bg-muted text-muted-foreground hover:bg-paper-2 dark:hover:bg-[hsl(54,7%,18%)]',\n        destructive:\n          'border-transparent bg-destructive/10 text-destructive hover:bg-destructive/15',\n      },\n      interactive: {\n        true: 'cursor-pointer',\n        false: 'cursor-default',\n      },\n    },\n    defaultVariants: { variant: 'default', interactive: false },\n  }\n);\n\nexport interface ChipProps\n  extends React.HTMLAttributes<HTMLSpanElement>,\n    VariantProps<typeof chipVariants> {\n  asButton?: boolean;\n}\n\nexport const Chip = React.forwardRef<HTMLSpanElement, ChipProps>(\n  ({ className, variant, interactive, asButton, onClick, ...props }, ref) => {\n    const clickable = !!onClick || !!interactive || !!asButton;\n    if (asButton) {\n      // Explicit type=\"button\" — defaulting to submit silently breaks chips\n      // rendered inside <form> (e.g. the chat composer's suggestion chips).\n      return (\n        <button\n          ref={ref as unknown as React.Ref<HTMLButtonElement>}\n          type=\"button\"\n          onClick={onClick as React.MouseEventHandler<HTMLButtonElement>}\n          className={cn(chipVariants({ variant, interactive: clickable, className }))}\n          {...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}\n        />\n      );\n    }\n    return (\n      <span\n        ref={ref}\n        onClick={onClick}\n        role={clickable ? 'button' : undefined}\n        tabIndex={clickable ? 0 : undefined}\n        className={cn(chipVariants({ variant, interactive: clickable, className }))}\n        {...props}\n      />\n    );\n  }\n);\nChip.displayName = 'Chip';\n\nexport { chipVariants };\n"
  },
  {
    "path": "app/renderer/src/components/ui/confirm-dialog.tsx",
    "content": "import * as React from 'react';\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\n\nexport interface ConfirmDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  title: string;\n  description?: React.ReactNode;\n  confirmLabel?: string;\n  cancelLabel?: string;\n  destructive?: boolean;\n  onConfirm: () => void | Promise<void>;\n  isPending?: boolean;\n}\n\nexport function ConfirmDialog({\n  open,\n  onOpenChange,\n  title,\n  description,\n  confirmLabel = 'Confirm',\n  cancelLabel = 'Cancel',\n  destructive,\n  onConfirm,\n  isPending,\n}: ConfirmDialogProps) {\n  const [busy, setBusy] = React.useState(false);\n  const pending = busy || isPending;\n\n  const handleConfirm = async () => {\n    setBusy(true);\n    try {\n      await onConfirm();\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent data-confirm-dialog className=\"max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{title}</DialogTitle>\n          {description && <DialogDescription>{description}</DialogDescription>}\n        </DialogHeader>\n        <DialogFooter>\n          <DialogClose asChild>\n            <Button variant=\"outline\" disabled={pending}>\n              {cancelLabel}\n            </Button>\n          </DialogClose>\n          <Button\n            variant={destructive ? 'destructive' : 'default'}\n            onClick={() => void handleConfirm()}\n            disabled={pending}\n          >\n            {pending ? 'Working...' : confirmLabel}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/ui/dialog.tsx",
    "content": "import * as React from 'react';\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { X } from 'lucide-react';\nimport { cn } from '@/lib/utils';\n\nexport const Dialog = DialogPrimitive.Root;\nexport const DialogTrigger = DialogPrimitive.Trigger;\nexport const DialogPortal = DialogPrimitive.Portal;\nexport const DialogClose = DialogPrimitive.Close;\n\nexport const DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      'fixed inset-0 z-50 bg-ink-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',\n      className,\n    )}\n    {...props}\n  />\n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nexport const DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-card p-6 shadow-lg duration-fast ease-steno data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <DialogPrimitive.Close className=\"absolute right-3 top-3 rounded-sm opacity-60 transition-opacity duration-fast ease-steno hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n        <X className=\"size-4\" />\n        <span className=\"sr-only\">Close</span>\n      </DialogPrimitive.Close>\n    </DialogPrimitive.Content>\n  </DialogPortal>\n));\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nexport function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn('flex flex-col gap-1.5', className)} {...props} />;\n}\n\nexport function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}\n      {...props}\n    />\n  );\n}\n\nexport const DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn('font-serif text-xl leading-[1.25] tracking-[-0.01em]', className)}\n    {...props}\n  />\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nexport const DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n"
  },
  {
    "path": "app/renderer/src/components/ui/input.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@/lib/utils';\n\nconst inputVariants = cva(\n  'flex w-full rounded-md border border-border bg-transparent text-sm transition-colors duration-fast ease-steno placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',\n  {\n    variants: {\n      variant: {\n        default: '',\n        sunken: 'border-transparent bg-paper-1 dark:bg-[hsl(54,7%,14%)]',\n        inherit:\n          'border-transparent bg-transparent p-0 font-[inherit] text-[inherit] leading-[inherit] tracking-[inherit]',\n      },\n      size: {\n        default: 'h-9 px-3',\n        sm: 'h-8 px-3 text-xs',\n        lg: 'h-11 px-4 text-base',\n      },\n    },\n    defaultVariants: { variant: 'default', size: 'default' },\n  }\n);\n\ntype Size = NonNullable<VariantProps<typeof inputVariants>['size']>;\ntype Variant = NonNullable<VariantProps<typeof inputVariants>['variant']>;\n\nexport interface InputProps\n  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {\n  variant?: Variant;\n  size?: Size;\n  iconStart?: React.ReactNode;\n  iconEnd?: React.ReactNode;\n}\n\nexport const Input = React.forwardRef<HTMLInputElement, InputProps>(\n  ({ className, variant, size, iconStart, iconEnd, type = 'text', ...props }, ref) => {\n    const hasStart = !!iconStart;\n    const hasEnd = !!iconEnd;\n    const input = (\n      <input\n        type={type}\n        ref={ref}\n        className={cn(\n          inputVariants({ variant, size, className }),\n          hasStart && 'pl-8',\n          hasEnd && 'pr-8',\n        )}\n        {...props}\n      />\n    );\n\n    if (!hasStart && !hasEnd) return input;\n\n    return (\n      <div className=\"relative\">\n        {hasStart && (\n          <span className=\"pointer-events-none absolute left-2.5 top-1/2 flex size-4 -translate-y-1/2 items-center justify-center text-muted-foreground\">\n            {iconStart}\n          </span>\n        )}\n        {input}\n        {hasEnd && (\n          <span className=\"pointer-events-none absolute right-2.5 top-1/2 flex size-4 -translate-y-1/2 items-center justify-center text-muted-foreground\">\n            {iconEnd}\n          </span>\n        )}\n      </div>\n    );\n  }\n);\nInput.displayName = 'Input';\n\nexport interface TextareaProps\n  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  variant?: Variant;\n  autoResize?: boolean;\n}\n\nexport const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n  ({ className, variant, autoResize, onInput, rows = 2, ...props }, ref) => {\n    const handleInput = React.useCallback(\n      (e: React.InputEvent<HTMLTextAreaElement>) => {\n        if (autoResize) {\n          const el = e.currentTarget;\n          el.style.height = 'auto';\n          el.style.height = `${el.scrollHeight}px`;\n        }\n        onInput?.(e);\n      },\n      [autoResize, onInput],\n    );\n\n    return (\n      <textarea\n        ref={ref}\n        rows={rows}\n        onInput={handleInput}\n        className={cn(\n          inputVariants({ variant, size: 'default', className }),\n          'min-h-[36px] resize-none py-2',\n        )}\n        {...props}\n      />\n    );\n  }\n);\nTextarea.displayName = 'Textarea';\n\nexport { inputVariants };\n"
  },
  {
    "path": "app/renderer/src/components/ui/kbd.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils';\n\nexport function KbdKey({ className, children }: { className?: string; children: React.ReactNode }) {\n  return (\n    <kbd\n      className={cn(\n        'inline-flex items-center justify-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground shadow-[0_1px_0_0_hsl(var(--border))]',\n        className,\n      )}\n    >\n      {children}\n    </kbd>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/components/ui/popover.tsx",
    "content": "import * as React from 'react';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { cn } from '@/lib/utils';\n\nexport const Popover = PopoverPrimitive.Root;\nexport const PopoverTrigger = PopoverPrimitive.Trigger;\nexport const PopoverAnchor = PopoverPrimitive.Anchor;\n\nexport const PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = 'end', sideOffset = 6, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        'z-50 w-64 rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-lg outline-none',\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n        className,\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n));\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n"
  },
  {
    "path": "app/renderer/src/components/ui/row.tsx",
    "content": "import * as React from 'react';\nimport { ChevronRight } from 'lucide-react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@/lib/utils';\n\nconst rowVariants = cva(\n  'flex w-full items-center gap-2 rounded-md text-left transition-colors duration-fast ease-steno',\n  {\n    variants: {\n      size: {\n        sm: 'px-2 py-1 text-xs',\n        md: 'px-2 py-1.5 text-sm',\n        lg: 'px-3 py-2 text-sm',\n      },\n      interactive: {\n        true: 'cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n        false: '',\n      },\n      active: {\n        true: 'bg-muted font-medium',\n        false: '',\n      },\n    },\n    defaultVariants: { size: 'md', interactive: false, active: false },\n  }\n);\n\nexport interface RowProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, 'onClick'>,\n    VariantProps<typeof rowVariants> {\n  icon?: React.ReactNode;\n  trailing?: React.ReactNode;\n  collapsible?: boolean;\n  open?: boolean;\n  onClick?: (e: React.MouseEvent) => void;\n  as?: 'div' | 'button' | 'li';\n  label: React.ReactNode;\n}\n\nexport const Row = React.forwardRef<HTMLElement, RowProps>(\n  (\n    {\n      className,\n      size,\n      active,\n      icon,\n      trailing,\n      collapsible,\n      open,\n      onClick,\n      as,\n      label,\n      ...props\n    },\n    ref,\n  ) => {\n    const clickable = !!onClick || collapsible;\n    const Tag = (as ?? (clickable ? 'button' : 'div')) as 'button';\n    const chevron = collapsible ? (\n      <ChevronRight\n        className={cn(\n          'size-3 shrink-0 text-muted-foreground transition-transform duration-fast ease-steno',\n          open && 'rotate-90',\n        )}\n      />\n    ) : null;\n\n    return (\n      <Tag\n        ref={ref as never}\n        onClick={onClick}\n        type={Tag === 'button' ? 'button' : undefined}\n        aria-expanded={collapsible ? !!open : undefined}\n        className={cn(\n          rowVariants({ size, interactive: clickable, active, className }),\n        )}\n        {...(props as React.HTMLAttributes<HTMLButtonElement>)}\n      >\n        {chevron}\n        {icon && <span className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\">{icon}</span>}\n        <span className=\"min-w-0 flex-1 truncate\">{label}</span>\n        {trailing && (\n          <span className=\"flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground\">\n            {trailing}\n          </span>\n        )}\n      </Tag>\n    );\n  },\n);\nRow.displayName = 'Row';\n\nexport { rowVariants };\n"
  },
  {
    "path": "app/renderer/src/components/ui/select.tsx",
    "content": "import * as React from 'react';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react';\nimport { cn } from '@/lib/utils';\n\nexport const Select = SelectPrimitive.Root;\nexport const SelectGroup = SelectPrimitive.Group;\nexport const SelectValue = SelectPrimitive.Value;\n\nexport const SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'flex h-9 w-full items-center justify-between rounded-md border border-border bg-transparent px-3 py-1 text-sm transition-colors duration-fast ease-steno focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',\n      className,\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n));\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn('flex cursor-default items-center justify-center py-1', className)}\n    {...props}\n  >\n    <ChevronUp className=\"size-4\" />\n  </SelectPrimitive.ScrollUpButton>\n));\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn('flex cursor-default items-center justify-center py-1', className)}\n    {...props}\n  >\n    <ChevronDown className=\"size-4\" />\n  </SelectPrimitive.ScrollDownButton>\n));\nSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;\n\nexport const SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = 'popper', ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      position={position}\n      className={cn(\n        'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg',\n        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n        position === 'popper' &&\n          'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',\n        className,\n      )}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          'p-1',\n          position === 'popper' &&\n            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n));\nSelectContent.displayName = SelectPrimitive.Content.displayName;\n\nexport const SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { description?: string }\n>(({ className, children, description, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex w-full cursor-default select-none items-center rounded py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex size-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"size-3.5\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <div className=\"flex flex-col\">\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n      {description && (\n        <span className=\"text-xs text-muted-foreground\">{description}</span>\n      )}\n    </div>\n  </SelectPrimitive.Item>\n));\nSelectItem.displayName = SelectPrimitive.Item.displayName;\n\nexport const SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', className)}\n    {...props}\n  />\n));\nSelectLabel.displayName = SelectPrimitive.Label.displayName;\n\nexport const SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-border', className)}\n    {...props}\n  />\n));\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName;\n"
  },
  {
    "path": "app/renderer/src/components/ui/switch.tsx",
    "content": "import * as React from 'react';\nimport * as SwitchPrimitive from '@radix-ui/react-switch';\nimport { cn } from '@/lib/utils';\n\nexport const Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitive.Root\n    ref={ref}\n    className={cn(\n      'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors duration-fast ease-steno focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted',\n      className,\n    )}\n    {...props}\n  >\n    <SwitchPrimitive.Thumb\n      className={cn(\n        'pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0 transition-transform duration-fast ease-steno data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5',\n      )}\n    />\n  </SwitchPrimitive.Root>\n));\nSwitch.displayName = SwitchPrimitive.Root.displayName;\n"
  },
  {
    "path": "app/renderer/src/components/ui/tabs.tsx",
    "content": "import * as React from 'react';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport { cn } from '@/lib/utils';\n\nexport const Tabs = TabsPrimitive.Root;\n\nexport const TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      'inline-flex h-9 items-center gap-1 rounded-md bg-muted p-1 text-muted-foreground',\n      className,\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nexport const TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'inline-flex items-center justify-center whitespace-nowrap rounded px-3 py-1 text-xs font-medium transition-colors duration-fast ease-steno focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n      className,\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nexport const TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn('mt-4 focus-visible:outline-none', className)}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n"
  },
  {
    "path": "app/renderer/src/components/ui/tooltip.tsx",
    "content": "import * as React from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\nimport { cn } from '@/lib/utils';\n\nfunction TooltipProvider({ delayDuration = 120, ...props }: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) {\n  return <TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />;\n}\nconst Tooltip = TooltipPrimitive.Root;\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Portal>\n    <TooltipPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1 text-xs text-background shadow-md',\n        'animate-in fade-in-0 zoom-in-95',\n        'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n        'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',\n        'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        className,\n      )}\n      {...props}\n    />\n  </TooltipPrimitive.Portal>\n));\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "app/renderer/src/components/ui/typography.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils';\n\ntype HProps = React.HTMLAttributes<HTMLHeadingElement>;\n\n// Merge default fontVariationSettings with caller-supplied style so a\n// downstream style={{ color: 'red' }} doesn't drop the variation defaults.\nconst DISPLAY_VAR = \"'opsz' 144, 'SOFT' 30\";\nconst H2_VAR = \"'opsz' 96\";\n\nexport function Display({ className, style, ...props }: HProps) {\n  return (\n    <h1\n      className={cn(\n        'font-serif text-3xl leading-[1.05] tracking-[-0.02em]',\n        className\n      )}\n      style={{ fontVariationSettings: DISPLAY_VAR, ...style }}\n      {...props}\n    />\n  );\n}\n\nexport function H1({ className, style, ...props }: HProps) {\n  return (\n    <h1\n      className={cn(\n        'font-serif text-2xl leading-[1.1] tracking-[-0.02em]',\n        className\n      )}\n      style={{ fontVariationSettings: DISPLAY_VAR, ...style }}\n      {...props}\n    />\n  );\n}\n\nexport function H2({ className, style, ...props }: HProps) {\n  return (\n    <h2\n      className={cn(\n        'font-serif text-xl leading-[1.25] tracking-[-0.01em]',\n        className\n      )}\n      style={{ fontVariationSettings: H2_VAR, ...style }}\n      {...props}\n    />\n  );\n}\n\nexport function H3({ className, ...props }: HProps) {\n  return (\n    <h3\n      className={cn('font-sans text-lg font-medium leading-[1.3]', className)}\n      {...props}\n    />\n  );\n}\n\nexport function Lead({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLParagraphElement>) {\n  return (\n    <p\n      className={cn('text-md text-muted-foreground leading-[1.55]', className)}\n      {...props}\n    />\n  );\n}\n\nexport function Muted({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLParagraphElement>) {\n  return <p className={cn('text-sm text-muted-foreground', className)} {...props} />;\n}\n"
  },
  {
    "path": "app/renderer/src/globals.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600&family=JetBrains+Mono:wght@400;500&display=swap');\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* =============================================================================\n   Design tokens — paper + ink palette\n   Source: .context/design/mac-app/project/steno-app/colors_and_type.css\n   =============================================================================*/\n\n@layer base {\n  :root {\n    /* Raw neutrals */\n    --paper-0: #FAF9F5;\n    --paper-1: #F5F3EC;\n    --paper-2: #EFEBE1;\n    --paper-3: #E5DFD1;\n\n    --ink-900: #1B1B19;\n    --ink-700: #3D3D39;\n    --ink-500: #6B6B66;\n    --ink-300: #A8A8A0;\n    --ink-100: #D6D4CB;\n\n    /* Semantic status */\n    --red-600: #B84A3A;\n    --red-50: #F5E3DE;\n    --green-600: #4F7A5B;\n    --green-50: #E0EAE0;\n\n    /* Semantic surfaces */\n    --page: var(--paper-0);\n    --surface: var(--paper-0);\n    --surface-raised: #FFFFFF;\n    --surface-sunken: var(--paper-1);\n    --surface-hover: var(--paper-1);\n    --surface-active: var(--paper-2);\n    --surface-translucent: rgba(250, 249, 245, 0.82);\n\n    /* Text */\n    --fg-1: var(--ink-900);\n    --fg-2: var(--ink-500);\n    --fg-muted: var(--ink-300);\n    --fg-inverse: var(--paper-0);\n\n    /* Borders (rare — prefer whitespace) */\n    --border-subtle: rgba(27, 27, 25, 0.06);\n    --border-strong: rgba(27, 27, 25, 0.22);\n\n    /* Interactive */\n    --primary-hover: var(--ink-700);\n    --primary-fg: var(--paper-0);\n    --focus-ring: rgba(27, 27, 25, 0.35);\n\n    --danger: var(--red-600);\n    --danger-bg: var(--red-50);\n    --success: var(--green-600);\n    --success-bg: var(--green-50);\n    --recording: var(--red-600);\n\n    /* Elevation */\n    --shadow-sm: 0 1px 2px rgba(27, 27, 25, 0.05);\n    --shadow-md: 0 8px 24px -8px rgba(27, 27, 25, 0.14), 0 2px 4px -2px rgba(27, 27, 25, 0.06);\n    --shadow-lg: 0 24px 48px -16px rgba(27, 27, 25, 0.22), 0 4px 8px -4px rgba(27, 27, 25, 0.08);\n\n    /* Radii */\n    --radius-xs: 4px;\n    --radius-sm: 6px;\n    --radius: 8px;\n    --radius-md: 10px;\n    --radius-lg: 14px;\n    --radius-xl: 20px;\n\n    /* Spacing (8px grid) */\n    --sp-1: 4px;\n    --sp-2: 8px;\n    --sp-3: 12px;\n    --sp-4: 16px;\n    --sp-5: 24px;\n    --sp-6: 32px;\n    --sp-7: 48px;\n    --sp-8: 64px;\n\n    /* Motion */\n    --ease: cubic-bezier(0.2, 0, 0, 1);\n    --dur-fast: 120ms;\n    --dur: 200ms;\n    --dur-slow: 320ms;\n\n    /* Type */\n    --font-serif: 'Charter', 'Bitstream Charter', 'Sitka Text', 'Iowan Old Style', Cambria, Georgia, serif;\n    --font-sans: 'Inter', -apple-system, 'Segoe UI', 'Helvetica Neue', sans-serif;\n    --font-mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;\n\n    --fs-xs: 12px;\n    --fs-sm: 14px;\n    --fs-base: 15px;\n    --fs-md: 17px;\n    --fs-lg: 22px;\n    --fs-xl: 30px;\n    --fs-2xl: 44px;\n    --fs-3xl: 64px;\n\n    /* Shadcn semantic HSL tokens — point at the design palette so shadcn\n       primitives (Button, Input, Popover, etc.) inherit it automatically. */\n    --background: 48 29% 97%;          /* #FAF9F5 */\n    --foreground: 60 4% 10%;           /* #1B1B19 */\n    --card: 48 29% 97%;\n    --card-foreground: 60 4% 10%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 60 4% 10%;\n    --primary: 60 4% 10%;\n    --primary-foreground: 48 29% 97%;\n    --secondary: 44 23% 92%;\n    --secondary-foreground: 60 4% 10%;\n    --muted: 44 23% 92%;\n    --muted-foreground: 60 3% 42%;\n    --accent: 44 23% 92%;\n    --accent-foreground: 60 4% 10%;\n    --destructive: 8 51% 47%;\n    --destructive-foreground: 48 29% 97%;\n    --border: 60 4% 10% / 0.10;\n    --input: 60 4% 10% / 0.10;\n    --ring: 60 4% 10% / 0.35;\n    --accent-primary: 239 84% 67%;\n  }\n\n  .dark,\n  [data-theme=\"dark\"] {\n    --page: #1A1A18;\n    --surface: #1A1A18;\n    --surface-raised: #24241F;\n    --surface-sunken: #14140F;\n    --surface-hover: #242420;\n    --surface-active: #2E2E28;\n    --surface-translucent: rgba(26, 26, 24, 0.78);\n\n    --fg-1: #EDEAE0;\n    --fg-2: #9A968A;\n    --fg-muted: #5D5A52;\n    --fg-inverse: var(--ink-900);\n\n    --border-subtle: rgba(237, 234, 224, 0.06);\n    --border-strong: rgba(237, 234, 224, 0.20);\n\n    --primary-hover: #FFFFFF;\n    --primary-fg: #1A1A18;\n    --focus-ring: rgba(237, 234, 224, 0.45);\n\n    --danger: #D17563;\n    --danger-bg: rgba(209, 117, 99, 0.14);\n    --success: #7DA088;\n    --success-bg: rgba(125, 160, 136, 0.14);\n    --recording: #D17563;\n\n    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);\n    --shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.55), 0 2px 4px -2px rgba(0, 0, 0, 0.3);\n    --shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.6), 0 4px 8px -4px rgba(0, 0, 0, 0.3);\n\n    /* Shadcn dark variants */\n    --background: 60 4% 10%;\n    --foreground: 48 20% 90%;\n    --card: 60 4% 10%;\n    --card-foreground: 48 20% 90%;\n    --popover: 54 7% 14%;\n    --popover-foreground: 48 20% 90%;\n    --primary: 48 20% 90%;\n    --primary-foreground: 60 4% 10%;\n    --secondary: 54 7% 14%;\n    --secondary-foreground: 48 20% 90%;\n    --muted: 54 7% 14%;\n    --muted-foreground: 48 5% 57%;\n    --accent: 54 7% 14%;\n    --accent-foreground: 48 20% 90%;\n    --destructive: 8 51% 60%;\n    --destructive-foreground: 48 20% 90%;\n    --border: 48 20% 90% / 0.10;\n    --input: 48 20% 90% / 0.10;\n    --ring: 48 20% 90% / 0.45;\n    --accent-primary: 239 84% 75%;\n  }\n}\n\n@layer base {\n  * { @apply border-border; }\n\n  body {\n    background: var(--page);\n    color: var(--fg-1);\n    font-family: var(--font-sans);\n    font-size: var(--fs-base);\n    line-height: 1.55;\n    font-feature-settings: 'ss01', 'cv11';\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n\n  h1, h2, .serif {\n    font-family: var(--font-serif);\n    font-weight: 400;\n    letter-spacing: -0.02em;\n  }\n\n  h1 { @apply text-[44px] leading-[1.1]; }\n  h2 { @apply text-[30px] leading-[1.25] tracking-[-0.01em]; }\n  h3 { @apply text-[22px] leading-[1.3] font-medium; }\n\n  code, pre, .mono { font-family: var(--font-mono); }\n\n  /* Tinted accent so the selection is visible against subtle surface tints\n     like the search bars (which sit on a near-paper background where the\n     previous --paper-2 highlight blended in invisibly). */\n  ::selection { background: rgba(99, 102, 241, 0.22); color: var(--fg-1); }\n  .dark ::selection,\n  [data-theme=\"dark\"] ::selection {\n    background: rgba(129, 140, 248, 0.32);\n    color: var(--fg-1);\n  }\n}\n\n/* =============================================================================\n   Keyframes\n   =============================================================================*/\n\n@keyframes record-pulse {\n  0%   { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.55); }\n  70%  { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }\n  100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }\n}\n@keyframes wave {\n  0%, 100% { transform: scaleY(0.6); }\n  50%      { transform: scaleY(1.0); }\n}\n@keyframes lnv-wait {\n  0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }\n  40%           { opacity: 1;   transform: translateY(-2px); }\n}\n@keyframes thinkingBounce {\n  0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }\n  40%            { transform: scale(1);   opacity: 1;   }\n}\n@keyframes titleFadeIn {\n  from { opacity: 0; transform: translateY(-5px); }\n  to   { opacity: 1; transform: translateY(0); }\n}\n\n/* =============================================================================\n   Component layer\n   Structural / stateful shapes from app.css that are awkward in utilities.\n   =============================================================================*/\n\n@layer components {\n  .animate-title-in {\n    animation: titleFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1);\n  }\n\n  .thinking-dot {\n    display: inline-block;\n    width: 5px; height: 5px; border-radius: 50%;\n    background: currentColor;\n    animation: thinkingBounce 1.2s infinite ease-in-out;\n  }\n  .thinking-dot:nth-child(2) { animation-delay: 0.2s; }\n  .thinking-dot:nth-child(3) { animation-delay: 0.4s; }\n\n  /* Record button — pill with recording-state pulse */\n  .record-btn {\n    display: inline-flex; align-items: center; gap: 8px;\n    height: 30px; padding: 0 14px; border-radius: 999px;\n    background: transparent; color: var(--fg-1);\n    font-weight: 500; font-size: 13px; border: 0; cursor: pointer;\n    box-shadow: inset 0 0 0 1px hsl(var(--border));\n    transition: background var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease), filter var(--dur-fast) var(--ease);\n    font-family: var(--font-sans);\n    letter-spacing: -0.005em;\n  }\n  .record-btn:hover {\n    background: var(--surface-hover);\n    box-shadow: inset 0 0 0 1px var(--border-strong);\n  }\n  .record-btn.is-recording {\n    background: var(--recording); color: #fff;\n  }\n  .record-btn.is-recording:hover { filter: brightness(0.96); }\n\n  .record-dot {\n    width: 8px; height: 8px; border-radius: 999px; background: #fff;\n    animation: record-pulse 1.6s ease-out infinite;\n  }\n\n  /* Sidebar top band — traffic-light region */\n  .sb-top {\n    display: flex; align-items: center;\n    padding: 14px 12px 6px 82px;\n    -webkit-app-region: drag;\n    flex-shrink: 0;\n  }\n  .sb-top > * { -webkit-app-region: no-drag; }\n\n  /* Sidebar rows */\n  .sb-row {\n    display: flex; align-items: center; gap: 10px;\n    padding: 6px 10px; border-radius: var(--radius-sm);\n    color: var(--fg-1); font-size: 13px; cursor: pointer;\n    background: transparent; border: 0; text-align: left; width: 100%;\n    font-family: var(--font-sans);\n    transition: background var(--dur-fast) var(--ease);\n    line-height: 1.3;\n  }\n  .sb-row:hover { background: var(--surface-hover); }\n  .sb-row.active {\n    background: var(--surface-raised);\n    box-shadow: 0 1px 2px rgba(27,27,25,0.04), 0 0 0 1px var(--border-subtle);\n    font-weight: 500;\n  }\n  .dark .sb-row.active,\n  [data-theme=\"dark\"] .sb-row.active {\n    background: var(--surface-raised);\n    box-shadow: 0 0 0 1px var(--border-subtle);\n  }\n  .sb-row svg { color: var(--fg-2); flex-shrink: 0; }\n  .sb-row.active svg { color: var(--fg-1); }\n\n  /* Home — serif greeting */\n  .home-hello {\n    font-family: var(--font-serif);\n    font-size: 30px;\n    line-height: 1.1;\n    letter-spacing: -0.025em;\n    color: var(--fg-1);\n    font-weight: 400;\n  }\n  .home-hello .faint { color: var(--fg-2); }\n\n  /* Upcoming card — 3-column grid */\n  .upcoming-card {\n    background: var(--surface-raised);\n    border: 1px solid var(--border-subtle);\n    border-radius: var(--radius-lg);\n    padding: 14px 16px;\n    display: grid;\n    grid-template-columns: 72px 1fr auto;\n    align-items: center;\n    gap: 16px;\n    cursor: pointer;\n    transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);\n  }\n  .upcoming-card:hover {\n    border-color: hsl(var(--border));\n    box-shadow: var(--shadow-sm);\n  }\n  .upcoming-card-live {\n    border-color: var(--border-strong);\n  }\n\n  /* Previous row — grid w/ border-top, Granola style */\n  .previous-row {\n    display: grid;\n    grid-template-columns: 64px 1fr auto;\n    gap: 18px;\n    padding: 14px 16px;\n    border-top: 1px solid var(--border-subtle);\n    cursor: pointer;\n    transition: background var(--dur-fast) var(--ease);\n    align-items: baseline;\n    border-radius: 6px;\n    margin: 0 -16px;\n  }\n  .previous-row:hover { background: var(--surface-hover); }\n\n  /* Meeting view — 40px serif title */\n  .mv-title {\n    font-family: var(--font-serif);\n    font-weight: 400;\n    font-size: 40px;\n    line-height: 1.1;\n    letter-spacing: -0.025em;\n    color: var(--fg-1);\n    margin: 0;\n  }\n\n  /* Meeting view — chip row */\n  .mv-chip {\n    display: inline-flex; align-items: center; gap: 6px;\n    padding: 4px 10px;\n    border-radius: 999px;\n    background: var(--surface-hover);\n    color: var(--fg-2);\n    font-size: 12px;\n    font-family: var(--font-sans);\n    font-weight: 500;\n    border: 0;\n    cursor: pointer;\n    transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);\n    font-variant-numeric: tabular-nums;\n    letter-spacing: -0.003em;\n  }\n  .mv-chip:hover {\n    background: var(--surface-active); color: var(--fg-1);\n  }\n  .mv-chip svg { color: var(--fg-muted); }\n  .mv-chip:hover svg { color: var(--fg-2); }\n  .mv-chip-dashed {\n    background: transparent;\n    color: var(--fg-2);\n    box-shadow: inset 0 0 0 1px hsl(var(--border));\n  }\n  .mv-chip-dashed:hover {\n    background: var(--surface-hover);\n    box-shadow: inset 0 0 0 1px hsl(var(--border));\n  }\n\n  /* Meeting view — bullet list */\n  .mv-bullets {\n    list-style: none; padding: 0; margin: 0;\n    display: flex; flex-direction: column; gap: 8px;\n  }\n  .mv-bullets li {\n    position: relative; padding-left: 18px;\n    font-size: 14.5px; line-height: 1.55;\n    color: var(--fg-1); max-width: 64ch;\n  }\n  .mv-bullets li::before {\n    content: \"\"; position: absolute; left: 4px; top: 10px;\n    width: 4px; height: 4px; border-radius: 999px;\n    background: var(--fg-2);\n  }\n\n  /* Meeting view — topics */\n  .mv-topics {\n    display: flex; flex-direction: column; gap: 18px;\n  }\n  .mv-topic-title {\n    font-size: 14.5px; font-weight: 600; color: var(--fg-1);\n    letter-spacing: -0.005em;\n  }\n  .mv-topic-body {\n    font-size: 14px; line-height: 1.6; color: var(--fg-2);\n    max-width: 64ch;\n  }\n\n  /* Clean scrollbar (used by main + sidebar) */\n  .scrollbar-clean::-webkit-scrollbar { width: 10px; height: 10px; }\n  .scrollbar-clean::-webkit-scrollbar-track { background: transparent; }\n  .scrollbar-clean::-webkit-scrollbar-thumb {\n    background: transparent; border-radius: 999px;\n  }\n  .scrollbar-clean:hover::-webkit-scrollbar-thumb {\n    background: rgba(27, 27, 25, 0.12);\n  }\n  .dark .scrollbar-clean:hover::-webkit-scrollbar-thumb,\n  [data-theme=\"dark\"] .scrollbar-clean:hover::-webkit-scrollbar-thumb {\n    background: rgba(237, 234, 224, 0.14);\n  }\n\n  /* ==========================================================================\n     Dock — transcript disclosure + chat composer\n     ========================================================================== */\n\n  .mv-dock {\n    position: absolute;\n    left: 0; right: 0; bottom: 0;\n    padding-bottom: 16px;\n    pointer-events: none;\n    z-index: 40;\n  }\n  .mv-dock::before {\n    content: \"\";\n    position: absolute;\n    inset: -80px 0 0 0;\n    background: linear-gradient(to bottom, rgba(250, 249, 245, 0) 0%, rgba(250, 249, 245, 0.85) 45%, var(--page) 70%);\n    pointer-events: none;\n  }\n  .dark .mv-dock::before,\n  [data-theme=\"dark\"] .mv-dock::before {\n    background: linear-gradient(to bottom, rgba(26, 26, 24, 0) 0%, rgba(26, 26, 24, 0.85) 45%, var(--page) 70%);\n  }\n  .mv-dock-inner {\n    pointer-events: auto;\n    position: relative;\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n  }\n\n  /* Transcript disclosure */\n  .mv-transcript {\n    background: color-mix(in srgb, var(--surface-raised) 88%, transparent);\n    border: 1px solid var(--border-subtle);\n    border-radius: var(--radius-lg);\n    overflow: hidden;\n    box-shadow: var(--shadow-sm);\n    backdrop-filter: saturate(160%) blur(10px);\n    -webkit-backdrop-filter: saturate(160%) blur(10px);\n    transition: box-shadow var(--dur) var(--ease);\n  }\n  .mv-transcript.open {\n    box-shadow: var(--shadow-md);\n    animation: askChatIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);\n  }\n\n  @keyframes askChatIn {\n    from { opacity: 0; transform: translateY(10px) scale(0.98); }\n    to   { opacity: 1; transform: translateY(0) scale(1); }\n  }\n\n  .mv-transcript-head {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n    padding: 10px 14px;\n    width: 100%;\n    background: transparent;\n    border: 0;\n    cursor: pointer;\n    font-family: var(--font-sans);\n    text-align: left;\n    color: var(--fg-1);\n    transition: background var(--dur-fast) var(--ease);\n  }\n  .mv-transcript-head:hover { background: var(--surface-hover); }\n\n  .mv-transcript-label {\n    flex: 1;\n    font-size: 13px;\n    font-weight: 500;\n    color: var(--fg-1);\n    display: flex;\n    align-items: baseline;\n    gap: 8px;\n  }\n  .mv-transcript-head > svg { color: var(--fg-2); }\n\n  .mv-transcript-wave {\n    display: inline-flex;\n    align-items: flex-end;\n    gap: 2px;\n    height: 16px;\n    width: 24px;\n    flex-shrink: 0;\n  }\n  .mv-transcript-wave span {\n    display: block;\n    width: 2px;\n    background: var(--fg-2);\n    border-radius: 2px;\n    animation: dock-wave 1.4s ease-in-out infinite;\n  }\n  /* Static variant — used in the meeting transcript panel header where the\n     animation reads as \"currently recording\" and confuses users. */\n  .mv-transcript-wave-static span {\n    animation: none;\n  }\n  .mv-transcript-wave span:nth-child(1) { height: 40%; animation-delay: 0s; }\n  .mv-transcript-wave span:nth-child(2) { height: 70%; animation-delay: 0.1s; }\n  .mv-transcript-wave span:nth-child(3) { height: 100%; animation-delay: 0.2s; }\n  .mv-transcript-wave span:nth-child(4) { height: 60%; animation-delay: 0.3s; }\n  .mv-transcript-wave span:nth-child(5) { height: 90%; animation-delay: 0.15s; }\n  .mv-transcript-wave span:nth-child(6) { height: 50%; animation-delay: 0.25s; }\n  .mv-transcript-wave span:nth-child(7) { height: 30%; animation-delay: 0.05s; }\n\n  @keyframes dock-wave {\n    0%, 100% { transform: scaleY(0.6); }\n    50%       { transform: scaleY(1.0); }\n  }\n\n  .mv-transcript-body {\n    max-height: 240px;\n    overflow-y: auto;\n    padding: 4px 14px 14px;\n    border-top: 1px solid var(--border-subtle);\n    display: flex;\n    flex-direction: column;\n    gap: 0;\n  }\n  .mv-t-line {\n    display: flex;\n    gap: 10px;\n    padding: 8px 0;\n    border-bottom: 1px solid var(--border-subtle);\n  }\n  .mv-t-line:last-child { border-bottom: 0; }\n  .mv-t-speaker {\n    font-size: 11px;\n    font-weight: 600;\n    color: var(--fg-2);\n    flex-shrink: 0;\n    padding-top: 2px;\n    width: 44px;\n  }\n  .mv-t-text {\n    font-size: 13px;\n    line-height: 1.55;\n    color: var(--fg-1);\n    flex: 1;\n  }\n\n  /* Chat bubble — trim leading/trailing margins from rendered markdown so\n     the inner padding stays balanced regardless of which block element\n     starts/ends the response (paragraph, heading, list…). */\n  .chat-bubble > :first-child { margin-top: 0; }\n  .chat-bubble > :last-child { margin-bottom: 0; }\n\n  /* Chat composer */\n  .mv-chat {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    padding: 8px 8px 8px 12px;\n    background: var(--surface-raised);\n    border: 1px solid var(--border-subtle);\n    border-radius: 14px;\n    box-shadow: var(--shadow-sm);\n  }\n  .mv-chat-input {\n    flex: 1;\n    min-width: 0;\n    height: 32px;\n    padding: 0 8px;\n    border: 0;\n    background: transparent;\n    color: var(--fg-1);\n    font-size: 14px;\n    font-weight: 500;\n    font-family: var(--font-sans);\n    outline: none;\n  }\n  .mv-chat-input::placeholder { color: var(--fg-2); font-weight: 500; }\n\n  .mv-chat-tool {\n    width: 30px; height: 30px;\n    border-radius: 8px;\n    background: transparent;\n    border: 0;\n    color: var(--fg-2);\n    cursor: pointer;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n    transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);\n  }\n  .mv-chat-tool:hover { background: var(--surface-hover); color: var(--fg-1); }\n  .mv-chat-tool.active { color: var(--fg-1); }\n\n  .mv-chat-send {\n    width: 30px; height: 30px;\n    border-radius: 999px;\n    background: var(--surface-active);\n    border: 0;\n    color: var(--fg-2);\n    cursor: pointer;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n    transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);\n  }\n  .mv-chat-send.active {\n    background: var(--fg-1);\n    color: var(--primary-fg);\n  }\n  .mv-chat-send:disabled { cursor: default; }\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/index.ts",
    "content": "export * from './useMeetings';\nexport * from './useFolders';\nexport * from './useRecording';\nexport * from './useStreamingQuery';\nexport * from './useModels';\nexport * from './useAiPrompts';\nexport * from './useCalendarEvents';\nexport * from './useTheme';\nexport * from './useSettings';\nexport * from './useAi';\nexport * from './useSetup';\nexport * from './useChatSessions';\n"
  },
  {
    "path": "app/renderer/src/hooks/liveDraftStore.ts",
    "content": "import { create } from 'zustand';\n\n/**\n * In-memory draft state for the in-progress recording. Title lives only in\n * memory until the recording finishes (we apply the rename on\n * processingComplete via useUpdateMeeting). Notes are mirrored to disk via\n * `save-meeting-notes` on a 500ms debounce in useLiveMeeting.\n *\n * Keyed by sessionName so a quick stop+start with a different name doesn't\n * leak the previous title.\n */\n\nexport interface DraftEntry {\n  title: string;\n  notes: string;\n  startedAtMs: number;\n}\n\ninterface LiveDraftStore {\n  drafts: Record<string, DraftEntry>;\n  ensure: (sessionName: string, defaults: { startedAtMs: number }) => void;\n  setTitle: (sessionName: string, title: string) => void;\n  setNotes: (sessionName: string, notes: string) => void;\n}\n\nexport const useLiveDraftStore = create<LiveDraftStore>((set) => ({\n  drafts: {},\n  ensure: (sessionName, defaults) =>\n    set((state) => {\n      if (state.drafts[sessionName]) return state;\n      return {\n        drafts: {\n          ...state.drafts,\n          [sessionName]: {\n            title: sessionName,\n            notes: '',\n            startedAtMs: defaults.startedAtMs,\n          },\n        },\n      };\n    }),\n  setTitle: (sessionName, title) =>\n    set((state) => {\n      const existing = state.drafts[sessionName];\n      if (!existing) return state;\n      return {\n        drafts: { ...state.drafts, [sessionName]: { ...existing, title } },\n      };\n    }),\n  setNotes: (sessionName, notes) =>\n    set((state) => {\n      const existing = state.drafts[sessionName];\n      if (!existing) return state;\n      return {\n        drafts: { ...state.drafts, [sessionName]: { ...existing, notes } },\n      };\n    }),\n}));\n\n/** Non-hook accessor for the current draft (use inside event handlers). */\nexport function getLiveDraft(sessionName: string): DraftEntry | undefined {\n  return useLiveDraftStore.getState().drafts[sessionName];\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/meetingKeys.ts",
    "content": "export const meetingsKeys = {\n  all: ['meetings'] as const,\n  list: () => [...meetingsKeys.all, 'list'] as const,\n};\n"
  },
  {
    "path": "app/renderer/src/hooks/useAi.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\nimport type { AiProvider, CloudProvider } from '@/lib/ipc';\nimport { modelsKeys } from '@/hooks/useModels';\n\nexport const aiKeys = {\n  all: ['ai'] as const,\n  provider: () => [...aiKeys.all, 'provider'] as const,\n};\n\nexport function useAiProvider() {\n  return useQuery({\n    queryKey: aiKeys.provider(),\n    queryFn: async () => unwrap(await ipc().ai.getProvider()),\n  });\n}\n\nexport function useSetAiProvider() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (p: AiProvider) => unwrap(await ipc().ai.setProvider(p)),\n    onMutate: async (p) => {\n      await qc.cancelQueries({ queryKey: aiKeys.provider() });\n      const previous = qc.getQueryData(aiKeys.provider());\n      qc.setQueryData(aiKeys.provider(), (old: Record<string, unknown> | undefined) => ({\n        ...old,\n        ai_provider: p,\n      }));\n      return { previous };\n    },\n    onError: (_err, _p, ctx) => {\n      if (ctx?.previous !== undefined) qc.setQueryData(aiKeys.provider(), ctx.previous);\n    },\n    onSettled: () => {\n      qc.invalidateQueries({ queryKey: aiKeys.provider() });\n      qc.invalidateQueries({ queryKey: modelsKeys.list() });\n    },\n  });\n}\n\nexport function useSetRemoteOllamaUrl() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (url: string) => unwrap(await ipc().ai.setRemoteOllamaUrl(url)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: aiKeys.provider() }),\n  });\n}\n\nexport function useTestRemoteOllama() {\n  return useMutation({\n    mutationFn: async (url: string) => unwrap(await ipc().ai.testRemoteOllama(url)),\n  });\n}\n\nexport function useSetCloudApiUrl() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (url: string) => unwrap(await ipc().ai.setCloudApiUrl(url)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: aiKeys.provider() }),\n  });\n}\n\nexport function useSetCloudApiKey() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (key: string) => unwrap(await ipc().ai.setCloudApiKey(key)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: aiKeys.provider() }),\n  });\n}\n\nexport function useSetCloudProvider() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (p: CloudProvider) => unwrap(await ipc().ai.setCloudProvider(p)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: aiKeys.provider() }),\n  });\n}\n\nexport function useSetCloudModel() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (m: string) => unwrap(await ipc().ai.setCloudModel(m)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: aiKeys.provider() }),\n  });\n}\n\nexport function useTestCloudApi() {\n  return useMutation({\n    mutationFn: async () => unwrap(await ipc().ai.testCloudApi()),\n  });\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useAiPrompts.ts",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\n\nexport function useAiPrompts() {\n  return useQuery({\n    queryKey: ['ai-prompts'],\n    queryFn: async () => unwrap(await ipc().settings.getAiPrompts()),\n  });\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useAudioLevel.ts",
    "content": "import * as React from 'react';\n\ninterface UseAudioLevelOptions {\n  /** When false, the hook tears down the audio graph and returns zeros. */\n  enabled: boolean;\n  /** Number of bars to produce. */\n  bars: number;\n  /** Smoothing 0..1 (higher = more inertia). Default 0.55. */\n  smoothing?: number;\n  /** Min visible amplitude (0..1) so silent bars don't fully collapse. */\n  floor?: number;\n}\n\n/**\n * Reads the renderer's microphone via getUserMedia and returns N normalized\n * frequency levels (0..1) sampled at ~20 Hz. Works alongside the Python\n * recording subprocess — macOS allows multiple readers on the same input\n * device.\n *\n * Permission errors are swallowed silently; the returned array stays at the\n * floor value so the UI degrades to a flat shimmer.\n */\nexport function useAudioLevel({\n  enabled,\n  bars,\n  smoothing = 0.55,\n  floor = 0.05,\n}: UseAudioLevelOptions): number[] {\n  const [levels, setLevels] = React.useState<number[]>(() =>\n    new Array(bars).fill(floor),\n  );\n\n  React.useEffect(() => {\n    if (!enabled) {\n      setLevels(new Array(bars).fill(floor));\n      return;\n    }\n\n    let cancelled = false;\n    let stream: MediaStream | null = null;\n    let ctx: AudioContext | null = null;\n    let analyser: AnalyserNode | null = null;\n    let raf: number | null = null;\n    let lastUpdate = 0;\n    const smoothed = new Array(bars).fill(floor);\n\n    void (async () => {\n      try {\n        stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n        if (cancelled) {\n          stream.getTracks().forEach((t) => t.stop());\n          return;\n        }\n        ctx = new AudioContext();\n        const src = ctx.createMediaStreamSource(stream);\n        analyser = ctx.createAnalyser();\n        analyser.fftSize = 64;\n        analyser.smoothingTimeConstant = 0.4;\n        src.connect(analyser);\n\n        const data = new Uint8Array(analyser.frequencyBinCount);\n\n        const tick = (now: number) => {\n          if (cancelled || !analyser) return;\n          // Throttle React updates to ~20 Hz.\n          if (now - lastUpdate >= 50) {\n            lastUpdate = now;\n            analyser.getByteFrequencyData(data);\n            // Sample evenly across the lower-mid range (skip top bins —\n            // mostly noise above ~6 kHz for speech).\n            const usable = Math.floor(data.length * 0.7);\n            const next = new Array(bars);\n            for (let i = 0; i < bars; i++) {\n              const idx = Math.floor((i * usable) / bars);\n              const v = data[idx] / 255;\n              const prev = smoothed[i];\n              const blended = prev * smoothing + v * (1 - smoothing);\n              smoothed[i] = blended;\n              next[i] = Math.max(floor, blended);\n            }\n            setLevels(next);\n          }\n          raf = requestAnimationFrame(tick);\n        };\n        raf = requestAnimationFrame(tick);\n      } catch {\n        // Permission denied / no device — leave levels at floor.\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n      if (raf !== null) cancelAnimationFrame(raf);\n      if (analyser) analyser.disconnect();\n      if (ctx) ctx.close().catch(() => {});\n      if (stream) stream.getTracks().forEach((t) => t.stop());\n    };\n  }, [enabled, bars, smoothing, floor]);\n\n  return levels;\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useCalendarEvents.ts",
    "content": "import * as React from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc, type CalendarEvent } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\n\nexport type CalendarState =\n  | { needsAuth: false; events: CalendarEvent[] }\n  | { needsAuth: true; events: [] };\n\nexport const calendarKeys = {\n  all: ['calendar'] as const,\n  events: () => [...calendarKeys.all, 'events'] as const,\n  google: () => [...calendarKeys.all, 'google'] as const,\n  outlook: () => [...calendarKeys.all, 'outlook'] as const,\n};\n\nexport function useCalendarEvents() {\n  const qc = useQueryClient();\n\n  React.useEffect(() => {\n    const off = [\n      ipc().on.googleAuthChanged(() => {\n        qc.invalidateQueries({ queryKey: calendarKeys.all });\n      }),\n      ipc().on.outlookAuthChanged(() => {\n        qc.invalidateQueries({ queryKey: calendarKeys.all });\n      }),\n    ];\n    return () => off.forEach((fn) => fn());\n  }, [qc]);\n\n  return useQuery<CalendarState>({\n    queryKey: calendarKeys.events(),\n    queryFn: async (): Promise<CalendarState> => {\n      const res = await ipc().calendar.getEvents();\n      if (res.success) return { needsAuth: false, events: res.events };\n      if ('needsAuth' in res) return { needsAuth: true, events: [] };\n      throw new Error(res.error);\n    },\n  });\n}\n\nexport function useGoogleCalendarAuth() {\n  const qc = useQueryClient();\n  const status = useQuery({\n    queryKey: calendarKeys.google(),\n    queryFn: async () => {\n      const res = await ipc().calendar.google.status();\n      if (!res.success) throw new Error(res.error);\n      return { connected: res.connected };\n    },\n  });\n  const connect = useMutation({\n    mutationFn: async () => unwrap(await ipc().calendar.google.connect()),\n    onSuccess: () => qc.invalidateQueries({ queryKey: calendarKeys.all }),\n  });\n  const disconnect = useMutation({\n    mutationFn: async () => unwrap(await ipc().calendar.google.disconnect()),\n    onSuccess: () => qc.invalidateQueries({ queryKey: calendarKeys.all }),\n  });\n  return { status, connect, disconnect };\n}\n\nexport function useOutlookCalendarAuth() {\n  const qc = useQueryClient();\n  const status = useQuery({\n    queryKey: calendarKeys.outlook(),\n    queryFn: async () => {\n      const res = await ipc().calendar.outlook.status();\n      if (!res.success) throw new Error(res.error);\n      return { connected: res.connected };\n    },\n  });\n  const connect = useMutation({\n    mutationFn: async () => unwrap(await ipc().calendar.outlook.connect()),\n    onSuccess: () => qc.invalidateQueries({ queryKey: calendarKeys.all }),\n  });\n  const disconnect = useMutation({\n    mutationFn: async () => unwrap(await ipc().calendar.outlook.disconnect()),\n    onSuccess: () => qc.invalidateQueries({ queryKey: calendarKeys.all }),\n  });\n  return { status, connect, disconnect };\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useChatSessions.ts",
    "content": "import * as React from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc, type ChatSessionsBlob } from '@/lib/ipc';\n\nexport type ChatSession = ChatSessionsBlob['sessions'][number];\nexport type ChatMessage = ChatSession['messages'][number];\n\nfunction newSessionId() {\n  return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction emptyBlob(): ChatSessionsBlob {\n  return { sessions: [] };\n}\n\nconst CHAT_KEY = ['chat-sessions'] as const;\n\n// Legacy format written by the old renderer:\n// [[meetingName, OldSession[]], ...]\ntype LegacyMessage = { role: 'user' | 'ai'; content: string };\ntype LegacySession = { id: string; title: string; messages: LegacyMessage[]; pending: boolean };\ntype LegacyBlob = [meetingName: string, sessions: LegacySession[]][];\n\nfunction migrateLegacyBlob(legacy: LegacyBlob): ChatSessionsBlob {\n  const sessions: ChatSession[] = legacy.flatMap(([meetingName, oldSessions]) =>\n    oldSessions.map((s) => {\n      const ts = Number(s.id) || Date.now();\n      return {\n        id: s.id,\n        name: meetingName ? `${meetingName} — ${s.title}` : s.title,\n        messages: s.messages.map((m) => ({\n          role: m.role === 'ai' ? ('assistant' as const) : ('user' as const),\n          content: m.content,\n          ts,\n        })),\n        createdAt: ts,\n        updatedAt: ts,\n      };\n    }),\n  );\n  return { sessions };\n}\n\n/**\n * Returns the full chat blob (every session across every meeting). Used by\n * the Chat tab's Recents list and any future global chat-history view.\n * Shares the same query key as useChatSessions so a save in one place is\n * reflected everywhere.\n */\nexport function useAllChatSessions() {\n  return useQuery<ChatSessionsBlob>({\n    queryKey: CHAT_KEY,\n    queryFn: async () => {\n      const res = await ipc().chat.load();\n      if (!res.success) throw new Error(res.error);\n      const data = res.data;\n      if (!data) return emptyBlob();\n      if (Array.isArray(data.sessions)) return data;\n      if (Array.isArray(data)) return migrateLegacyBlob(data as unknown as LegacyBlob);\n      return emptyBlob();\n    },\n    staleTime: Infinity,\n  });\n}\n\nexport function useChatSessions(summaryFile: string | null, meetingName?: string | null) {\n  const queryClient = useQueryClient();\n  const [activeId, setActiveId] = React.useState<string | null>(null);\n\n  const query = useQuery<ChatSessionsBlob>({\n    queryKey: CHAT_KEY,\n    queryFn: async () => {\n      const res = await ipc().chat.load();\n      if (!res.success) throw new Error(res.error);\n      const data = res.data;\n      if (!data) return emptyBlob();\n      if (Array.isArray(data.sessions)) return data;\n      // Migrate legacy format: [[meetingName, OldSession[]], ...]\n      // Old sessions have {id, title, messages: [{role:'user'|'ai', content}], pending}\n      if (Array.isArray(data)) return migrateLegacyBlob(data as unknown as LegacyBlob);\n      return emptyBlob();\n    },\n    staleTime: Infinity,\n  });\n\n  const blob = query.data ?? emptyBlob();\n\n  // Expose sessions that belong to this meeting. Legacy sessions migrated from\n  // the old renderer don't have a summaryFile (the legacy format only tracked\n  // meeting names), so we best-effort match them via the \"meetingName — title\"\n  // prefix the migration writes into the session name. Sessions that can't be\n  // associated with any meeting stay in the blob but aren't surfaced here, so\n  // they don't leak across meetings.\n  const meetingSessions = React.useMemo(() => {\n    if (!summaryFile) return [];\n    const matched = blob.sessions.filter((s) => s.summaryFile === summaryFile);\n    if (!meetingName) return matched;\n    const legacyPrefix = `${meetingName} — `;\n    const legacyMatches = blob.sessions.filter(\n      (s) => !s.summaryFile && s.name.startsWith(legacyPrefix),\n    );\n    return [...matched, ...legacyMatches];\n  }, [blob.sessions, summaryFile, meetingName]);\n\n  // Always read the freshest blob from the cache so that rapid-fire mutations\n  // (createSession → appendMessage in the same tick) don't clobber each other\n  // via stale closures.\n  const readLatest = React.useCallback((): ChatSessionsBlob => {\n    return queryClient.getQueryData<ChatSessionsBlob>(CHAT_KEY) ?? emptyBlob();\n  }, [queryClient]);\n\n  // When the meeting changes, restore the most recently updated session for\n  // that meeting (so returning to a note shows your previous chat).\n  React.useEffect(() => {\n    if (!summaryFile) {\n      setActiveId(null);\n      return;\n    }\n    const sorted = readLatest()\n      .sessions.filter((s) => s.summaryFile === summaryFile)\n      .sort((a, b) => b.updatedAt - a.updatedAt);\n    setActiveId(sorted[0]?.id ?? null);\n  }, [summaryFile, readLatest]);\n\n  const persist = React.useCallback(\n    async (next: ChatSessionsBlob) => {\n      const previous = queryClient.getQueryData<ChatSessionsBlob>(CHAT_KEY);\n      queryClient.setQueryData(CHAT_KEY, next);\n      const res = await ipc().chat.save(next);\n      if (!res.success) {\n        // Rollback so the cache and disk stay in sync, and surface the error\n        // to callers instead of swallowing the failure.\n        queryClient.setQueryData(CHAT_KEY, previous);\n        throw new Error(res.error || 'Failed to save chat sessions');\n      }\n    },\n    [queryClient],\n  );\n\n  const activeSession = React.useMemo(\n    () => meetingSessions.find((s) => s.id === activeId) ?? null,\n    [meetingSessions, activeId],\n  );\n\n  const createSession = React.useCallback(\n    async (name?: string) => {\n      const now = Date.now();\n      const session: ChatSession = {\n        id: newSessionId(),\n        name: name ?? 'New chat',\n        ...(summaryFile ? { summaryFile } : {}),\n        messages: [],\n        createdAt: now,\n        updatedAt: now,\n      };\n      const current = readLatest();\n      await persist({ sessions: [session, ...current.sessions] });\n      setActiveId(session.id);\n      return session.id;\n    },\n    [persist, readLatest, summaryFile],\n  );\n\n  const appendMessage = React.useCallback(\n    async (sessionId: string, message: ChatMessage) => {\n      const current = readLatest();\n      await persist({\n        sessions: current.sessions.map((s) =>\n          s.id === sessionId\n            ? {\n                ...s,\n                messages: [...s.messages, message],\n                updatedAt: Date.now(),\n              }\n            : s,\n        ),\n      });\n    },\n    [persist, readLatest],\n  );\n\n  const renameSession = React.useCallback(\n    async (sessionId: string, name: string) => {\n      const current = readLatest();\n      await persist({\n        sessions: current.sessions.map((s) =>\n          s.id === sessionId ? { ...s, name, updatedAt: Date.now() } : s,\n        ),\n      });\n    },\n    [persist, readLatest],\n  );\n\n  const deleteSession = React.useCallback(\n    async (sessionId: string) => {\n      const current = readLatest();\n      if (activeId === sessionId) setActiveId(null);\n      await persist({\n        sessions: current.sessions.filter((s) => s.id !== sessionId),\n      });\n    },\n    [persist, readLatest, activeId],\n  );\n\n  return {\n    sessions: meetingSessions,\n    activeId,\n    activeSession,\n    setActiveId,\n    createSession,\n    appendMessage,\n    renameSession,\n    deleteSession,\n    isLoading: query.isLoading,\n  };\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useFolders.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\nimport { meetingsKeys } from './useMeetings';\n\nexport const foldersKeys = {\n  all: ['folders'] as const,\n  list: () => [...foldersKeys.all, 'list'] as const,\n};\n\nexport function useFolders() {\n  return useQuery({\n    queryKey: foldersKeys.list(),\n    queryFn: async () => unwrap(await ipc().folders.list()).folders,\n  });\n}\n\nexport function useCreateFolder() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { name: string; color?: string }) =>\n      unwrap(await ipc().folders.create(args.name, args.color)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: foldersKeys.all }),\n  });\n}\n\nexport function useRenameFolder() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { id: string; name: string }) =>\n      unwrap(await ipc().folders.rename(args.id, args.name)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: foldersKeys.all }),\n  });\n}\n\nexport function useUpdateFolderIcon() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { id: string; icon: string }) =>\n      unwrap(await ipc().folders.updateIcon(args.id, args.icon)),\n    onMutate: async ({ id, icon }) => {\n      // Cancel in-flight folder fetches so a refetch landing during the\n      // optimistic write doesn't overwrite our prediction.\n      await qc.cancelQueries({ queryKey: foldersKeys.list() });\n      const previous = qc.getQueryData<import('@/lib/ipc').Folder[]>(\n        foldersKeys.list(),\n      );\n      qc.setQueryData(\n        foldersKeys.list(),\n        (old: import('@/lib/ipc').Folder[] | undefined) =>\n          old?.map((f) => (f.id === id ? { ...f, icon } : f)),\n      );\n      return { previous };\n    },\n    onError: (_err, _args, ctx) => {\n      // Restore the cache so the UI doesn't silently keep an icon that\n      // never made it to disk.\n      if (ctx?.previous !== undefined) {\n        qc.setQueryData(foldersKeys.list(), ctx.previous);\n      }\n    },\n    onSettled: () => qc.invalidateQueries({ queryKey: foldersKeys.all }),\n  });\n}\n\nexport function useDeleteFolder() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (id: string) => unwrap(await ipc().folders.delete(id)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: foldersKeys.all }),\n  });\n}\n\nexport function useReorderFolders() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (ids: string[]) => unwrap(await ipc().folders.reorder(ids)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: foldersKeys.all }),\n  });\n}\n\nfunction patchMeetingFolders(\n  qc: ReturnType<typeof useQueryClient>,\n  summaryFile: string,\n  update: (folders: string[]) => string[],\n) {\n  qc.setQueryData(meetingsKeys.list(), (old: import('@/lib/ipc').Meeting[] | undefined) => {\n    if (!old) return old;\n    return old.map((m) =>\n      m.session_info.summary_file === summaryFile\n        ? { ...m, folders: update(m.folders ?? []) }\n        : m,\n    );\n  });\n}\n\nfunction restoreMeetingsSnapshot(\n  qc: ReturnType<typeof useQueryClient>,\n  previous: import('@/lib/ipc').Meeting[] | undefined,\n) {\n  qc.setQueryData(meetingsKeys.list(), previous);\n}\n\nexport function useAddMeetingToFolder() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { summaryFile: string; folderId: string }) =>\n      unwrap(await ipc().folders.addMeeting(args.summaryFile, args.folderId)),\n    onMutate: ({ summaryFile, folderId }) => {\n      const previous = qc.getQueryData<import('@/lib/ipc').Meeting[]>(meetingsKeys.list());\n      patchMeetingFolders(qc, summaryFile, (f) => [...new Set([...f, folderId])]);\n      return { previous };\n    },\n    onError: (_error, _args, ctx) => {\n      restoreMeetingsSnapshot(qc, ctx?.previous);\n    },\n    onSettled: () => {\n      qc.invalidateQueries({ queryKey: foldersKeys.all });\n      qc.invalidateQueries({ queryKey: meetingsKeys.all });\n    },\n  });\n}\n\nexport function useRemoveMeetingFromFolder() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { summaryFile: string; folderId: string }) =>\n      unwrap(await ipc().folders.removeMeeting(args.summaryFile, args.folderId)),\n    onMutate: ({ summaryFile, folderId }) => {\n      const previous = qc.getQueryData<import('@/lib/ipc').Meeting[]>(meetingsKeys.list());\n      patchMeetingFolders(qc, summaryFile, (f) => f.filter((id) => id !== folderId));\n      return { previous };\n    },\n    onError: (_error, _args, ctx) => {\n      restoreMeetingsSnapshot(qc, ctx?.previous);\n    },\n    onSettled: () => {\n      qc.invalidateQueries({ queryKey: foldersKeys.all });\n      qc.invalidateQueries({ queryKey: meetingsKeys.all });\n    },\n  });\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useLiveMeeting.ts",
    "content": "import * as React from 'react';\nimport { useRecording } from '@/hooks/useRecording';\nimport { useSaveMeetingNotes } from '@/hooks/useMeetings';\nimport { useLiveDraftStore } from '@/hooks/liveDraftStore';\n\nexport function useLiveMeeting() {\n  const recording = useRecording();\n  const saveNotes = useSaveMeetingNotes();\n\n  const sessionName = recording.sessionName;\n  const status = recording.status;\n  const active = status === 'recording' || status === 'paused';\n\n  const draft = useLiveDraftStore((s) =>\n    sessionName ? s.drafts[sessionName] : undefined,\n  );\n  const ensure = useLiveDraftStore((s) => s.ensure);\n  const setTitleStore = useLiveDraftStore((s) => s.setTitle);\n  const setNotesStore = useLiveDraftStore((s) => s.setNotes);\n\n  // Initialize the draft entry the first time we see a sessionName.\n  React.useEffect(() => {\n    if (!sessionName) return;\n    const startedAtMs = Date.now() - recording.elapsed * 1000;\n    ensure(sessionName, { startedAtMs });\n  }, [sessionName, recording.elapsed, ensure]);\n\n  // Debounced notes save. Re-arm on every notes change; flush after 500 ms.\n  const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const setNotes = React.useCallback(\n    (next: string) => {\n      if (!sessionName) return;\n      setNotesStore(sessionName, next);\n      if (debounceRef.current) clearTimeout(debounceRef.current);\n      debounceRef.current = setTimeout(() => {\n        saveNotes.mutate({ name: sessionName, notes: next });\n      }, 500);\n    },\n    [sessionName, setNotesStore, saveNotes],\n  );\n\n  React.useEffect(() => {\n    return () => {\n      if (debounceRef.current) clearTimeout(debounceRef.current);\n    };\n  }, []);\n\n  const setTitle = React.useCallback(\n    (next: string) => {\n      if (!sessionName) return;\n      setTitleStore(sessionName, next);\n    },\n    [sessionName, setTitleStore],\n  );\n\n  return {\n    active,\n    sessionName,\n    status,\n    elapsed: recording.elapsed,\n    title: draft?.title ?? sessionName ?? '',\n    notes: draft?.notes ?? '',\n    startedAt: draft ? new Date(draft.startedAtMs) : null,\n    setTitle,\n    setNotes,\n  };\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useMeetings.ts",
    "content": "import * as React from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc, type Meeting, type UpdateMeetingPatch } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\nimport { useRecording } from '@/hooks/useRecording';\nimport { useLiveDraftStore } from '@/hooks/liveDraftStore';\nimport { meetingsKeys } from '@/hooks/meetingKeys';\n\nexport { meetingsKeys };\n\n/** Sentinel summary_file path used by the synthetic in-progress recording row.\n *  Never matches a real meeting file. Consumers detect via `meeting.is_recording`. */\nexport const LIVE_SUMMARY_PREFIX = '__live__/';\n\nexport function useMeetings() {\n  const query = useQuery({\n    queryKey: meetingsKeys.list(),\n    queryFn: async () => unwrap(await ipc().meetings.list()).meetings,\n  });\n\n  const recording = useRecording();\n  const draft = useLiveDraftStore((s) =>\n    recording.sessionName ? s.drafts[recording.sessionName] : undefined,\n  );\n\n  // Local 1 Hz tick so the live row's duration_seconds advances smoothly\n  // regardless of when the queue poll lands. We re-derive from startedAtMs\n  // when available, falling back to the polled elapsed value.\n  const [nowMs, setNowMs] = React.useState(() => Date.now());\n  React.useEffect(() => {\n    if (recording.status !== 'recording') return;\n    const id = setInterval(() => setNowMs(Date.now()), 1000);\n    return () => clearInterval(id);\n  }, [recording.status]);\n\n  const liveElapsed = React.useMemo(() => {\n    if (!recording.sessionName) return 0;\n    if (draft?.startedAtMs && recording.status !== 'paused') {\n      return Math.max(0, Math.floor((nowMs - draft.startedAtMs) / 1000));\n    }\n    return recording.elapsed;\n  }, [\n    recording.sessionName,\n    recording.status,\n    recording.elapsed,\n    draft?.startedAtMs,\n    nowMs,\n  ]);\n\n  const data = React.useMemo<Meeting[] | undefined>(() => {\n    if (!query.data) return query.data;\n    const live = recording.sessionName\n      ? buildLiveMeeting(\n          recording.sessionName,\n          draft?.title,\n          draft?.startedAtMs,\n          liveElapsed,\n          recording.status === 'processing',\n        )\n      : null;\n    if (!live) return query.data;\n    return [live, ...query.data];\n  }, [\n    query.data,\n    recording.sessionName,\n    recording.status,\n    liveElapsed,\n    draft?.title,\n    draft?.startedAtMs,\n  ]);\n\n  return { ...query, data };\n}\n\nfunction buildLiveMeeting(\n  sessionName: string,\n  draftTitle: string | undefined,\n  startedAtMs: number | undefined,\n  elapsedSeconds: number,\n  isProcessing: boolean,\n): Meeting {\n  const ts = startedAtMs ?? Date.now();\n  const iso = new Date(ts).toISOString();\n  return {\n    is_recording: !isProcessing,\n    is_processing: isProcessing,\n    session_info: {\n      name: draftTitle ?? sessionName,\n      summary_file: `${LIVE_SUMMARY_PREFIX}${sessionName}`,\n      processed_at: iso,\n      updated_at: iso,\n      duration_seconds: elapsedSeconds,\n    },\n    summary: '',\n    key_points: [],\n    action_items: [],\n    transcript: '',\n    folders: [],\n  };\n}\n\nexport function useMeeting(summaryFile: string | null | undefined) {\n  const meetings = useMeetings();\n  const meeting = React.useMemo(\n    () =>\n      summaryFile\n        ? (meetings.data?.find((m) => m.session_info.summary_file === summaryFile) ?? null)\n        : null,\n    [meetings.data, summaryFile],\n  );\n  return { ...meetings, data: meeting };\n}\n\nexport function useTranscript(summaryFile: string | null | undefined) {\n  const { data, ...rest } = useMeeting(summaryFile);\n  return { ...rest, data: data?.transcript ?? null };\n}\n\nexport function useUpdateMeeting() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { summaryFile: string; patch: UpdateMeetingPatch }) =>\n      unwrap(await ipc().meetings.update(args.summaryFile, args.patch)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: meetingsKeys.all }),\n  });\n}\n\nexport function useDeleteMeeting() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (meeting: Meeting) => unwrap(await ipc().meetings.delete(meeting)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: meetingsKeys.all }),\n  });\n}\n\nexport function useReprocessMeeting() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (args: { summaryFile: string; regenTitle: boolean; name: string }) =>\n      unwrap(await ipc().meetings.reprocess(args.summaryFile, args.regenTitle, args.name)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: meetingsKeys.all }),\n  });\n}\n\nexport function useSaveMeetingNotes() {\n  return useMutation({\n    mutationFn: async (args: { name: string; notes: string }) =>\n      unwrap(await ipc().meetings.saveNotes(args.name, args.notes)),\n  });\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useModels.ts",
    "content": "import * as React from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc, type ListedModel } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\n\nexport const modelsKeys = {\n  all: ['models'] as const,\n  list: () => [...modelsKeys.all, 'list'] as const,\n  current: () => [...modelsKeys.all, 'current'] as const,\n  ollama: () => [...modelsKeys.all, 'ollama'] as const,\n};\n\nfunction parseSizeGb(size?: string): number | undefined {\n  if (!size) return undefined;\n  const match = size.match(/^([\\d.]+)\\s*(GB|MB|KB|B)?$/i);\n  if (!match) return undefined;\n  const value = parseFloat(match[1]);\n  const unit = (match[2] ?? 'B').toUpperCase();\n  if (unit === 'GB') return value;\n  if (unit === 'MB') return value / 1024;\n  if (unit === 'KB') return value / (1024 * 1024);\n  return value / (1024 * 1024 * 1024);\n}\n\nexport function useModels() {\n  return useQuery({\n    queryKey: modelsKeys.list(),\n    queryFn: async (): Promise<{ models: ListedModel[]; current: string; provider: string }> => {\n      const raw = unwrap(await ipc().models.list());\n      const models: ListedModel[] = Object.entries(raw.supported_models).map(([id, info]) => ({\n        name: id,\n        displayName: info.name,\n        size_gb: parseSizeGb(info.size),\n        installed: info.installed ?? false,\n        current: id === raw.current_model,\n        deprecated: info.deprecated,\n        description: info.description,\n        speed: info.speed,\n        quality: info.quality,\n      }));\n      return { models, current: raw.current_model, provider: raw.provider };\n    },\n  });\n}\n\nexport function useCurrentModel() {\n  return useQuery({\n    queryKey: modelsKeys.current(),\n    queryFn: async () => unwrap(await ipc().models.getCurrent()).model,\n  });\n}\n\nexport function useOllamaStatus() {\n  return useQuery({\n    queryKey: modelsKeys.ollama(),\n    queryFn: async () => unwrap(await ipc().models.checkOllama()),\n  });\n}\n\nexport function useSetCurrentModel() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (name: string) => unwrap(await ipc().models.set(name)),\n    onMutate: async (name) => {\n      await qc.cancelQueries({ queryKey: modelsKeys.current() });\n      const previous = qc.getQueryData(modelsKeys.current());\n      qc.setQueryData(modelsKeys.current(), name);\n      return { previous };\n    },\n    onError: (_err, _name, ctx) => {\n      if (ctx?.previous !== undefined) qc.setQueryData(modelsKeys.current(), ctx.previous);\n    },\n    onSettled: () => qc.invalidateQueries({ queryKey: modelsKeys.all }),\n  });\n}\n\nexport function usePullModel() {\n  const qc = useQueryClient();\n  const [progress, setProgress] = React.useState<Record<string, string>>({});\n  const [pendingSelect, setPendingSelect] = React.useState<string | null>(null);\n\n  React.useEffect(() => {\n    const offProgress = ipc().on.modelPullProgress(({ model, progress: p }) => {\n      setProgress((prev) => ({ ...prev, [model]: p }));\n    });\n    const offComplete = ipc().on.modelPullComplete(async ({ model, success }) => {\n      setProgress((prev) => {\n        const { [model]: _drop, ...rest } = prev;\n        return rest;\n      });\n      if (success && pendingSelect === model) {\n        await ipc().models.set(model);\n        setPendingSelect(null);\n      }\n      qc.invalidateQueries({ queryKey: modelsKeys.all });\n    });\n    return () => {\n      offProgress();\n      offComplete();\n    };\n  }, [qc, pendingSelect]);\n\n  const pullAndSelect = async (name: string) => {\n    setPendingSelect(name);\n    unwrap(await ipc().models.pull(name));\n  };\n\n  const mutation = useMutation({\n    mutationFn: pullAndSelect,\n  });\n\n  return { ...mutation, progress };\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useRecording.ts",
    "content": "import * as React from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\nimport { meetingsKeys } from './meetingKeys';\nimport { navigate } from '@/lib/router';\nimport type { Meeting, QueueStatus } from '@/lib/ipc';\n\nexport type RecordingStatus = 'idle' | 'recording' | 'paused' | 'processing';\n\nconst queueKey = ['recording', 'queue'] as const;\n\nexport function useRecording() {\n  const qc = useQueryClient();\n\n  const queue = useQuery({\n    queryKey: queueKey,\n    queryFn: async () => {\n      const res = await ipc().recording.getQueue();\n      if (!res.success) throw new Error(res.error);\n      return res;\n    },\n    refetchInterval: (query) => (query.state.data?.hasRecording ? 1000 : 2000),\n  });\n\n  const status: RecordingStatus = React.useMemo(() => {\n    const q = queue.data;\n    if (q?.hasRecording) return q.isPaused ? 'paused' : 'recording';\n    if (q?.isProcessing) return 'processing';\n    return 'idle';\n  }, [queue.data]);\n\n  // NOTE: processing-complete handling lives in useRecordingProcessingEffects\n  // below, mounted ONCE at App level. Putting it here would attach a fresh\n  // listener for every consumer of useRecording (12+ at last count), causing\n  // duplicate cache invalidations and N navigations per recording.\n\n  const startRecording = React.useCallback(\n    async (name?: string) => {\n      // Optimistic cache write so the UI flips to status='recording'\n      // instantly. The backend's start-recording-ui has a 2s warm-up and\n      // the next queue poll (1s) will reconcile sessionName + elapsed.\n      // 'Note' is the placeholder that the Python post-processor recognises\n      // (regex ^(Meeting|Note)(-[A-Z0-9]{6})?$) and replaces with an AI-\n      // generated title from the summary + transcript.\n      const optimisticName = name && name.trim() ? name.trim() : 'Note';\n      qc.setQueryData(queueKey, {\n        success: true,\n        isProcessing: false,\n        queueSize: 0,\n        currentJob: null,\n        hasRecording: true,\n        isPaused: false,\n        elapsedSeconds: 0,\n        sessionName: optimisticName,\n      });\n      navigate('/recording');\n      try {\n        const data = unwrap(await ipc().recording.start(name));\n        qc.invalidateQueries({ queryKey: queueKey });\n        return data;\n      } catch (err) {\n        // Roll back optimistic state and leave the dead /recording page.\n        qc.invalidateQueries({ queryKey: queueKey });\n        navigate('/');\n        throw err;\n      }\n    },\n    [qc],\n  );\n\n  const stopRecording = React.useCallback(async () => {\n    // Optimistic: flip the queue cache to processing so the UI can navigate\n    // away from /recording instantly, before the backend SIGTERM round-trip.\n    qc.setQueryData(queueKey, (prev: QueueStatus | undefined) => ({\n      success: true as const,\n      isProcessing: true,\n      queueSize: prev?.queueSize ?? 0,\n      currentJob: prev?.sessionName ?? prev?.currentJob ?? null,\n      hasRecording: false,\n      isPaused: false,\n      elapsedSeconds: 0,\n      sessionName: prev?.sessionName ?? null,\n    }));\n    navigate('/meetings/processing');\n    try {\n      const data = unwrap(await ipc().recording.stop());\n      qc.invalidateQueries({ queryKey: queueKey });\n      return data;\n    } catch (err) {\n      qc.invalidateQueries({ queryKey: queueKey });\n      throw err;\n    }\n  }, [qc]);\n\n  const pauseRecording = React.useCallback(async () => {\n    const data = unwrap(await ipc().recording.pause());\n    qc.invalidateQueries({ queryKey: queueKey });\n    return data;\n  }, [qc]);\n\n  const resumeRecording = React.useCallback(async () => {\n    const data = unwrap(await ipc().recording.resume());\n    qc.invalidateQueries({ queryKey: queueKey });\n    return data;\n  }, [qc]);\n\n  return {\n    status,\n    elapsed: queue.data?.elapsedSeconds ?? 0,\n    sessionName: queue.data?.sessionName ?? null,\n    startRecording,\n    stopRecording,\n    pauseRecording,\n    resumeRecording,\n    isLoading: queue.isLoading,\n  };\n}\n\n/**\n * Mount once at App level. Wires tray / shortcut / macOS-Shortcuts / global\n * hotkey events to start/stop recording, and tells main.js the renderer is\n * ready to receive shortcut events so any queued-from-launch URLs get\n * flushed.\n */\nexport function useRecordingEvents() {\n  const { status, startRecording, stopRecording } = useRecording();\n\n  React.useEffect(() => {\n    const bridge = ipc();\n    const toggle = () => {\n      if (status === 'recording' || status === 'paused') void stopRecording();\n      else if (status === 'idle') void startRecording();\n    };\n    const offs = [\n      bridge.on.toggleRecordingHotkey(toggle),\n      bridge.on.trayStartRecording(() => {\n        void startRecording();\n      }),\n      bridge.on.trayStopRecording(() => {\n        void stopRecording();\n      }),\n      bridge.on.shortcutStartRecording(({ sessionName }) => {\n        void startRecording(sessionName ?? undefined);\n      }),\n      bridge.on.shortcutStopRecording(() => {\n        void stopRecording();\n      }),\n    ];\n    bridge.shortcuts.rendererReady();\n    return () => offs.forEach((off) => off());\n  }, [status, startRecording, stopRecording]);\n}\n\n/**\n * Mount once at App level. The processing-complete listener does cache\n * pre-seeding + invalidation + post-recording navigation. Splitting this out\n * of useRecording keeps the side-effect singleton even though useRecording\n * itself is consumed by many components.\n */\nexport function useRecordingProcessingEffects() {\n  const qc = useQueryClient();\n  React.useEffect(() => {\n    const off = ipc().on.processingComplete((data) => {\n      if (data.success && data.meetingData?.session_info.summary_file) {\n        const newMeeting = data.meetingData as Meeting;\n        const newSummaryFile = newMeeting.session_info.summary_file;\n        qc.setQueryData<Meeting[]>(meetingsKeys.list(), (prev) => {\n          if (!prev) return [newMeeting];\n          const filtered = prev.filter(\n            (m) => m.session_info.summary_file !== newSummaryFile,\n          );\n          return [newMeeting, ...filtered];\n        });\n      }\n      qc.invalidateQueries({ queryKey: meetingsKeys.all });\n      qc.invalidateQueries({ queryKey: queueKey });\n      if (data.success && data.meetingData?.session_info.summary_file) {\n        navigate(`/meetings/${encodeURIComponent(data.meetingData.session_info.summary_file)}`);\n      }\n    });\n    return off;\n  }, [qc]);\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useSettings.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\n\nexport const settingsKeys = {\n  all: ['settings'] as const,\n  notifications: () => [...settingsKeys.all, 'notifications'] as const,\n  telemetry: () => [...settingsKeys.all, 'telemetry'] as const,\n  dockIcon: () => [...settingsKeys.all, 'dockIcon'] as const,\n  systemAudio: () => [...settingsKeys.all, 'systemAudio'] as const,\n  language: () => [...settingsKeys.all, 'language'] as const,\n  storagePath: () => [...settingsKeys.all, 'storagePath'] as const,\n  appVersion: () => [...settingsKeys.all, 'appVersion'] as const,\n  userName: () => [...settingsKeys.all, 'userName'] as const,\n};\n\nexport function useNotificationsSetting() {\n  return useQuery({\n    queryKey: settingsKeys.notifications(),\n    queryFn: async () => unwrap(await ipc().settings.getNotifications()).notifications_enabled,\n  });\n}\n\nexport function useSetNotifications() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (v: boolean) => unwrap(await ipc().settings.setNotifications(v)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.notifications() }),\n  });\n}\n\nexport function useTelemetrySetting() {\n  return useQuery({\n    queryKey: settingsKeys.telemetry(),\n    queryFn: async () => unwrap(await ipc().settings.getTelemetry()),\n  });\n}\n\nexport function useSetTelemetry() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (v: boolean) => unwrap(await ipc().settings.setTelemetry(v)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.telemetry() }),\n  });\n}\n\nexport function useDockIconSetting() {\n  return useQuery({\n    queryKey: settingsKeys.dockIcon(),\n    queryFn: async () => unwrap(await ipc().settings.getDockIcon()).hide_dock_icon,\n  });\n}\n\nexport function useSetDockIcon() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (v: boolean) => unwrap(await ipc().settings.setDockIcon(v)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.dockIcon() }),\n  });\n}\n\nexport function useSystemAudioSetting() {\n  return useQuery({\n    queryKey: settingsKeys.systemAudio(),\n    queryFn: async () => unwrap(await ipc().settings.getSystemAudio()).system_audio_enabled,\n  });\n}\n\nexport function useSetSystemAudio() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (v: boolean) => unwrap(await ipc().settings.setSystemAudio(v)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.systemAudio() }),\n  });\n}\n\nexport function useLanguageSetting() {\n  return useQuery({\n    queryKey: settingsKeys.language(),\n    queryFn: async () => unwrap(await ipc().settings.getLanguage()).language,\n  });\n}\n\nexport function useSetLanguage() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (code: string) => unwrap(await ipc().settings.setLanguage(code)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.language() }),\n  });\n}\n\nexport function useStoragePath() {\n  return useQuery({\n    queryKey: settingsKeys.storagePath(),\n    queryFn: async () => unwrap(await ipc().settings.getStoragePath()),\n  });\n}\n\nexport function useSetStoragePath() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (path: string) => unwrap(await ipc().settings.setStoragePath(path)),\n    onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.storagePath() }),\n  });\n}\n\nexport function usePickStorageFolder() {\n  return useMutation({\n    mutationFn: async () => unwrap(await ipc().settings.pickStorageFolder()).folderPath,\n  });\n}\n\nexport function useAppVersion() {\n  return useQuery({\n    queryKey: settingsKeys.appVersion(),\n    queryFn: async () => unwrap(await ipc().app.getVersion()),\n    staleTime: Infinity,\n  });\n}\n\nexport function useClearSystemState() {\n  return useMutation({\n    mutationFn: async () => unwrap(await ipc().system.clearState()),\n  });\n}\n\n// Mirror the persisted user name into sessionStorage so the next mount in\n// the same session has the value synchronously and the chat greeting\n// doesn't flash 'Ask anything' before flipping to 'Hi <name>, ...'.\nconst USER_NAME_CACHE_KEY = 'steno-user-name';\n\nfunction readCachedUserName(): string {\n  try {\n    return sessionStorage.getItem(USER_NAME_CACHE_KEY) ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction writeCachedUserName(name: string) {\n  try {\n    sessionStorage.setItem(USER_NAME_CACHE_KEY, name);\n  } catch {\n    // Storage may be unavailable in private mode — graceful degradation.\n  }\n}\n\nexport function useUserName() {\n  return useQuery({\n    queryKey: settingsKeys.userName(),\n    queryFn: async () => {\n      const name = unwrap(await ipc().settings.getUserName()).user_name;\n      writeCachedUserName(name);\n      return name;\n    },\n    // The name only changes via useSetUserName (which invalidates this\n    // key), so once we have it there's no reason to refetch on remount.\n    staleTime: Infinity,\n    // placeholderData (NOT initialData) so the query still fetches the\n    // canonical value from disk on first mount. initialData was marking\n    // the query as already-fresh — combined with staleTime: Infinity that\n    // suppressed the queryFn entirely, so the greeting was stuck on the\n    // empty sessionStorage default forever.\n    placeholderData: readCachedUserName(),\n  });\n}\n\nexport function useSetUserName() {\n  const qc = useQueryClient();\n  return useMutation({\n    mutationFn: async (name: string) =>\n      unwrap(await ipc().settings.setUserName(name)),\n    onSuccess: (_data, name) => {\n      writeCachedUserName(name.trim());\n      qc.invalidateQueries({ queryKey: settingsKeys.userName() });\n    },\n  });\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useSetup.ts",
    "content": "import * as React from 'react';\nimport { useMutation, useQuery } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\n\nexport const setupKeys = {\n  all: ['setup'] as const,\n  check: () => [...setupKeys.all, 'check'] as const,\n};\n\nexport function useSetupCheck() {\n  return useQuery({\n    queryKey: setupKeys.check(),\n    queryFn: async () => unwrap(await ipc().setup.check()),\n  });\n}\n\nexport function useSetupStep(name: 'systemCheck' | 'ffmpeg' | 'python' | 'ollamaAndModel' | 'whisper' | 'test') {\n  return useMutation({\n    mutationFn: async () => {\n      const res = await ipc().setup[name]();\n      if (!res.success) throw new Error(res.error);\n      return res;\n    },\n  });\n}\n\nexport function useCheckMicPermission() {\n  return useMutation({\n    mutationFn: async () => unwrap(await ipc().perm.checkMicrophone()).status,\n  });\n}\n\nexport function useRequestMicPermission() {\n  return useMutation({\n    mutationFn: async () => unwrap(await ipc().perm.requestMicrophone()).granted,\n  });\n}\n\nexport function useDebugLog() {\n  const [lines, setLines] = React.useState<string[]>([]);\n  React.useEffect(() => {\n    return ipc().on.debugLog((line) => {\n      setLines((prev) => {\n        const next = [...prev, line];\n        return next.length > 500 ? next.slice(-500) : next;\n      });\n    });\n  }, []);\n  const clear = React.useCallback(() => setLines([]), []);\n  return { lines, clear };\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useStreamingQuery.ts",
    "content": "import * as React from 'react';\nimport { ipc } from '@/lib/ipc';\n\nexport type StreamStatus = 'streaming' | 'done' | 'error';\n\nexport interface StreamState {\n  text: string;\n  status: StreamStatus;\n  error: string | null;\n}\n\nfunction newId() {\n  return `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n}\n\nexport function useStreamingQuery() {\n  const [streams, setStreams] = React.useState<Record<string, StreamState>>({});\n  const unsubsRef = React.useRef<Map<string, () => void>>(new Map());\n  const activeRef = React.useRef<Set<string>>(new Set());\n\n  // Tear down the IPC subscription for a stream and forget its handle.\n  // Called from onDone/onError so the listener doesn't linger past the\n  // stream's lifetime (otherwise unsubsRef accumulates dead entries until\n  // the component unmounts).\n  const detachStream = (id: string) => {\n    const off = unsubsRef.current.get(id);\n    if (off) {\n      off();\n      unsubsRef.current.delete(id);\n    }\n    activeRef.current.delete(id);\n  };\n\n  const startStream = React.useCallback((file: string, question: string): string => {\n    const id = newId();\n    setStreams((prev) => ({\n      ...prev,\n      [id]: { text: '', status: 'streaming', error: null },\n    }));\n    activeRef.current.add(id);\n\n    const off = ipc().subscribeQueryStream(id, {\n      onChunk: (chunk) => {\n        setStreams((prev) => {\n          const current = prev[id];\n          if (!current) return prev;\n          return { ...prev, [id]: { ...current, text: current.text + chunk } };\n        });\n      },\n      onDone: () => {\n        setStreams((prev) => {\n          const current = prev[id];\n          if (!current) return prev;\n          return { ...prev, [id]: { ...current, status: 'done' } };\n        });\n        detachStream(id);\n      },\n      onError: (err) => {\n        setStreams((prev) => {\n          const current = prev[id];\n          if (!current) return prev;\n          return { ...prev, [id]: { ...current, status: 'error', error: err.message } };\n        });\n        detachStream(id);\n      },\n    });\n    unsubsRef.current.set(id, off);\n    ipc().query.askStream(id, file, question);\n    return id;\n  }, []);\n\n  // Cross-note variant of startStream — same wire shape, no summaryFile.\n  // Used by the Chat tab to ask questions across every meeting summary,\n  // optionally scoped to a single folder.\n  const startGlobalStream = React.useCallback((\n    question: string,\n    folderId?: string | null,\n  ): string => {\n    const id = newId();\n    setStreams((prev) => ({\n      ...prev,\n      [id]: { text: '', status: 'streaming', error: null },\n    }));\n    activeRef.current.add(id);\n\n    const off = ipc().subscribeQueryStream(id, {\n      onChunk: (chunk) => {\n        setStreams((prev) => {\n          const current = prev[id];\n          if (!current) return prev;\n          return { ...prev, [id]: { ...current, text: current.text + chunk } };\n        });\n      },\n      onDone: () => {\n        setStreams((prev) => {\n          const current = prev[id];\n          if (!current) return prev;\n          return { ...prev, [id]: { ...current, status: 'done' } };\n        });\n        detachStream(id);\n      },\n      onError: (err) => {\n        setStreams((prev) => {\n          const current = prev[id];\n          if (!current) return prev;\n          return { ...prev, [id]: { ...current, status: 'error', error: err.message } };\n        });\n        detachStream(id);\n      },\n    });\n    unsubsRef.current.set(id, off);\n    ipc().query.chatGlobalStream(id, question, folderId ?? null);\n    return id;\n  }, []);\n\n  const cancelStream = React.useCallback((id: string) => {\n    const off = unsubsRef.current.get(id);\n    off?.();\n    unsubsRef.current.delete(id);\n    ipc().query.cancel(id);\n    setStreams((prev) => {\n      const current = prev[id];\n      if (!current) return prev;\n      return { ...prev, [id]: { ...current, status: 'done' } };\n    });\n    activeRef.current.delete(id);\n  }, []);\n\n  const clearStream = React.useCallback((id: string) => {\n    setStreams((prev) => {\n      if (!(id in prev)) return prev;\n      const { [id]: _drop, ...rest } = prev;\n      return rest;\n    });\n  }, []);\n\n  React.useEffect(() => {\n    return () => {\n      for (const off of unsubsRef.current.values()) off();\n      unsubsRef.current.clear();\n      for (const id of activeRef.current) {\n        try {\n          ipc().query.cancel(id);\n        } catch {\n          // bridge may already be torn down\n        }\n      }\n      activeRef.current.clear();\n    };\n  }, []);\n\n  return { streams, startStream, startGlobalStream, cancelStream, clearStream };\n}\n\nexport type StreamingQueryApi = ReturnType<typeof useStreamingQuery>;\n\n// Context-shared streaming state. Mounted at App level so streams survive\n// route changes (e.g. submitting on /chat then navigating to /chat/<id>\n// without losing the in-flight response). Consumers should prefer\n// useGlobalStreaming() over calling useStreamingQuery() directly.\nconst StreamingContext = React.createContext<StreamingQueryApi | null>(null);\n\nexport function StreamingProvider({ children }: { children: React.ReactNode }) {\n  const value = useStreamingQuery();\n  return React.createElement(StreamingContext.Provider, { value }, children);\n}\n\nexport function useGlobalStreaming(): StreamingQueryApi {\n  const ctx = React.useContext(StreamingContext);\n  if (!ctx) {\n    throw new Error('useGlobalStreaming must be used inside <StreamingProvider>');\n  }\n  return ctx;\n}\n"
  },
  {
    "path": "app/renderer/src/hooks/useTheme.ts",
    "content": "import * as React from 'react';\n\nexport type Theme = 'light' | 'dark' | 'system';\n\nconst STORAGE_KEY = 'steno-theme';\n\nfunction systemPrefersDark(): boolean {\n  return (\n    typeof window !== 'undefined' &&\n    window.matchMedia('(prefers-color-scheme: dark)').matches\n  );\n}\n\nfunction readStoredTheme(): Theme {\n  if (typeof window === 'undefined') return 'system';\n  const value = window.localStorage.getItem(STORAGE_KEY);\n  return value === 'light' || value === 'dark' || value === 'system'\n    ? value\n    : 'system';\n}\n\nfunction applyResolved(resolved: 'light' | 'dark') {\n  if (typeof document === 'undefined') return;\n  document.documentElement.classList.toggle('dark', resolved === 'dark');\n  // The design-v2 token layer keys off [data-theme=\"dark\"] in addition to\n  // .dark, so mirror the class onto the attribute for both designs.\n  document.documentElement.setAttribute('data-theme', resolved);\n}\n\nexport function useTheme() {\n  const [theme, setThemeState] = React.useState<Theme>(readStoredTheme);\n  const [resolved, setResolved] = React.useState<'light' | 'dark'>(() =>\n    readStoredTheme() === 'dark'\n      ? 'dark'\n      : readStoredTheme() === 'light'\n        ? 'light'\n        : systemPrefersDark()\n          ? 'dark'\n          : 'light',\n  );\n\n  const setTheme = React.useCallback((next: Theme) => {\n    setThemeState(next);\n    if (typeof window !== 'undefined') {\n      window.localStorage.setItem(STORAGE_KEY, next);\n    }\n  }, []);\n\n  React.useEffect(() => {\n    const next: 'light' | 'dark' =\n      theme === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : theme;\n    setResolved(next);\n    applyResolved(next);\n  }, [theme]);\n\n  React.useEffect(() => {\n    if (theme !== 'system' || typeof window === 'undefined') return;\n    const mq = window.matchMedia('(prefers-color-scheme: dark)');\n    const handler = () => {\n      const next = mq.matches ? 'dark' : 'light';\n      setResolved(next);\n      applyResolved(next);\n    };\n    mq.addEventListener('change', handler);\n    return () => mq.removeEventListener('change', handler);\n  }, [theme]);\n\n  return { theme, setTheme, resolved };\n}\n"
  },
  {
    "path": "app/renderer/src/lib/askBarContext.tsx",
    "content": "import * as React from 'react';\n\ninterface AskBarContextValue {\n  activeSummaryFile: string | null;\n  activeMeetingName: string | null;\n  setActiveMeeting: (summaryFile: string | null, name: string | null) => void;\n  transcriptOpen: boolean;\n  setTranscriptOpen: (open: boolean) => void;\n}\n\nconst AskBarContext = React.createContext<AskBarContextValue | null>(null);\n\nexport function AskBarProvider({ children }: { children: React.ReactNode }) {\n  const [active, setActive] = React.useState<{\n    summaryFile: string | null;\n    name: string | null;\n  }>({ summaryFile: null, name: null });\n  const [transcriptOpen, setTranscriptOpen] = React.useState(false);\n\n  React.useEffect(() => {\n    if (!active.summaryFile) setTranscriptOpen(false);\n  }, [active.summaryFile]);\n\n  const setActiveMeeting = React.useCallback(\n    (summaryFile: string | null, name: string | null) =>\n      setActive((prev) =>\n        prev.summaryFile === summaryFile && prev.name === name ? prev : { summaryFile, name },\n      ),\n    [],\n  );\n\n  const value = React.useMemo<AskBarContextValue>(\n    () => ({\n      activeSummaryFile: active.summaryFile,\n      activeMeetingName: active.name,\n      setActiveMeeting,\n      transcriptOpen,\n      setTranscriptOpen,\n    }),\n    [active, transcriptOpen, setActiveMeeting],\n  );\n\n  return <AskBarContext.Provider value={value}>{children}</AskBarContext.Provider>;\n}\n\nexport function useAskBar(): AskBarContextValue {\n  const ctx = React.useContext(AskBarContext);\n  if (!ctx) throw new Error('useAskBar must be used inside AskBarProvider');\n  return ctx;\n}\n\nexport function useActiveMeeting(\n  summaryFile: string | null,\n  name: string | null,\n) {\n  const { setActiveMeeting } = useAskBar();\n  React.useEffect(() => {\n    setActiveMeeting(summaryFile, name);\n    return () => setActiveMeeting(null, null);\n  }, [summaryFile, name, setActiveMeeting]);\n}\n"
  },
  {
    "path": "app/renderer/src/lib/chat.ts",
    "content": "// Shared helpers for the Chat tab + conversation view.\n\n// Sentinel summaryFile that marks a chat session as belonging to the global\n// Chat tab rather than any specific meeting. Stored on the session record so\n// the Recents list can filter to chat-tab sessions and skip in-meeting\n// AskBar history.\nexport const GLOBAL_SCOPE = '__global__';\n\nexport function deriveSessionName(question: string): string {\n  const trimmed = question.trim().replace(/\\s+/g, ' ');\n  if (trimmed.length <= 40) return trimmed;\n  return trimmed.slice(0, 40) + '…';\n}\n\n// Bucket label for grouped chat-history lists (matches the Granola\n// History dropdown's \"Today / Last 2 weeks / April\" pattern). Returns a\n// stable key (not localized) so consumers can sort/group; format the key\n// with toBucketLabel() before display.\nexport function bucketKey(ts: number, now: number = Date.now()): string {\n  const d = new Date(ts);\n  const today = new Date(now);\n  today.setHours(0, 0, 0, 0);\n  const startOfDay = today.getTime();\n  if (ts >= startOfDay) return 'today';\n  // Day-difference comparisons. Subtracting fixed 24h offsets breaks across\n  // DST boundaries (a Sunday morning shift means \"yesterday\" really started\n  // 23h ago, not 24h), so step back by calendar day instead.\n  const dayBefore = (n: number): number => {\n    const t = new Date(today);\n    t.setDate(t.getDate() - n);\n    return t.getTime();\n  };\n  if (ts >= dayBefore(1)) return 'yesterday';\n  if (ts >= dayBefore(7)) return 'this-week';\n  if (ts >= dayBefore(14)) return 'last-2-weeks';\n  // Same calendar month\n  if (\n    d.getFullYear() === today.getFullYear() &&\n    d.getMonth() === today.getMonth()\n  ) {\n    return 'this-month';\n  }\n  // Same calendar year — return month name as the key\n  if (d.getFullYear() === today.getFullYear()) {\n    return `month-${d.getMonth()}`;\n  }\n  return `year-${d.getFullYear()}`;\n}\n\nexport function toBucketLabel(key: string): string {\n  if (key === 'today') return 'Today';\n  if (key === 'yesterday') return 'Yesterday';\n  if (key === 'this-week') return 'This week';\n  if (key === 'last-2-weeks') return 'Last 2 weeks';\n  if (key === 'this-month') return 'This month';\n  if (key.startsWith('month-')) {\n    const m = parseInt(key.slice(6), 10);\n    return new Date(2000, m, 1).toLocaleString(undefined, { month: 'long' });\n  }\n  if (key.startsWith('year-')) return key.slice(5);\n  return key;\n}\n\nexport function relativeTime(ts: number): string {\n  const now = Date.now();\n  const diff = Math.max(0, now - ts);\n  const minutes = Math.floor(diff / 60000);\n  if (minutes < 1) return 'now';\n  if (minutes < 60) return `${minutes}m`;\n  const hours = Math.floor(minutes / 60);\n  if (hours < 24) return `${hours}h`;\n  const days = Math.floor(hours / 24);\n  if (days < 7) return `${days}d`;\n  const weeks = Math.floor(days / 7);\n  if (weeks < 5) return `${weeks}w`;\n  const months = Math.floor(days / 30);\n  if (months < 12) return `${months}mo`;\n  return `${Math.floor(months / 12)}y`;\n}\n"
  },
  {
    "path": "app/renderer/src/lib/chatPresets.tsx",
    "content": "// Templated preset prompts surfaced two ways:\n//   1. Chip row at the bottom of the /chat entry page (always visible).\n//   2. Popover triggered by typing '/' as the first character in either\n//      composer (entry page or /chat/<id> conversation page).\n// Edit the list here; both call sites pick it up automatically.\nexport interface ChatPreset {\n  label: string;\n  prompt: string;\n  description: string;\n}\n\nexport const PRESETS: ChatPreset[] = [\n  {\n    label: 'List recent todos',\n    prompt: 'List my action items from the last week.',\n    description: 'Pulls outstanding to-dos from recent meeting notes',\n  },\n  {\n    label: 'Coach me',\n    prompt: 'Coach me on my recent meetings — patterns, blind spots, things to work on.',\n    description: 'Looks for patterns and suggests areas to improve',\n  },\n  {\n    label: 'Write weekly recap',\n    prompt: 'Write a recap of this week based on my notes.',\n    description: 'Summary of the week across every meeting',\n  },\n  {\n    label: 'Blind spots',\n    prompt: 'What blind spots have come up across my recent meetings?',\n    description: 'Surfaces themes you may have missed',\n  },\n];\n\n/** Slash glyph used as the leading icon on every preset chip + popover\n *  row. Reinforces the \"/\" keyboard shortcut. Plain grey using the\n *  existing ink tokens so it sits quietly in the warm paper palette\n *  without claiming a colored accent. */\nexport function PresetGlyph() {\n  return (\n    <span\n      aria-hidden\n      className=\"inline-flex size-[18px] flex-shrink-0 items-center justify-center rounded-md font-mono text-[13px] font-semibold leading-none\"\n      style={{\n        color: 'var(--fg-2)',\n        background: 'var(--surface-active)',\n      }}\n    >\n      /\n    </span>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/lib/debugLogs.ts",
    "content": "// Module-level ring buffer for backend debug log lines.\n//\n// The DeveloperTab in Settings was previously the only listener for the\n// `debug-log` IPC channel — it subscribed on mount and tore down on unmount,\n// so every log emitted before the user opened that tab was lost. This store\n// keeps a rolling buffer that's populated from app start (App.tsx primes it\n// once) and consumed by any component that wants to render logs.\n//\n// Cap is intentionally generous (1000 lines) since each line is short and\n// users sometimes need to scroll back through a recording session.\n\nconst MAX_LINES = 1000;\n\ntype Listener = () => void;\n\nlet buffer: string[] = [];\nconst listeners = new Set<Listener>();\nlet primed = false;\n\nfunction notify() {\n  listeners.forEach((l) => l());\n}\n\nexport function appendDebugLog(line: string) {\n  buffer = buffer.length >= MAX_LINES ? [...buffer.slice(-(MAX_LINES - 1)), line] : [...buffer, line];\n  notify();\n}\n\nexport function clearDebugLogs() {\n  if (buffer.length === 0) return;\n  buffer = [];\n  notify();\n}\n\nexport function getDebugLogs(): string[] {\n  return buffer;\n}\n\nexport function subscribeDebugLogs(l: Listener): () => void {\n  listeners.add(l);\n  return () => {\n    listeners.delete(l);\n  };\n}\n\n/**\n * Wire the IPC channel into the store. Idempotent — safe to call from any\n * mount point; subsequent calls are no-ops. The returned cleanup unsubscribes\n * the IPC listener AND resets the primed flag, so a later mount can rebind.\n */\nexport function primeDebugLogs(\n  bind: (cb: (line: string) => void) => () => void,\n): () => void {\n  if (primed) return () => {};\n  primed = true;\n  const unsubscribe = bind((line) => appendDebugLog(line));\n  return () => {\n    unsubscribe();\n    primed = false;\n  };\n}\n"
  },
  {
    "path": "app/renderer/src/lib/ipc.ts",
    "content": "/**\n * Typed wrapper over `window.stenoai` — the contextBridge surface defined in\n * `app/preload.js`. All hooks/components talk to this module; no direct\n * `ipcRenderer` usage in renderer code.\n *\n * The source of truth for the shape is `app/docs/ipc-contract.md` + the\n * preload itself. When you change one, change the other in the same commit.\n */\n\n// ---------- shared result envelope ----------\nexport type Result<T> = ({ success: true } & T) | { success: false; error: string };\n\n// ---------- domain types ----------\nexport interface SessionInfo {\n  name: string;\n  summary_file: string;\n  transcript_file?: string;\n  audio_file?: string;\n  processed_at?: string;\n  updated_at?: string;\n  duration_seconds?: number;\n  folders?: string[];\n}\n\nexport interface Meeting {\n  session_info: SessionInfo;\n  summary: string;\n  participants?: unknown[];\n  discussion_areas?: unknown[];\n  key_points?: string[];\n  action_items?: unknown[];\n  transcript: string;\n  is_diarised?: boolean;\n  diarised_text?: string | null;\n  folders?: string[];\n  notes?: string;\n  /** Synthetic flag set by the renderer for the in-progress recording. Never sent by backend. */\n  is_recording?: boolean;\n  /** Synthetic flag set by the renderer when a recording is in the processing pipeline (post-stop, pre-summary). */\n  is_processing?: boolean;\n}\n\nexport interface Folder {\n  id: string;\n  name: string;\n  color: string;\n  order: number;\n  icon?: string;\n}\n\nexport interface ListedModel {\n  name: string;\n  displayName?: string;\n  size_gb?: number;\n  installed: boolean;\n  current?: boolean;\n  deprecated?: boolean;\n  description?: string;\n  speed?: string;\n  quality?: string;\n}\n\nexport interface CalendarEvent {\n  id: string;\n  title: string;\n  start: string;\n  end: string;\n  attendees?: Array<{ email: string; name?: string }>;\n  location?: string;\n  meeting_url?: string;\n  description?: string;\n}\n\nexport interface Announcement {\n  id: string;\n  title: string;\n  body: string;\n  action_url?: string;\n  action_label?: string;\n  min_version?: string;\n  max_version?: string;\n  dismissible?: boolean;\n}\n\nexport interface UpdateMeetingPatch {\n  name?: string;\n  summary?: string;\n  participants?: unknown[];\n  key_points?: string[];\n  action_items?: unknown[];\n}\n\nexport interface ChatSessionsBlob {\n  sessions: Array<{\n    id: string;\n    name: string;\n    summaryFile?: string;\n    messages: Array<{ role: 'user' | 'assistant'; content: string; ts: number }>;\n    createdAt: number;\n    updatedAt: number;\n  }>;\n}\n\nexport type MicPermissionStatus =\n  | 'granted'\n  | 'denied'\n  | 'restricted'\n  | 'not-determined'\n  | 'unknown';\n\nexport type AiProvider = 'local' | 'remote' | 'cloud';\nexport type CloudProvider = 'openai' | 'anthropic' | 'custom';\n\n// ---------- response envelopes ----------\nexport type AppVersionResponse = Result<{ version: string; name: string }>;\nexport type StatusResponse = Result<{ status: string; details?: unknown }>;\nexport type SetupCheckResponse = Result<{\n  allGood: boolean;\n  checks: Array<[icon: string, label: string]>;\n}>;\n\nexport type MicPermissionResponse = Result<{ status: MicPermissionStatus }>;\nexport type MicPermissionGrantResponse = Result<{ granted: boolean }>;\n\nexport type StartRecordingResponse = Result<{ message: string; sessionName?: string }>;\nexport type StopRecordingResponse = Result<{ message: string; sessionName?: string }>;\nexport type PauseRecordingResponse = Result<{ message: string }>;\nexport type ResumeRecordingResponse = Result<{ message: string }>;\n\nexport interface QueueStatus {\n  success: true;\n  isProcessing: boolean;\n  queueSize: number;\n  currentJob: string | null;\n  hasRecording: boolean;\n  isPaused: boolean;\n  elapsedSeconds: number;\n  sessionName: string | null;\n}\n\nexport type PickAudioFileResponse = Result<{ filePath: string }>;\nexport type RecordingsDirResponse = Result<{ path: string }>;\n\nexport type ListMeetingsResponse = Result<{ meetings: Meeting[] }>;\nexport type UpdateMeetingResponse = Result<{ message: string; updatedData: Meeting }>;\nexport type DeleteMeetingResponse = Result<{ message: string }>;\nexport type SaveMeetingNotesResponse = Result<{ path: string }>;\n\nexport type QueryResponse = Result<{ answer: string }>;\nexport type LoadChatSessionsResponse = Result<{ data: ChatSessionsBlob | null }>;\n\nexport type ListFoldersResponse = Result<{ folders: Folder[] }>;\nexport type CreateFolderResponse = Result<{ folder: Folder }>;\n\nexport type CheckOllamaResponse = Result<{ installed: boolean; path?: string }>;\nexport type CheckModelInstalledResponse = Result<{ installed: boolean }>;\nexport interface RawSupportedModel {\n  name?: string;\n  size?: string;\n  params?: string;\n  description?: string;\n  speed?: string;\n  quality?: string;\n  deprecated?: boolean;\n  installed?: boolean;\n}\n\nexport type ListModelsResponse = Result<{\n  supported_models: Record<string, RawSupportedModel>;\n  current_model: string;\n  provider: string;\n}>;\nexport type GetCurrentModelResponse = Result<{ model: string }>;\n\nexport type GetNotificationsResponse = Result<{ notifications_enabled: boolean }>;\nexport type GetTelemetryResponse = Result<{\n  telemetry_enabled: boolean;\n  anonymous_id?: string;\n}>;\nexport type GetDockIconResponse = Result<{ hide_dock_icon: boolean }>;\nexport type GetSystemAudioResponse = Result<{ system_audio_enabled: boolean }>;\nexport type GetLanguageResponse = Result<{ language: string }>;\nexport type GetUserNameResponse = Result<{ user_name: string }>;\nexport type StoragePathResponse = Result<{\n  storage_path: string | null;\n  custom_path: string | null;\n  default_path: string;\n}>;\nexport type PickStorageFolderResponse = Result<{ folderPath: string }>;\nexport type GetAiPromptsResponse = Result<{ summarization: string }>;\n\nexport type GetAiProviderResponse = Result<{\n  ai_provider: AiProvider;\n  remote_ollama_url: string;\n  cloud_api_url: string;\n  cloud_provider: CloudProvider;\n  cloud_model: string;\n  cloud_api_key_set: boolean;\n}>;\n\nexport type AuthStatusResponse = Result<{ connected: boolean }>;\nexport type GetCalendarEventsResponse =\n  | { success: true; events: CalendarEvent[] }\n  | { success: false; needsAuth: true }\n  | { success: false; error: string };\n\nexport type CheckForUpdatesResponse = Result<{\n  updateAvailable: boolean;\n  currentVersion: string;\n  latestVersion: string;\n  releaseUrl: string;\n  releaseName: string;\n  downloadUrl: string | null;\n}>;\nexport type CheckAnnouncementsResponse = Result<{\n  announcements: Announcement[];\n  currentVersion: string;\n}>;\n\n// ---------- event payloads ----------\nexport interface SummaryChunkEvent {\n  chunk: string;\n  sessionName: string;\n}\nexport interface SummaryTitleEvent {\n  title: string;\n  sessionName: string;\n}\nexport interface SummaryCompleteEvent {\n  success: boolean;\n  sessionName: string;\n}\nexport interface ProcessingCompleteEvent {\n  success: boolean;\n  sessionName: string;\n  message: string;\n  meetingData?: Meeting;\n}\nexport interface QueryChunkEvent {\n  queryId: string;\n  chunk: string;\n}\nexport interface QueryDoneEvent {\n  queryId: string;\n  success: boolean;\n  error?: string;\n}\nexport interface ModelPullProgressEvent {\n  model: string;\n  progress: string;\n}\nexport interface ModelPullCompleteEvent {\n  model: string;\n  success: boolean;\n  error?: string;\n}\nexport interface UpdateAvailableEvent {\n  version: string;\n}\nexport interface UpdateProgressEvent {\n  percent: number;\n}\nexport interface UpdateDownloadedEvent {\n  version: string;\n}\nexport interface ShortcutStartRecordingEvent {\n  sessionName: string | null;\n}\n\n// ---------- bridge shape ----------\ntype RequestFn<Args extends unknown[], Res> = (...args: Args) => Promise<Res>;\ntype SendFn<Args extends unknown[]> = (...args: Args) => void;\ntype Subscribe<P = void> = (cb: (payload: P) => void) => () => void;\n\nexport interface StenoaiBridge {\n  version: number;\n\n  app: { getVersion: RequestFn<[], AppVersionResponse> };\n\n  window: { focus: SendFn<[]>; readyToShow: SendFn<[]> };\n\n  shell: {\n    openExternal: RequestFn<[string], Result<Record<string, never>>>;\n  };\n\n  system: {\n    getStatus: RequestFn<[], StatusResponse>;\n    test: RequestFn<[], Result<Record<string, never>>>;\n    clearState: RequestFn<[], Result<Record<string, never>>>;\n  };\n\n  setup: {\n    check: RequestFn<[], SetupCheckResponse>;\n    systemCheck: RequestFn<[], Result<Record<string, unknown>>>;\n    ffmpeg: RequestFn<[], Result<Record<string, unknown>>>;\n    python: RequestFn<[], Result<Record<string, unknown>>>;\n    ollamaAndModel: RequestFn<[], Result<Record<string, unknown>>>;\n    whisper: RequestFn<[], Result<Record<string, unknown>>>;\n    test: RequestFn<[], Result<Record<string, unknown>>>;\n    triggerWizard: RequestFn<[], Result<Record<string, unknown>>>;\n  };\n\n  perm: {\n    checkMicrophone: RequestFn<[], MicPermissionResponse>;\n    requestMicrophone: RequestFn<[], MicPermissionGrantResponse>;\n  };\n\n  recording: {\n    start: RequestFn<[name?: string], StartRecordingResponse>;\n    stop: RequestFn<[], StopRecordingResponse>;\n    pause: RequestFn<[], PauseRecordingResponse>;\n    resume: RequestFn<[], ResumeRecordingResponse>;\n    reportSystemAudioState: SendFn<[active: boolean]>;\n    processSystemAudio: RequestFn<[filePath: string, name: string], Result<{ message: string }>>;\n    processFile: RequestFn<[filePath: string, name: string], Result<{ message: string }>>;\n    pickAudioFile: RequestFn<[], PickAudioFileResponse>;\n    getQueue: RequestFn<[], QueueStatus | { success: false; error: string }>;\n    getDir: RequestFn<[], RecordingsDirResponse>;\n  };\n\n  meetings: {\n    list: RequestFn<[], ListMeetingsResponse>;\n    update: RequestFn<[summaryFile: string, patch: UpdateMeetingPatch], UpdateMeetingResponse>;\n    revealFolder: RequestFn<[filePath: string], Result<Record<string, never>>>;\n    delete: RequestFn<[meeting: Meeting], DeleteMeetingResponse>;\n    reprocess: RequestFn<\n      [summaryFile: string, regenTitle: boolean, name: string],\n      Result<{ message: string }>\n    >;\n    saveNotes: RequestFn<[name: string, notes: string], SaveMeetingNotesResponse>;\n    regenTitle: RequestFn<[summaryFile: string, name: string], Result<Record<string, never>>>;\n  };\n\n  query: {\n    ask: RequestFn<[file: string, q: string], QueryResponse>;\n    askStream: SendFn<[id: string, file: string, q: string]>;\n    chatGlobalStream: SendFn<[id: string, q: string, folderId?: string | null]>;\n    cancel: SendFn<[id: string]>;\n  };\n\n  chat: {\n    save: RequestFn<[data: ChatSessionsBlob], Result<Record<string, never>>>;\n    load: RequestFn<[], LoadChatSessionsResponse>;\n  };\n\n  folders: {\n    list: RequestFn<[], ListFoldersResponse>;\n    create: RequestFn<[name: string, color?: string], CreateFolderResponse>;\n    rename: RequestFn<[id: string, name: string], Result<Record<string, never>>>;\n    updateIcon: RequestFn<[id: string, icon: string], Result<Record<string, never>>>;\n    delete: RequestFn<[id: string], Result<Record<string, never>>>;\n    reorder: RequestFn<[ids: string[]], Result<Record<string, never>>>;\n    addMeeting: RequestFn<\n      [summaryFile: string, folderId: string],\n      Result<Record<string, never>>\n    >;\n    removeMeeting: RequestFn<\n      [summaryFile: string, folderId: string],\n      Result<Record<string, never>>\n    >;\n  };\n\n  models: {\n    checkOllama: RequestFn<[], CheckOllamaResponse>;\n    list: RequestFn<[], ListModelsResponse>;\n    getCurrent: RequestFn<[], GetCurrentModelResponse>;\n    set: RequestFn<[name: string], Result<Record<string, never>>>;\n    checkInstalled: RequestFn<[name: string], CheckModelInstalledResponse>;\n    pull: RequestFn<[name: string], Result<Record<string, never>>>;\n  };\n\n  settings: {\n    getNotifications: RequestFn<[], GetNotificationsResponse>;\n    setNotifications: RequestFn<[v: boolean], Result<Record<string, never>>>;\n    getTelemetry: RequestFn<[], GetTelemetryResponse>;\n    setTelemetry: RequestFn<[v: boolean], Result<Record<string, never>>>;\n    getDockIcon: RequestFn<[], GetDockIconResponse>;\n    setDockIcon: RequestFn<[v: boolean], Result<Record<string, never>>>;\n    getSystemAudio: RequestFn<[], GetSystemAudioResponse>;\n    setSystemAudio: RequestFn<[v: boolean], Result<Record<string, never>>>;\n    getLanguage: RequestFn<[], GetLanguageResponse>;\n    setLanguage: RequestFn<[code: string], Result<Record<string, never>>>;\n    getUserName: RequestFn<[], GetUserNameResponse>;\n    setUserName: RequestFn<[name: string], Result<Record<string, never>>>;\n    getStoragePath: RequestFn<[], StoragePathResponse>;\n    setStoragePath: RequestFn<[p: string], Result<Record<string, never>>>;\n    pickStorageFolder: RequestFn<[], PickStorageFolderResponse>;\n    getAiPrompts: RequestFn<[], GetAiPromptsResponse>;\n  };\n\n  ai: {\n    getProvider: RequestFn<[], GetAiProviderResponse>;\n    setProvider: RequestFn<[p: AiProvider], Result<Record<string, never>>>;\n    setRemoteOllamaUrl: RequestFn<[url: string], Result<Record<string, never>>>;\n    testRemoteOllama: RequestFn<[url: string], Result<{ ok: boolean; message?: string }>>;\n    setCloudApiUrl: RequestFn<[url: string], Result<Record<string, never>>>;\n    setCloudApiKey: RequestFn<[key: string], Result<Record<string, never>>>;\n    setCloudProvider: RequestFn<[p: CloudProvider], Result<Record<string, never>>>;\n    setCloudModel: RequestFn<[m: string], Result<Record<string, never>>>;\n    testCloudApi: RequestFn<[], Result<{ models?: string[] }>>;\n  };\n\n  calendar: {\n    google: {\n      connect: RequestFn<[], Result<Record<string, never>>>;\n      status: RequestFn<[], AuthStatusResponse>;\n      disconnect: RequestFn<[], Result<Record<string, never>>>;\n    };\n    outlook: {\n      connect: RequestFn<[], Result<Record<string, never>>>;\n      status: RequestFn<[], AuthStatusResponse>;\n      disconnect: RequestFn<[], Result<Record<string, never>>>;\n    };\n    getEvents: RequestFn<[], GetCalendarEventsResponse>;\n  };\n\n  updates: {\n    check: RequestFn<[], CheckForUpdatesResponse>;\n    announcements: RequestFn<[], CheckAnnouncementsResponse>;\n    openReleasePage: RequestFn<[url: string], Result<Record<string, never>>>;\n    install: SendFn<[]>;\n  };\n\n  shortcuts: {\n    rendererReady: SendFn<[]>;\n  };\n\n  on: {\n    debugLog: Subscribe<string>;\n    setupFlowTriggered: Subscribe<unknown>;\n    toggleRecordingHotkey: Subscribe<void>;\n    summaryChunk: Subscribe<SummaryChunkEvent>;\n    summaryTitle: Subscribe<SummaryTitleEvent>;\n    summaryComplete: Subscribe<SummaryCompleteEvent>;\n    processingComplete: Subscribe<ProcessingCompleteEvent>;\n    queryChunk: Subscribe<QueryChunkEvent>;\n    queryDone: Subscribe<QueryDoneEvent>;\n    modelPullProgress: Subscribe<ModelPullProgressEvent>;\n    modelPullComplete: Subscribe<ModelPullCompleteEvent>;\n    updateAvailable: Subscribe<UpdateAvailableEvent>;\n    updateDownloadProgress: Subscribe<UpdateProgressEvent>;\n    updateDownloaded: Subscribe<UpdateDownloadedEvent>;\n    googleAuthChanged: Subscribe<{ connected: boolean }>;\n    outlookAuthChanged: Subscribe<{ connected: boolean }>;\n    shortcutStartRecording: Subscribe<ShortcutStartRecordingEvent>;\n    shortcutStopRecording: Subscribe<void>;\n    trayStartRecording: Subscribe<void>;\n    trayStopRecording: Subscribe<void>;\n    trayOpenSettings: Subscribe<void>;\n    showQuitDialog: Subscribe<{ type: 'recording' | 'processing'; jobCount?: number }>;\n  };\n\n  dialog: {\n    respondQuit: SendFn<[confirmed: boolean]>;\n  };\n\n  subscribeQueryStream: (\n    queryId: string,\n    handlers: {\n      onChunk?: (chunk: string) => void;\n      onDone?: () => void;\n      onError?: (err: Error) => void;\n    }\n  ) => () => void;\n}\n\ndeclare global {\n  interface Window {\n    stenoai: StenoaiBridge;\n  }\n}\n\n/**\n * Accessor that asserts the bridge was actually installed. Throws a loud\n * error if the preload didn't run (e.g. someone forgot the contextIsolation\n * flag or loaded the renderer in a browser tab). Call this instead of\n * `window.stenoai.*` directly so bugs surface as messages, not\n * `Cannot read properties of undefined`.\n */\nexport function ipc(): StenoaiBridge {\n  if (typeof window === 'undefined' || !window.stenoai) {\n    throw new Error(\n      '[ipc] window.stenoai is not defined — preload did not run. ' +\n        'Check that main.js selected the new renderer webPreferences.'\n    );\n  }\n  return window.stenoai;\n}\n"
  },
  {
    "path": "app/renderer/src/lib/markdown.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils';\n\n// Lightweight markdown → React renderer for chat bubbles. Handles the\n// formatting LLMs actually produce in answers: headings, paragraphs,\n// bullet/numbered lists, bold/italic, inline code, fenced code blocks.\n// Intentionally NOT a full CommonMark parser — we trade completeness for\n// zero deps, predictable output, and safe-by-default React text rendering\n// (no innerHTML).\n\nexport function renderMarkdown(text: string): React.ReactNode {\n  if (!text) return null;\n\n  // Strip trailing \\r so CRLF input doesn't leave ghost characters at the end\n  // of every line — that breaks regex anchors and table cell parsing.\n  const lines = text.split('\\n').map((l) => l.replace(/\\r$/, ''));\n  const nodes: React.ReactNode[] = [];\n  let listItems: string[] = [];\n  let listType: 'ul' | 'ol' | null = null;\n  let codeLines: string[] = [];\n  let codeLang: string | null = null;\n  let inCode = false;\n  let lastWasGap = false;\n  let key = 0;\n\n  // Detect a markdown table starting at index i. Returns the table's row\n  // index range and the parsed rows + alignment, or null if it isn't one.\n  // Expected shape:\n  //   | h1 | h2 |\n  //   |----|:---:|\n  //   | a  | b  |\n  // The separator row distinguishes a real table from a bunch of pipes\n  // in regular text.\n  const detectTable = (\n    i: number,\n  ): { end: number; header: string[]; rows: string[][]; align: ('left' | 'center' | 'right' | null)[] } | null => {\n    if (i + 1 >= lines.length) return null;\n    const headerRaw = lines[i];\n    const sepRaw = lines[i + 1];\n    if (!isTableRow(headerRaw) || !isTableSeparator(sepRaw)) return null;\n    const header = splitRow(headerRaw);\n    const align = parseAlign(sepRaw);\n    if (header.length === 0 || align.length === 0) return null;\n    // Header and separator must agree on column count, otherwise the alignment\n    // array would have undefined slots and the table would be malformed.\n    if (header.length !== align.length) return null;\n\n    const rows: string[][] = [];\n    let j = i + 2;\n    while (j < lines.length && isTableRow(lines[j])) {\n      const cells = splitRow(lines[j]);\n      // Pad/truncate to header width so React doesn't render undefined cells.\n      while (cells.length < header.length) cells.push('');\n      if (cells.length > header.length) cells.length = header.length;\n      rows.push(cells);\n      j++;\n    }\n    // Require at least one data row — otherwise it's likely just two\n    // adjacent pipe-containing lines that aren't actually a table.\n    if (rows.length === 0) return null;\n    return { end: j - 1, header, rows, align };\n  };\n\n  const flushList = () => {\n    if (!listItems.length) return;\n    const Tag = listType ?? 'ul';\n    nodes.push(\n      <Tag\n        key={key++}\n        className={cn(\n          'my-1.5 space-y-0.5 pl-5',\n          Tag === 'ul' ? 'list-disc' : 'list-decimal',\n        )}\n      >\n        {listItems.map((item, i) => (\n          <li key={i}>{renderInline(item)}</li>\n        ))}\n      </Tag>,\n    );\n    listItems = [];\n    listType = null;\n    lastWasGap = false;\n  };\n\n  const flushCode = () => {\n    if (!inCode) return;\n    nodes.push(\n      <pre\n        key={key++}\n        className=\"my-2 overflow-x-auto rounded-md px-3 py-2 text-[12.5px]\"\n        style={{\n          background: 'var(--surface-active)',\n          fontFamily: 'var(--font-mono)',\n          color: 'var(--fg-1)',\n        }}\n        data-lang={codeLang || undefined}\n      >\n        <code>{codeLines.join('\\n')}</code>\n      </pre>,\n    );\n    codeLines = [];\n    codeLang = null;\n    inCode = false;\n    lastWasGap = false;\n  };\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i];\n    // Fenced code block: ```lang ... ```\n    const fence = line.match(/^```(\\w*)\\s*$/);\n    if (fence) {\n      if (inCode) {\n        flushCode();\n      } else {\n        flushList();\n        inCode = true;\n        codeLang = fence[1] || null;\n      }\n      continue;\n    }\n    if (inCode) {\n      codeLines.push(line);\n      continue;\n    }\n\n    // Markdown table. Detect on the header row, consume through the last\n    // data row, render as a real <table>. Tested before headings/lists\n    // so a leading-pipe table row never gets mistaken for something else.\n    if (line.trimStart().startsWith('|')) {\n      const tbl = detectTable(i);\n      if (tbl) {\n        flushList();\n        nodes.push(\n          <div key={key++} className=\"my-2 overflow-x-auto\">\n            <table\n              className=\"w-full border-collapse text-[13px]\"\n              style={{ fontFamily: 'var(--font-sans)' }}\n            >\n              <thead>\n                <tr>\n                  {tbl.header.map((cell, ci) => (\n                    <th\n                      key={ci}\n                      className=\"border-b px-2.5 py-1.5 text-left font-semibold\"\n                      style={{\n                        borderColor: 'var(--border-subtle)',\n                        color: 'var(--fg-1)',\n                        textAlign: tbl.align[ci] ?? 'left',\n                      }}\n                    >\n                      {renderInline(cell)}\n                    </th>\n                  ))}\n                </tr>\n              </thead>\n              <tbody>\n                {tbl.rows.map((row, ri) => (\n                  <tr key={ri}>\n                    {row.map((cell, ci) => (\n                      <td\n                        key={ci}\n                        className=\"border-b px-2.5 py-1.5 align-top\"\n                        style={{\n                          borderColor: 'var(--border-subtle)',\n                          color: 'var(--fg-1)',\n                          textAlign: tbl.align[ci] ?? 'left',\n                        }}\n                      >\n                        {renderInline(cell)}\n                      </td>\n                    ))}\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>,\n        );\n        lastWasGap = false;\n        i = tbl.end;\n        continue;\n      }\n    }\n\n    // Heading (# / ## / ###). h1 is reserved for page titles, so the LLM's\n    // top-level heading maps to h3 inside a bubble — keeps the visual\n    // hierarchy of the surrounding UI intact.\n    const h = line.match(/^(#{1,6})\\s+(.+)$/);\n    if (h) {\n      flushList();\n      const level = h[1].length;\n      const content = h[2];\n      const sizeClass =\n        level <= 1\n          ? 'mt-3 mb-1.5 text-[16px] font-semibold'\n          : level === 2\n            ? 'mt-3 mb-1.5 text-[15px] font-semibold'\n            : 'mt-2.5 mb-1 text-[14px] font-semibold';\n      nodes.push(\n        <div key={key++} className={sizeClass} style={{ color: 'var(--fg-1)' }}>\n          {renderInline(content)}\n        </div>,\n      );\n      lastWasGap = false;\n      continue;\n    }\n\n    // Bulleted item: -, *, • all accepted (LLMs sometimes use bullet glyph).\n    const ulMatch = line.match(/^\\s*[-*•]\\s+(.+)/);\n    const olMatch = line.match(/^\\s*\\d+\\.\\s+(.+)/);\n\n    if (ulMatch) {\n      if (listType === 'ol') flushList();\n      listType = 'ul';\n      listItems.push(ulMatch[1]);\n      continue;\n    }\n    if (olMatch) {\n      if (listType === 'ul') flushList();\n      listType = 'ol';\n      listItems.push(olMatch[1]);\n      continue;\n    }\n\n    flushList();\n    if (line.trim()) {\n      nodes.push(\n        <p key={key++} className=\"my-1.5\">\n          {renderInline(line)}\n        </p>,\n      );\n      lastWasGap = false;\n    } else if (nodes.length > 0 && !lastWasGap) {\n      // Blank line between blocks — collapse runs, but preserve one as a gap.\n      nodes.push(<div key={key++} className=\"h-2\" aria-hidden />);\n      lastWasGap = true;\n    }\n  }\n  flushList();\n  flushCode();\n\n  return <>{nodes}</>;\n}\n\n// ---------------------------------------------------------------------------\n// Table helpers\n// ---------------------------------------------------------------------------\n\nfunction isTableRow(line: string): boolean {\n  // A row starts with `|` (after optional whitespace) and contains at least\n  // one more `|`. Pure separator runs are excluded — those go through\n  // isTableSeparator.\n  const trimmed = line.trim();\n  if (!trimmed.startsWith('|')) return false;\n  // Need at least 2 pipes to delimit a single cell.\n  return (trimmed.match(/\\|/g) || []).length >= 2;\n}\n\nfunction isTableSeparator(line: string): boolean {\n  // Each cell is dashes with optional leading/trailing colons for alignment.\n  // Examples: |---|---|, | :--- | :---: | ---: |\n  const trimmed = line.trim();\n  if (!trimmed.startsWith('|')) return false;\n  const cells = splitRow(trimmed);\n  if (cells.length === 0) return false;\n  return cells.every((c) => /^:?-{3,}:?$/.test(c.trim()));\n}\n\nfunction splitRow(line: string): string[] {\n  // Walk the row character-by-character so we can treat `\\|` as a literal\n  // pipe inside cell content rather than a column boundary. Strip the\n  // outer pipes and any trailing empty cells (a row of `| a | b |` should\n  // not render a phantom third column).\n  const trimmed = line.trim().replace(/^\\|/, '').replace(/\\|$/, '');\n  const cells: string[] = [];\n  let buf = '';\n  for (let i = 0; i < trimmed.length; i++) {\n    const ch = trimmed[i];\n    if (ch === '\\\\' && trimmed[i + 1] === '|') {\n      buf += '|';\n      i++;\n      continue;\n    }\n    if (ch === '|') {\n      cells.push(buf.trim());\n      buf = '';\n      continue;\n    }\n    buf += ch;\n  }\n  cells.push(buf.trim());\n  while (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();\n  return cells;\n}\n\nfunction parseAlign(separator: string): ('left' | 'center' | 'right' | null)[] {\n  return splitRow(separator).map((cell) => {\n    const t = cell.trim();\n    const startsColon = t.startsWith(':');\n    const endsColon = t.endsWith(':');\n    if (startsColon && endsColon) return 'center';\n    if (endsColon) return 'right';\n    if (startsColon) return 'left';\n    return null;\n  });\n}\n\n// Inline markdown: **bold**, *italic*/_italic_, `code`. Order matters —\n// resolve code spans first so backticks inside don't get parsed as\n// emphasis, then bold (greedy double-star), then italic.\nexport function renderInline(text: string): React.ReactNode {\n  const out: React.ReactNode[] = [];\n  let key = 0;\n  // Match the next inline span: code, bold, or italic. The combined regex\n  // walks left-to-right so we don't double-process overlapping ranges.\n  const RE = /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(\\*[^*\\n]+\\*)|(_[^_\\n]+_)/g;\n  let last = 0;\n  let m: RegExpExecArray | null;\n  while ((m = RE.exec(text)) !== null) {\n    if (m.index > last) out.push(text.slice(last, m.index));\n    const tok = m[0];\n    if (tok.startsWith('`')) {\n      out.push(\n        <code\n          key={key++}\n          className=\"rounded px-1 py-px text-[0.9em]\"\n          style={{\n            background: 'var(--surface-active)',\n            fontFamily: 'var(--font-mono)',\n            color: 'var(--fg-1)',\n          }}\n        >\n          {tok.slice(1, -1)}\n        </code>,\n      );\n    } else if (tok.startsWith('**')) {\n      out.push(<strong key={key++}>{tok.slice(2, -2)}</strong>);\n    } else {\n      // *italic* or _italic_\n      out.push(<em key={key++}>{tok.slice(1, -1)}</em>);\n    }\n    last = m.index + tok.length;\n  }\n  if (last < text.length) out.push(text.slice(last));\n  if (out.length === 0) return text;\n  if (out.length === 1) return out[0];\n  return <>{out}</>;\n}\n"
  },
  {
    "path": "app/renderer/src/lib/meetingDetailState.ts",
    "content": "/**\n * Module-scoped state shared between MeetingDetail (classic) and\n * MeetingDetailV2 (new design). Keeping it here means:\n *   - title-regen spinners survive cross-component navigation (classic → v2)\n *   - partial streaming text restores when remounting either variant\n *   - toggling the design flag mid-session doesn't drop pending IPC promises\n */\n\nexport type StreamPhase = 'idle' | 'analyzing' | 'generating' | 'done';\n\nexport interface StreamState {\n  text: string;\n  phase: StreamPhase;\n}\n\nexport const streamCache = new Map<string, StreamState>();\nexport const pendingTitleRegens = new Map<string, Promise<void>>();\n"
  },
  {
    "path": "app/renderer/src/lib/meetingsListContext.tsx",
    "content": "import * as React from 'react';\nimport type { SidebarContextAction } from '@/components/Sidebar';\n\nexport interface MeetingsListActions {\n  /**\n   * Trigger the rename/delete context menu for a meeting. Hosted by\n   * MeetingsShellV2; called from any list row that wants to expose a\n   * right-click menu.\n   */\n  openMeetingContextMenu: (\n    meetingSummaryFile: string,\n    event: React.MouseEvent,\n  ) => void;\n  /**\n   * Begin a meeting drag. Sets the application/x-steno-meeting payload\n   * on the dataTransfer so SidebarV2 folder rows can pick it up.\n   */\n  startMeetingDrag: (\n    meetingSummaryFile: string,\n    event: React.DragEvent,\n  ) => void;\n}\n\nconst MeetingsListContext = React.createContext<MeetingsListActions | null>(null);\n\ninterface ProviderProps {\n  onContextAction: (action: SidebarContextAction) => void;\n  children: React.ReactNode;\n}\n\nexport function MeetingsListProvider({ onContextAction, children }: ProviderProps) {\n  const value = React.useMemo<MeetingsListActions>(\n    () => ({\n      openMeetingContextMenu: (id, e) => {\n        e.preventDefault();\n        const itemRect = e.currentTarget.getBoundingClientRect();\n        onContextAction({\n          type: 'meeting',\n          id,\n          clientX: e.clientX,\n          clientY: e.clientY,\n          itemRect,\n        });\n      },\n      startMeetingDrag: (id, e) => {\n        e.dataTransfer.setData('application/x-steno-meeting', id);\n        e.dataTransfer.effectAllowed = 'move';\n      },\n    }),\n    [onContextAction],\n  );\n\n  return (\n    <MeetingsListContext.Provider value={value}>{children}</MeetingsListContext.Provider>\n  );\n}\n\n/**\n * Returns null when used outside MeetingsListProvider — list-row primitives\n * should degrade gracefully (e.g. drag/context become no-ops).\n */\nexport function useMeetingsList(): MeetingsListActions | null {\n  return React.useContext(MeetingsListContext);\n}\n"
  },
  {
    "path": "app/renderer/src/lib/queryClient.ts",
    "content": "import { QueryClient } from '@tanstack/react-query';\n\nexport const queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 30_000,\n      refetchOnWindowFocus: false,\n      retry: 1,\n    },\n  },\n});\n"
  },
  {
    "path": "app/renderer/src/lib/result.ts",
    "content": "import type { Result } from './ipc';\n\nexport function unwrap<T>(result: Result<T>): T {\n  if (!result.success) throw new Error(result.error);\n  const { success: _success, ...rest } = result;\n  return rest as T;\n}\n"
  },
  {
    "path": "app/renderer/src/lib/router.ts",
    "content": "import * as React from 'react';\n\nexport function useHashRoute(): string {\n  const [hash, setHash] = React.useState(() =>\n    typeof window === 'undefined' ? '' : window.location.hash,\n  );\n  React.useEffect(() => {\n    const handler = () => setHash(window.location.hash);\n    window.addEventListener('hashchange', handler);\n    return () => window.removeEventListener('hashchange', handler);\n  }, []);\n  return hash;\n}\n\nexport function routeFromHash(hash: string): string {\n  const stripped = hash.replace(/^#/, '');\n  return stripped.length > 0 ? stripped : '/';\n}\n\nexport function useRoute(): string {\n  return routeFromHash(useHashRoute());\n}\n\nexport function navigate(path: string) {\n  if (typeof window === 'undefined') return;\n  const next = path.startsWith('/') ? path : `/${path}`;\n  if (window.location.hash === `#${next}`) return;\n  window.location.hash = next;\n}\n\nexport function useNavigate() {\n  return navigate;\n}\n\n// Tracks the most recent non-settings route so Settings's back button (and\n// the sidebar Settings toggle) can return the user to where they came from\n// instead of dumping them on Home. Module-level so it survives across\n// component remounts.\nlet lastNonSettingsRoute: string = '/';\n\nexport function rememberNonSettingsRoute(route: string) {\n  if (route !== '/settings') lastNonSettingsRoute = route;\n}\n\nexport function getLastNonSettingsRoute(): string {\n  return lastNonSettingsRoute;\n}\n\n/**\n * Toggle Settings: if already on /settings, return to the last non-settings\n * route the user was on. Otherwise stash the current route and navigate to\n * /settings.\n */\nexport function toggleSettings(currentRoute: string) {\n  if (currentRoute === '/settings') {\n    navigate(lastNonSettingsRoute || '/');\n  } else {\n    rememberNonSettingsRoute(currentRoute);\n    navigate('/settings');\n  }\n}\n"
  },
  {
    "path": "app/renderer/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport const isMac =\n  typeof navigator !== 'undefined' &&\n  /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);\n\n/**\n * Render a keyboard shortcut for the current platform. Pass mac glyphs and a\n * non-mac fallback; we pick the right one at call time. The Electron main\n * accelerator already auto-maps CommandOrControl, so this is purely cosmetic.\n */\nexport function shortcut(mac: string, other: string): string {\n  return isMac ? mac : other;\n}\n"
  },
  {
    "path": "app/renderer/src/main.tsx",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { QueryClientProvider } from '@tanstack/react-query';\nimport './globals.css';\nimport { App } from './App';\nimport { queryClient } from './lib/queryClient';\nimport { TooltipProvider } from './components/ui/tooltip';\n\nclass ErrorBoundary extends React.Component<\n  { children: React.ReactNode },\n  { error: Error | null }\n> {\n  constructor(props: { children: React.ReactNode }) {\n    super(props);\n    this.state = { error: null };\n  }\n  static getDerivedStateFromError(error: Error) {\n    return { error };\n  }\n  render() {\n    if (this.state.error) {\n      return (\n        <div style={{ padding: 32, fontFamily: 'monospace', whiteSpace: 'pre-wrap', color: 'red' }}>\n          <strong>Renderer crashed:</strong>{'\\n\\n'}\n          {this.state.error.stack ?? this.state.error.message}\n        </div>\n      );\n    }\n    return this.props.children;\n  }\n}\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n    <ErrorBoundary>\n      <QueryClientProvider client={queryClient}>\n        <TooltipProvider delayDuration={50}>\n          <App />\n        </TooltipProvider>\n      </QueryClientProvider>\n    </ErrorBoundary>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "app/renderer/src/routes/Chat.tsx",
    "content": "import * as React from 'react';\nimport {\n  ArrowUp,\n  ChevronRight,\n  Sparkles,\n} from 'lucide-react';\nimport { ChatHistoryRow } from '@/components/ChatHistoryRow';\nimport { FolderScopePicker } from '@/components/FolderScopePicker';\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n} from '@/components/ui/popover';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport {\n  useAllChatSessions,\n  useChatSessions,\n} from '@/hooks/useChatSessions';\nimport { useGlobalStreaming } from '@/hooks/useStreamingQuery';\nimport { useAiProvider } from '@/hooks/useAi';\nimport { useUserName } from '@/hooks/useSettings';\nimport { navigate } from '@/lib/router';\nimport { GLOBAL_SCOPE, bucketKey, deriveSessionName, toBucketLabel } from '@/lib/chat';\nimport { PRESETS, PresetGlyph } from '@/lib/chatPresets';\n\nexport function Chat() {\n  const allSessions = useAllChatSessions();\n  // Reuse useChatSessions's persist/createSession with the global sentinel\n  // so saves go through the same atomic-write path.\n  const chat = useChatSessions(GLOBAL_SCOPE, null);\n  const streaming = useGlobalStreaming();\n  const provider = useAiProvider();\n  const userName = useUserName();\n\n  const [input, setInput] = React.useState('');\n  const [presetsOpen, setPresetsOpen] = React.useState(false);\n  // Scope: null = ask across every note. Folder ID limits the corpus\n  // server-side. Default null so first-time users get the broadest\n  // possible answer.\n  const [scopeFolderId, setScopeFolderId] = React.useState<string | null>(null);\n  const submittingRef = React.useRef(false);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  const isCloud = provider.data?.ai_provider === 'cloud';\n  const cloudKeySet = provider.data?.cloud_api_key_set ?? false;\n  const ready = isCloud && cloudKeySet;\n\n  // Recents = global-scope chats only (sessions started from THIS tab),\n  // never the in-meeting AskBar history.\n  const allRecents = React.useMemo(() => {\n    const list = allSessions.data?.sessions ?? [];\n    return list\n      .filter((s) => s.summaryFile === GLOBAL_SCOPE)\n      .sort((a, b) => b.updatedAt - a.updatedAt);\n  }, [allSessions.data?.sessions]);\n\n  // Default view shows the 8 most-recent chats; \"See all\" expands to the\n  // full list grouped by time bucket (Today / Last 2 weeks / April / …).\n  // Hide the toggle entirely when the list already fits.\n  const COLLAPSED_LIMIT = 8;\n  const [recentsExpanded, setRecentsExpanded] = React.useState(false);\n  const canExpand = allRecents.length > COLLAPSED_LIMIT;\n  const recents = recentsExpanded ? allRecents : allRecents.slice(0, COLLAPSED_LIMIT);\n  const groupedRecents = React.useMemo(() => {\n    if (!recentsExpanded) return null;\n    const groups = new Map<string, typeof allRecents>();\n    const now = Date.now();\n    for (const s of allRecents) {\n      const k = bucketKey(s.updatedAt, now);\n      const arr = groups.get(k) ?? [];\n      arr.push(s);\n      groups.set(k, arr);\n    }\n    return Array.from(groups.entries()).map(([key, sessions]) => ({\n      key,\n      label: toBucketLabel(key),\n      sessions,\n    }));\n  }, [recentsExpanded, allRecents]);\n\n  const [submitError, setSubmitError] = React.useState<string | null>(null);\n  const submit = async (raw: string) => {\n    const q = raw.trim();\n    if (!q || submittingRef.current || !ready) return;\n    submittingRef.current = true;\n    setSubmitError(null);\n    let createdSessionId: string | null = null;\n    try {\n      createdSessionId = await chat.createSession(deriveSessionName(q));\n      await chat.appendMessage(createdSessionId, {\n        role: 'user',\n        content: q,\n        ts: Date.now(),\n      });\n      setInput('');\n      const streamId = streaming.startGlobalStream(q, scopeFolderId);\n      // Record the handoff under THIS sessionId so a fast double-submit\n      // can't clobber an earlier in-flight stream before the conversation\n      // page mounts and claims it.\n      recordPendingNewChat({ sessionId: createdSessionId, streamId, folderId: scopeFolderId });\n      navigate(`/chat/${encodeURIComponent(createdSessionId)}`);\n    } catch (err) {\n      // appendMessage / startGlobalStream / createSession can all fail\n      // (disk full, IPC error, cloud-key revoked). Surface the error,\n      // restore the user's text so they don't have to retype, and roll\n      // back the empty session so it doesn't appear in History/Recents.\n      const message = err instanceof Error ? err.message : 'Failed to send';\n      setSubmitError(message);\n      setInput(q);\n      if (createdSessionId) {\n        try {\n          await chat.deleteSession(createdSessionId);\n        } catch {\n          // best-effort cleanup\n        }\n      }\n    } finally {\n      submittingRef.current = false;\n    }\n  };\n\n  const onPickPreset = (prompt: string) => {\n    setInput(prompt);\n    setPresetsOpen(false);\n    inputRef.current?.focus();\n  };\n\n  const onSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    void submit(input);\n  };\n\n  return (\n    <MeetingsShell activeSummaryFile={null}>\n      <div className=\"mx-auto max-w-[640px] pt-14\">\n        <h1\n          className=\"mb-6\"\n          style={{\n            fontFamily: 'var(--font-serif)',\n            fontSize: 32,\n            lineHeight: 1.1,\n            letterSpacing: '-0.02em',\n            color: 'var(--fg-1)',\n          }}\n        >\n          {userName.data ? `Hi ${userName.data}, ask anything` : 'Ask anything'}\n        </h1>\n\n        {!ready && provider.isFetched && <CloudRequiredBanner />}\n        {submitError && (\n          <div\n            role=\"alert\"\n            className=\"mb-4 rounded-md border px-3 py-2 text-[13px]\"\n            style={{\n              borderColor: 'var(--border-subtle)',\n              background: 'var(--danger-bg)',\n              color: 'var(--danger)',\n            }}\n          >\n            {submitError}\n          </div>\n        )}\n\n        <Popover open={presetsOpen} onOpenChange={setPresetsOpen}>\n          <PopoverAnchor asChild>\n            <form\n              onSubmit={onSubmit}\n              className=\"mb-8 rounded-2xl border p-3 transition-shadow focus-within:shadow-[var(--shadow-md)]\"\n              style={{\n                borderColor: 'var(--border-subtle)',\n                background: 'var(--surface-raised)',\n                opacity: ready ? 1 : 0.6,\n              }}\n            >\n              <input\n                ref={inputRef}\n                type=\"text\"\n                value={input}\n                onChange={(e) => setInput(e.target.value)}\n                onKeyDown={(e) => {\n                  // Type \"/\" with an empty input → open the presets picker\n                  // (matches the Granola pattern). Don't insert the slash —\n                  // it's a shortcut character, not part of the prompt.\n                  if (e.key === '/' && input === '' && ready) {\n                    e.preventDefault();\n                    setPresetsOpen(true);\n                    return;\n                  }\n                  if (e.key === 'Enter' && !e.shiftKey) {\n                    e.preventDefault();\n                    void submit(input);\n                  }\n                }}\n                disabled={!ready}\n                placeholder={ready ? 'Summarise my meetings this week  /' : 'Connect a cloud provider in Settings to ask across notes'}\n                className=\"block w-full bg-transparent px-2 pb-3 pt-1 outline-none disabled:cursor-not-allowed\"\n                style={{ fontSize: 15, color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n              />\n          <div className=\"flex items-center justify-between gap-2 px-1\">\n            <div className=\"flex items-center gap-1\">\n              <FolderScopePicker value={scopeFolderId} onChange={setScopeFolderId} />\n              <span className=\"text-[12px]\" style={{ color: 'var(--fg-muted)' }}>\n                {provider.data?.cloud_provider\n                  ? `${provider.data.cloud_provider} · ${provider.data.cloud_model}`\n                  : 'Auto'}\n              </span>\n            </div>\n            <div className=\"flex items-center gap-1\">\n              <button\n                type=\"submit\"\n                disabled={!input.trim() || !ready}\n                className=\"inline-flex size-7 items-center justify-center rounded-full transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-40\"\n                style={{ color: 'var(--fg-1)' }}\n                aria-label=\"Send\"\n              >\n                <ArrowUp className=\"size-[14px]\" />\n              </button>\n            </div>\n          </div>\n        </form>\n          </PopoverAnchor>\n          <PopoverContent\n            align=\"start\"\n            sideOffset={8}\n            className=\"w-[var(--radix-popover-trigger-width)] max-w-none p-1\"\n            // Don't yank focus from the input when the popover opens — the\n            // user is mid-typing and Enter/Esc need to keep working there.\n            onOpenAutoFocus={(e) => e.preventDefault()}\n          >\n            <div className=\"px-2 pb-1 pt-0.5 text-[11px] font-medium\" style={{ color: 'var(--fg-muted)' }}>\n              Presets\n            </div>\n            <div className=\"flex flex-col\">\n              {PRESETS.map((p) => (\n                <button\n                  key={p.label}\n                  type=\"button\"\n                  onClick={() => onPickPreset(p.prompt)}\n                  className=\"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[color:var(--surface-hover)]\"\n                >\n                  <div className=\"flex items-center gap-2 text-[13px]\" style={{ color: 'var(--fg-1)' }}>\n                    <PresetGlyph />\n                    {p.label}\n                  </div>\n                  <div className=\"pl-[26px] text-[12px]\" style={{ color: 'var(--fg-2)' }}>\n                    {p.description}\n                  </div>\n                </button>\n              ))}\n            </div>\n          </PopoverContent>\n        </Popover>\n\n        <section className=\"mb-10\">\n          <SectionHead\n            title=\"Recents\"\n            action={\n              canExpand ? (\n                <button\n                  type=\"button\"\n                  onClick={() => setRecentsExpanded((v) => !v)}\n                  className=\"inline-flex items-center gap-0.5 text-[12px] transition-colors hover:text-[color:var(--fg-1)]\"\n                  style={{ color: 'var(--fg-2)' }}\n                >\n                  {recentsExpanded ? 'Show less' : 'See all'}\n                  <ChevronRight\n                    className={`size-[12px] transition-transform ${recentsExpanded ? 'rotate-90' : ''}`}\n                  />\n                </button>\n              ) : undefined\n            }\n          />\n          {allRecents.length === 0 ? (\n            <div\n              className=\"rounded-md border px-4 py-6 text-center text-[13px]\"\n              style={{\n                borderColor: 'var(--border-subtle)',\n                color: 'var(--fg-2)',\n                background: 'var(--surface-sunken)',\n              }}\n            >\n              Your past chats will show up here.\n            </div>\n          ) : recentsExpanded && groupedRecents ? (\n            <div className=\"flex flex-col\">\n              {groupedRecents.map((group) => (\n                <div key={group.key} className=\"mb-2 last:mb-0\">\n                  <div\n                    className=\"px-1 pb-1 pt-2 text-[11px] font-medium\"\n                    style={{ color: 'var(--fg-muted)' }}\n                  >\n                    {group.label}\n                  </div>\n                  {group.sessions.map((s) => (\n                    <ChatHistoryRow\n                      key={s.id}\n                      session={s}\n                      showTime\n                      onRename={(name) => void chat.renameSession(s.id, name)}\n                      onDelete={() => void chat.deleteSession(s.id)}\n                    />\n                  ))}\n                </div>\n              ))}\n            </div>\n          ) : (\n            <div className=\"flex flex-col\">\n              {recents.map((s) => (\n                <ChatHistoryRow\n                  key={s.id}\n                  session={s}\n                  showTime\n                  onRename={(name) => void chat.renameSession(s.id, name)}\n                  onDelete={() => void chat.deleteSession(s.id)}\n                />\n              ))}\n            </div>\n          )}\n        </section>\n\n        <section className=\"pb-12\">\n          <SectionHead title=\"Presets\" />\n          <div className=\"flex flex-wrap gap-2\">\n            {PRESETS.map((m) => (\n              <button\n                key={m.label}\n                type=\"button\"\n                onClick={() => onPickPreset(m.prompt)}\n                className=\"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n                style={{\n                  borderColor: 'var(--border-subtle)',\n                  color: 'var(--fg-1)',\n                  background: 'var(--surface-raised)',\n                }}\n              >\n                <PresetGlyph />\n                {m.label}\n              </button>\n            ))}\n          </div>\n        </section>\n      </div>\n    </MeetingsShell>\n  );\n}\n\n// Module-level handoff between the entry page (kicks off the stream right\n// before navigating) and the conversation page (picks up the stream id +\n// session id on mount). Avoids stuffing them in the URL.\n//\n// Keyed by sessionId so a fast double-submit can't clobber an earlier\n// in-flight handoff before the conversation page mounts to claim it.\nexport interface PendingNewChat {\n  sessionId: string;\n  streamId: string;\n  folderId: string | null;\n}\nconst pendingNewChats = new Map<string, PendingNewChat>();\n\nexport function recordPendingNewChat(pending: PendingNewChat) {\n  pendingNewChats.set(pending.sessionId, pending);\n}\n\nexport function consumePendingNewChat(sessionId: string): PendingNewChat | null {\n  const out = pendingNewChats.get(sessionId);\n  if (!out) return null;\n  pendingNewChats.delete(sessionId);\n  return out;\n}\n\nfunction CloudRequiredBanner() {\n  return (\n    <div\n      className=\"mb-4 flex items-start gap-3 rounded-md border px-3 py-2.5 text-[13px]\"\n      style={{\n        borderColor: 'var(--border-subtle)',\n        background: 'var(--surface-sunken)',\n        color: 'var(--fg-2)',\n      }}\n    >\n      <Sparkles className=\"mt-0.5 size-[14px] flex-shrink-0\" style={{ color: 'var(--fg-2)' }} />\n      <div className=\"flex-1\">\n        Cross-note chat needs a cloud AI provider — local models can't fit a\n        full-corpus prompt yet. Switch to OpenAI or Anthropic in{' '}\n        <button\n          type=\"button\"\n          className=\"underline transition-colors hover:text-[color:var(--fg-1)]\"\n          onClick={() => navigate('/settings')}\n          style={{ color: 'var(--fg-1)' }}\n        >\n          Settings → AI\n        </button>\n        .\n      </div>\n    </div>\n  );\n}\n\nfunction SectionHead({\n  title,\n  action,\n}: {\n  title: string;\n  action?: React.ReactNode;\n}) {\n  return (\n    <div className=\"mb-3 flex items-baseline justify-between\">\n      <h2 className=\"text-[13px] font-medium tracking-[-0.005em]\" style={{ color: 'var(--fg-1)' }}>\n        {title}\n      </h2>\n      {action}\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "app/renderer/src/routes/ChatConversation.tsx",
    "content": "import * as React from 'react';\nimport {\n  ArrowLeft,\n  ArrowUp,\n  ChevronDown,\n  Square,\n} from 'lucide-react';\nimport { FolderScopePicker } from '@/components/FolderScopePicker';\nimport { ChatHistoryRow } from '@/components/ChatHistoryRow';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { PRESETS, PresetGlyph } from '@/lib/chatPresets';\nimport {\n  useAllChatSessions,\n  useChatSessions,\n  type ChatMessage,\n} from '@/hooks/useChatSessions';\nimport { useGlobalStreaming } from '@/hooks/useStreamingQuery';\nimport { useAiProvider } from '@/hooks/useAi';\nimport { navigate } from '@/lib/router';\nimport {\n  GLOBAL_SCOPE,\n  bucketKey,\n  deriveSessionName,\n  toBucketLabel,\n} from '@/lib/chat';\nimport { consumePendingNewChat } from '@/routes/Chat';\nimport { renderMarkdown } from '@/lib/markdown';\n\ninterface ChatConversationProps {\n  sessionId: string;\n}\n\nexport function ChatConversation({ sessionId }: ChatConversationProps) {\n  const allSessions = useAllChatSessions();\n  const chat = useChatSessions(GLOBAL_SCOPE, null);\n  const streaming = useGlobalStreaming();\n  const provider = useAiProvider();\n\n  const [input, setInput] = React.useState('');\n  const [activeStreamId, setActiveStreamId] = React.useState<string | null>(null);\n  const [historyOpen, setHistoryOpen] = React.useState(false);\n  const [presetsOpen, setPresetsOpen] = React.useState(false);\n  // Folder scope persists for the lifetime of the conversation page mount.\n  // The entry page's scope is handed off via consumePendingNewChat; later\n  // turns in the same conversation can be re-scoped from this composer.\n  const [scopeFolderId, setScopeFolderId] = React.useState<string | null>(null);\n  const pendingPersistRef = React.useRef<string | null>(null);\n  const submittingRef = React.useRef(false);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n\n  const isCloud = provider.data?.ai_provider === 'cloud';\n  const cloudKeySet = provider.data?.cloud_api_key_set ?? false;\n  const ready = isCloud && cloudKeySet;\n\n  // Make THIS session the active one as soon as the route mounts so\n  // chat.activeSession / chat.appendMessage operate on the right record\n  // instead of whichever one useChatSessions's auto-restore landed on.\n  React.useEffect(() => {\n    chat.setActiveId(sessionId);\n  }, [sessionId, chat]);\n\n  // Pick up an in-flight stream the entry page kicked off right before\n  // navigating, so we don't lose its tokens during the route change.\n  // The entry page's chosen scope rides along with the handoff so the\n  // composer here starts with the same folder context.\n  React.useEffect(() => {\n    const pending = consumePendingNewChat(sessionId);\n    if (pending) {\n      pendingPersistRef.current = pending.sessionId;\n      setActiveStreamId(pending.streamId);\n      setScopeFolderId(pending.folderId);\n    }\n  }, [sessionId]);\n\n  const session = React.useMemo(() => {\n    const list = allSessions.data?.sessions ?? [];\n    return list.find((s) => s.id === sessionId) ?? null;\n  }, [allSessions.data?.sessions, sessionId]);\n\n  const otherSessions = React.useMemo(() => {\n    const list = allSessions.data?.sessions ?? [];\n    return list\n      .filter((s) => s.summaryFile === GLOBAL_SCOPE)\n      .sort((a, b) => b.updatedAt - a.updatedAt);\n  }, [allSessions.data?.sessions]);\n\n  // Group sessions into time buckets for the History dropdown (\"Today\",\n  // \"Last 2 weeks\", \"April\", etc.) — same pattern Granola uses. Order is\n  // determined by the highest updatedAt in each group, so a stale \"April\"\n  // group sinks below a fresh \"Today\" automatically.\n  const groupedSessions = React.useMemo(() => {\n    const groups = new Map<string, typeof otherSessions>();\n    const now = Date.now();\n    for (const s of otherSessions) {\n      const k = bucketKey(s.updatedAt, now);\n      const arr = groups.get(k) ?? [];\n      arr.push(s);\n      groups.set(k, arr);\n    }\n    return Array.from(groups.entries()).map(([key, sessions]) => ({\n      key,\n      label: toBucketLabel(key),\n      sessions,\n    }));\n  }, [otherSessions]);\n\n  const activeStream = activeStreamId ? streaming.streams[activeStreamId] : null;\n  const isStreaming = activeStream?.status === 'streaming';\n\n  // Persist the assistant turn when its stream finishes.\n  React.useEffect(() => {\n    if (!activeStreamId) return;\n    const stream = streaming.streams[activeStreamId];\n    if (!stream || stream.status === 'streaming') return;\n    const persistId = pendingPersistRef.current;\n    if (!persistId) return;\n    const content =\n      stream.text.trim() ||\n      (stream.status === 'error'\n        ? `Error: ${stream.error ?? 'query failed'}`\n        : '(empty response)');\n    const message: ChatMessage = {\n      role: 'assistant',\n      content,\n      ts: Date.now(),\n    };\n    void chat.appendMessage(persistId, message);\n    pendingPersistRef.current = null;\n    streaming.clearStream(activeStreamId);\n    setActiveStreamId(null);\n  }, [activeStreamId, streaming, chat]);\n\n  // Keep the conversation scrolled to the bottom on new content.\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [session?.messages.length, activeStream?.text]);\n\n  const [submitError, setSubmitError] = React.useState<string | null>(null);\n  const submit = async (raw: string) => {\n    const q = raw.trim();\n    if (!q || isStreaming || submittingRef.current || !ready || !session) return;\n    submittingRef.current = true;\n    setSubmitError(null);\n    let appended = false;\n    try {\n      await chat.appendMessage(session.id, {\n        role: 'user',\n        content: q,\n        ts: Date.now(),\n      });\n      appended = true;\n      setInput('');\n      const streamId = streaming.startGlobalStream(q, scopeFolderId);\n      pendingPersistRef.current = session.id;\n      setActiveStreamId(streamId);\n    } catch (err) {\n      // Disk write / IPC / streaming setup can all fail. Surface the error.\n      // Only restore the input if nothing made it to disk — once the user\n      // message is persisted it's already visible in the thread, and\n      // re-populating the box would duplicate it on the next submit.\n      const message = err instanceof Error ? err.message : 'Failed to send';\n      setSubmitError(message);\n      if (!appended) setInput(q);\n    } finally {\n      submittingRef.current = false;\n    }\n  };\n\n  const stop = () => {\n    if (!activeStreamId) return;\n    streaming.cancelStream(activeStreamId);\n  };\n\n  const onSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    void submit(input);\n  };\n\n  const liveText = isStreaming ? activeStream?.text ?? '' : '';\n\n  // Session might not exist yet on cold reload (allSessions is still\n  // loading). Show a soft loading state rather than dumping the user\n  // back to /chat — the URL is the source of truth.\n  if (!session && allSessions.isFetched) {\n    return (\n      <MeetingsShell activeSummaryFile={null}>\n        <div className=\"mx-auto max-w-[640px] py-20 text-center\">\n          <h1 className=\"mv-title mb-3\">Chat not found.</h1>\n          <p className=\"text-[14px]\" style={{ color: 'var(--fg-2)' }}>\n            This conversation may have been deleted.\n          </p>\n          <button\n            type=\"button\"\n            onClick={() => navigate('/chat')}\n            className=\"mt-4 inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n            style={{ color: 'var(--fg-1)', background: 'var(--surface-raised)' }}\n          >\n            <ArrowLeft className=\"size-[13px]\" />\n            Back to Chat\n          </button>\n        </div>\n      </MeetingsShell>\n    );\n  }\n\n  return (\n    // bleed: skip AppShell's centered scroll wrapper (which has pb-36 baked\n    // in) so we can own the layout — flex column with a scrolling message\n    // area + composer pinned to the bottom of the viewport with no padding\n    // gap underneath.\n    <MeetingsShell activeSummaryFile={null} bleed>\n      <div className=\"flex min-h-0 flex-1 flex-col\" style={{ background: 'var(--page)' }}>\n        {/* Toolbar — back, History dropdown, New chat. */}\n        <div className=\"mx-auto flex w-full max-w-[760px] items-center justify-between gap-2 px-10 pb-3 pt-2\">\n          <div className=\"flex items-center gap-1\">\n            <button\n              type=\"button\"\n              onClick={() => navigate('/chat')}\n              className=\"inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)]\"\n              style={{ color: 'var(--fg-2)' }}\n              aria-label=\"Back to Chat\"\n              title=\"Back to Chat\"\n            >\n              <ArrowLeft className=\"size-[15px]\" />\n            </button>\n\n            <Popover open={historyOpen} onOpenChange={setHistoryOpen}>\n              <PopoverTrigger asChild>\n                <button\n                  type=\"button\"\n                  className=\"ml-1 inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-[13px] transition-colors hover:bg-[color:var(--surface-hover)]\"\n                  style={{\n                    borderColor: 'var(--border-subtle)',\n                    color: 'var(--fg-1)',\n                    background: 'var(--surface-raised)',\n                  }}\n                  aria-label=\"Switch chat\"\n                >\n                  History\n                  <ChevronDown className=\"size-[12px]\" style={{ color: 'var(--fg-2)' }} />\n                </button>\n              </PopoverTrigger>\n              <PopoverContent align=\"start\" className=\"w-[320px] p-1\">\n                {otherSessions.length === 0 ? (\n                  <div className=\"px-3 py-3 text-[13px]\" style={{ color: 'var(--fg-2)' }}>\n                    No other chats yet.\n                  </div>\n                ) : (\n                  <div className=\"max-h-[420px] overflow-y-auto py-1\">\n                    {/* Few chats → flat list, no group headers (less visual\n                        noise). Many chats → grouped by time bucket so old\n                        ones are findable. */}\n                    {otherSessions.length <= 5 ? (\n                      otherSessions.map((s) => (\n                        <ChatHistoryRow\n                          key={s.id}\n                          session={s}\n                          activeId={sessionId}\n                          onSelect={() => setHistoryOpen(false)}\n                          onRename={(name) => void chat.renameSession(s.id, name)}\n                          onDelete={async () => {\n                            const wasActive = s.id === sessionId;\n                            await chat.deleteSession(s.id);\n                            if (wasActive) navigate('/chat');\n                          }}\n                        />\n                      ))\n                    ) : (\n                      groupedSessions.map((group) => (\n                        <div key={group.key} className=\"mb-1.5 last:mb-0\">\n                          <div\n                            className=\"px-2 pb-0.5 pt-1 text-[11px] font-medium\"\n                            style={{ color: 'var(--fg-muted)' }}\n                          >\n                            {group.label}\n                          </div>\n                          {group.sessions.map((s) => (\n                            <ChatHistoryRow\n                              key={s.id}\n                              session={s}\n                              activeId={sessionId}\n                              onSelect={() => setHistoryOpen(false)}\n                              onRename={(name) => void chat.renameSession(s.id, name)}\n                              onDelete={async () => {\n                                const wasActive = s.id === sessionId;\n                                await chat.deleteSession(s.id);\n                                if (wasActive) navigate('/chat');\n                              }}\n                            />\n                          ))}\n                        </div>\n                      ))\n                    )}\n                  </div>\n                )}\n              </PopoverContent>\n            </Popover>\n          </div>\n\n          {/* \"New chat\" lives in the global toolbar (route-aware \"+ New\" pill)\n              when on chat routes, so we don't repeat it here. */}\n        </div>\n\n        {/* Scrolling message area. flex-1 takes all remaining vertical space\n            so the composer below it renders at the actual viewport bottom\n            with no empty band underneath. */}\n        <div\n          ref={scrollRef}\n          className=\"scrollbar-clean min-h-0 flex-1 overflow-y-auto\"\n          style={{ scrollbarGutter: 'stable' }}\n        >\n          <div className=\"mx-auto flex w-full max-w-[760px] flex-col gap-5 px-10 pb-6 pt-2\">\n            {(session?.messages ?? []).map((m, i) => (\n              <Bubble key={i} role={m.role} content={m.content} />\n            ))}\n            {isStreaming && (\n              <Bubble role=\"assistant\" content={liveText || 'Thinking…'} live />\n            )}\n          </div>\n        </div>\n\n        {/* Composer pinned at the visual bottom — out of the scroll\n            container, in the flex column. No leftover padding underneath. */}\n        <div className=\"mx-auto w-full max-w-[760px] px-10 pb-6 pt-2\">\n          {submitError && (\n            <div\n              role=\"alert\"\n              className=\"mb-3 rounded-md border px-3 py-2 text-[13px]\"\n              style={{\n                borderColor: 'var(--border-subtle)',\n                background: 'var(--danger-bg)',\n                color: 'var(--danger)',\n              }}\n            >\n              {submitError}\n            </div>\n          )}\n          <Popover open={presetsOpen} onOpenChange={setPresetsOpen}>\n            <PopoverAnchor asChild>\n          <form\n            onSubmit={onSubmit}\n            className=\"rounded-2xl border p-3 transition-shadow focus-within:shadow-[var(--shadow-md)]\"\n            style={{\n              borderColor: 'var(--border-subtle)',\n              background: 'var(--surface-raised)',\n            }}\n          >\n          <input\n            ref={inputRef}\n            type=\"text\"\n            value={input}\n            onChange={(e) => setInput(e.target.value)}\n            onKeyDown={(e) => {\n              // Same '/' shortcut as the entry page — opens the preset\n              // picker when the input is empty so a literal slash typed\n              // mid-sentence doesn't surprise the user.\n              if (e.key === '/' && input === '' && ready && !isStreaming) {\n                e.preventDefault();\n                setPresetsOpen(true);\n                return;\n              }\n              if (e.key === 'Enter' && !e.shiftKey && !isStreaming) {\n                e.preventDefault();\n                void submit(input);\n              }\n            }}\n            disabled={!ready || isStreaming}\n            placeholder=\"Ask anything  /\"\n            className=\"block w-full bg-transparent px-2 pb-3 pt-1 outline-none disabled:cursor-not-allowed\"\n            style={{ fontSize: 15, color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n          />\n          <div className=\"flex items-center justify-between gap-2 px-1\">\n            <div className=\"flex items-center gap-1\">\n              <FolderScopePicker value={scopeFolderId} onChange={setScopeFolderId} />\n              <span className=\"text-[12px]\" style={{ color: 'var(--fg-muted)' }}>\n                {provider.data?.cloud_provider\n                  ? `${provider.data.cloud_provider} · ${provider.data.cloud_model}`\n                  : 'Auto'}\n              </span>\n            </div>\n            <div className=\"flex items-center gap-1\">\n              {isStreaming ? (\n                <button\n                  type=\"button\"\n                  onClick={stop}\n                  className=\"inline-flex size-7 items-center justify-center rounded-full transition-colors hover:bg-[color:var(--surface-hover)]\"\n                  style={{ color: 'var(--fg-1)' }}\n                  aria-label=\"Stop\"\n                >\n                  <Square className=\"size-[12px]\" fill=\"currentColor\" />\n                </button>\n              ) : (\n                <button\n                  type=\"submit\"\n                  disabled={!input.trim() || !ready}\n                  className=\"inline-flex size-7 items-center justify-center rounded-full transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-40\"\n                  style={{ color: 'var(--fg-1)' }}\n                  aria-label=\"Send\"\n                >\n                  <ArrowUp className=\"size-[14px]\" />\n                </button>\n              )}\n            </div>\n          </div>\n          </form>\n            </PopoverAnchor>\n            <PopoverContent\n              align=\"start\"\n              side=\"top\"\n              sideOffset={8}\n              className=\"w-[var(--radix-popover-trigger-width)] max-w-none p-1\"\n              onOpenAutoFocus={(e) => e.preventDefault()}\n            >\n              <div\n                className=\"px-2 pb-1 pt-0.5 text-[11px] font-medium\"\n                style={{ color: 'var(--fg-muted)' }}\n              >\n                Presets\n              </div>\n              <div className=\"flex flex-col\">\n                {PRESETS.map((p) => (\n                  <button\n                    key={p.label}\n                    type=\"button\"\n                    onClick={() => {\n                      setInput(p.prompt);\n                      setPresetsOpen(false);\n                      inputRef.current?.focus();\n                    }}\n                    className=\"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[color:var(--surface-hover)]\"\n                  >\n                    <div\n                      className=\"flex items-center gap-2 text-[13px]\"\n                      style={{ color: 'var(--fg-1)' }}\n                    >\n                      <PresetGlyph />\n                      {p.label}\n                    </div>\n                    <div className=\"pl-[26px] text-[12px]\" style={{ color: 'var(--fg-2)' }}>\n                      {p.description}\n                    </div>\n                  </button>\n                ))}\n              </div>\n            </PopoverContent>\n          </Popover>\n        </div>\n      </div>\n    </MeetingsShell>\n  );\n}\n\n\nfunction Bubble({\n  role,\n  content,\n  live,\n}: {\n  role: 'user' | 'assistant';\n  content: string;\n  live?: boolean;\n}) {\n  const isUser = role === 'user';\n  return (\n    <div className={isUser ? 'flex justify-end' : 'flex'}>\n      <div\n        className={`chat-bubble max-w-[85%] rounded-2xl px-4 py-3 text-[14px] leading-[1.55] ${live ? 'animate-pulse' : ''}`}\n        style={{\n          background: isUser ? 'var(--surface-active)' : 'var(--surface-sunken)',\n          color: 'var(--fg-1)',\n          fontFamily: 'var(--font-sans)',\n        }}\n      >\n        {/* User turns are typed plain text — render as-is to preserve any\n            literal asterisks/backticks. Assistant turns get full markdown. */}\n        {isUser ? (\n          <span style={{ whiteSpace: 'pre-wrap' }}>{content}</span>\n        ) : (\n          renderMarkdown(content)\n        )}\n      </div>\n    </div>\n  );\n}\n\n// Re-export so callers don't need to know about deriveSessionName here.\nexport { deriveSessionName };\n"
  },
  {
    "path": "app/renderer/src/routes/FolderDetail.tsx",
    "content": "import * as React from 'react';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { PreviousRow } from '@/components/home/PreviousRow';\nimport { useMeetings } from '@/hooks/useMeetings';\nimport { useFolders, useUpdateFolderIcon } from '@/hooks/useFolders';\nimport { LucideIcon, IconPicker } from '@/components/IconPicker';\nimport { navigate } from '@/lib/router';\n\ninterface FolderDetailProps {\n  folderId: string;\n}\n\nexport function FolderDetail({ folderId }: FolderDetailProps) {\n  const meetings = useMeetings();\n  const folders = useFolders();\n  const updateIcon = useUpdateFolderIcon();\n  const [iconPickerAnchor, setIconPickerAnchor] = React.useState<DOMRect | null>(null);\n\n  const folder = folders.data?.find((f) => f.id === folderId);\n  const filtered = (meetings.data ?? []).filter((m) =>\n    (m.folders ?? m.session_info.folders ?? []).includes(folderId),\n  );\n\n  const isLoading = meetings.isLoading || folders.isLoading;\n\n  return (\n    <MeetingsShell activeSummaryFile={null}>\n      {isLoading ? (\n        <div className=\"flex min-h-[40vh] items-center justify-center text-[color:var(--fg-2)]\">\n          Loading folder…\n        </div>\n      ) : !folder ? (\n        <div className=\"space-y-4 text-center\">\n          <h1 className=\"home-hello\">Folder not found.</h1>\n          <p className=\"text-sm\" style={{ color: 'var(--fg-2)' }}>\n            This folder may have been deleted.{' '}\n            <button\n              type=\"button\"\n              className=\"underline\"\n              onClick={() => navigate('/')}\n              style={{ color: 'var(--fg-1)' }}\n            >\n              Back to Home\n            </button>\n            .\n          </p>\n        </div>\n      ) : (\n        <>\n          <div className=\"mb-10\">\n            <div className=\"mb-1.5 flex items-end justify-between gap-6\">\n              <h1 className=\"home-hello flex items-center gap-3.5\">\n                <button\n                  type=\"button\"\n                  aria-label=\"Change folder icon\"\n                  className=\"inline-flex h-10 w-10 items-center justify-center rounded-lg transition-colors hover:bg-[color:var(--surface-active)]\"\n                  style={{ background: 'var(--surface-hover)', color: 'var(--fg-1)', flexShrink: 0 }}\n                  onClick={(e) => setIconPickerAnchor(e.currentTarget.getBoundingClientRect())}\n                >\n                  <LucideIcon name={folder.icon ?? 'folder'} size={20} />\n                </button>\n                {folder.name}\n              </h1>\n              {iconPickerAnchor && (\n                <IconPicker\n                  anchorRect={iconPickerAnchor}\n                  onSelect={(icon) => updateIcon.mutate({ id: folderId, icon })}\n                  onClose={() => setIconPickerAnchor(null)}\n                />\n              )}\n              <div\n                className=\"pb-2 text-[13px] tabular-nums\"\n                style={{ color: 'var(--fg-2)' }}\n              >\n                {filtered.length} {filtered.length === 1 ? 'meeting' : 'meetings'}\n              </div>\n            </div>\n          </div>\n\n          <section>\n            <div className=\"mb-3.5 flex items-baseline justify-between pb-2.5\">\n              <div className=\"flex items-baseline gap-2.5\">\n                <h2\n                  className=\"text-sm font-medium tracking-[-0.005em]\"\n                  style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n                >\n                  Meetings\n                </h2>\n                <span\n                  className=\"text-[12.5px] tabular-nums\"\n                  style={{ color: 'var(--fg-muted)' }}\n                >\n                  {filtered.length}\n                </span>\n              </div>\n            </div>\n\n            {filtered.length === 0 ? (\n              <div className=\"px-6 py-24 text-center\" style={{ color: 'var(--fg-2)' }}>\n                <div\n                  className=\"mb-1.5\"\n                  style={{\n                    fontFamily: 'var(--font-serif)',\n                    fontSize: 24,\n                    color: 'var(--fg-1)',\n                    letterSpacing: '-0.02em',\n                  }}\n                >\n                  Nothing here yet\n                </div>\n                <div\n                  className=\"mx-auto max-w-[40ch] text-[13.5px] leading-[1.55]\"\n                  style={{ color: 'var(--fg-2)' }}\n                >\n                  Meetings you save to this folder will show up here.\n                </div>\n              </div>\n            ) : (\n              <div>\n                {filtered.map((m) => (\n                  <PreviousRow\n                    key={m.session_info.summary_file}\n                    meeting={m}\n                  />\n                ))}\n              </div>\n            )}\n          </section>\n        </>\n      )}\n    </MeetingsShell>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/routes/Home.tsx",
    "content": "import * as React from 'react';\nimport { PencilLine, RefreshCw, Search, Square, X } from 'lucide-react';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { UpcomingCard } from '@/components/home/UpcomingCard';\nimport { PreviousRow } from '@/components/home/PreviousRow';\nimport { Button } from '@/components/ui/button';\nimport { AppIcon } from '@/components/ui/app-icon';\nimport { KbdKey } from '@/components/ui/kbd';\nimport { useMeetings } from '@/hooks/useMeetings';\nimport { useRecording } from '@/hooks/useRecording';\nimport { useCalendarEvents } from '@/hooks/useCalendarEvents';\nimport { useFolders } from '@/hooks/useFolders';\nimport type { Meeting } from '@/lib/ipc';\nimport { shortcut } from '@/lib/utils';\nimport { navigate } from '@/lib/router';\n\ninterface HomeProps {\n  mode: 'home' | 'meetings';\n}\n\nexport function Home({ mode }: HomeProps) {\n  const meetings = useMeetings();\n  const folders = useFolders();\n  const calendar = useCalendarEvents();\n  const recording = useRecording();\n\n  const emptyState = !meetings.data?.length;\n  const isRecording = recording.status === 'recording' || recording.status === 'paused';\n  // Empty-state CTA: idle → start (auto-navigates); recording/paused → back to /recording.\n  const onToggleRecording = () => {\n    if (recording.status === 'idle') {\n      void recording.startRecording();\n    } else if (isRecording) {\n      navigate('/recording');\n    }\n  };\n\n  const folderName = React.useMemo(() => {\n    const map = new Map<string, string>();\n    for (const f of folders.data ?? []) map.set(f.id, f.name);\n    return map;\n  }, [folders.data]);\n\n  const upcoming =\n    calendar.data && !calendar.data.needsAuth ? calendar.data.events.slice(0, 3) : [];\n\n  const previous = meetings.data ?? [];\n\n  // Search applies only to /meetings (the All meetings list). Home keeps the\n  // unfiltered Previous list since it's already chronologically grouped.\n  const [search, setSearch] = React.useState('');\n  const searchInputRef = React.useRef<HTMLInputElement>(null);\n  const filtered = React.useMemo(() => {\n    if (mode !== 'meetings') return previous;\n    const needle = search.trim().toLowerCase();\n    if (!needle) return previous;\n    return previous.filter((m) => {\n      const name = m.session_info.name?.toLowerCase() ?? '';\n      const summary = m.summary?.toLowerCase() ?? '';\n      return name.includes(needle) || summary.includes(needle);\n    });\n  }, [mode, previous, search]);\n  const groups = React.useMemo(() => groupPrevious(filtered), [filtered]);\n\n  const greeting = `Ready to capture beautiful notes`;\n  const dateStr = new Date().toLocaleDateString(undefined, {\n    weekday: 'long',\n    month: 'long',\n    day: 'numeric',\n  });\n\n  return (\n    <MeetingsShell\n      activeSummaryFile={null}\n      contentAlign={emptyState && mode === 'home' ? 'center' : 'top'}\n    >\n      {meetings.isLoading ? (\n        <div className=\"flex min-h-[40vh] items-center justify-center text-[color:var(--fg-2)]\">\n          Loading meetings…\n        </div>\n      ) : emptyState ? (\n        <div className=\"flex flex-col items-center gap-8 text-center\">\n          <AppIcon size={56} />\n          <div className=\"space-y-3\">\n            <h1\n              style={{\n                fontFamily: 'var(--font-serif)',\n                fontSize: 44,\n                lineHeight: 1.1,\n                letterSpacing: '-0.025em',\n                color: 'var(--fg-1)',\n              }}\n            >\n              Welcome to StenoAI.\n            </h1>\n            <p\n              className=\"text-[17px] leading-[1.55]\"\n              style={{ color: 'var(--fg-2)' }}\n            >\n              Capture your first meeting — transcription and summaries happen\n              locally on your Mac.\n            </p>\n            <p className=\"text-sm\" style={{ color: 'var(--fg-muted)' }}>\n              Always get consent when transcribing others.\n            </p>\n          </div>\n          <div className=\"flex flex-col items-center gap-3\">\n            <Button\n              variant={isRecording ? 'destructive' : 'default'}\n              onClick={onToggleRecording}\n              className=\"gap-2\"\n            >\n              {isRecording ? <Square className=\"size-4\" /> : <PencilLine className=\"size-4\" />}\n              {isRecording ? 'Stop recording' : 'New note'}\n            </Button>\n            <p\n              className=\"flex items-center gap-1.5 text-xs\"\n              style={{ color: 'var(--fg-muted)' }}\n            >\n              <span>Quick start:</span>\n              <KbdKey>⌘</KbdKey>\n              <KbdKey>⇧</KbdKey>\n              <KbdKey>R</KbdKey>\n              <span>from anywhere</span>\n            </p>\n          </div>\n        </div>\n      ) : (\n        <>\n          {mode === 'home' && (\n            <div className=\"mb-10\">\n              <div className=\"mb-1.5 flex items-end justify-between gap-6\">\n                <h1 className=\"home-hello\">\n                  {greeting}\n                  <span className=\"faint\">.</span>\n                </h1>\n                <div\n                  className=\"pb-2 text-[13px] tabular-nums\"\n                  style={{ color: 'var(--fg-2)' }}\n                >\n                  {dateStr}\n                </div>\n              </div>\n              <p\n                className=\"max-w-[52ch] text-sm leading-[1.55]\"\n                style={{ color: 'var(--fg-2)' }}\n              >\n                {summaryLine(upcoming.length)}\n              </p>\n            </div>\n          )}\n\n          {upcoming.length > 0 && mode === 'home' && (\n            <section className=\"mb-10\">\n              <SectionHead\n                title=\"Upcoming\"\n                count={upcoming.length}\n                action={\n                  <button\n                    type=\"button\"\n                    className=\"inline-flex items-center rounded p-0.5 transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-50\"\n                    title=\"Check for new calendar events\"\n                    onClick={() => calendar.refetch()}\n                    disabled={calendar.isFetching}\n                    style={{ color: 'var(--fg-2)' }}\n                  >\n                    <RefreshCw className={`size-3 ${calendar.isFetching ? 'animate-spin' : ''}`} />\n                  </button>\n                }\n              />\n              <div className=\"flex flex-col gap-2\">\n                {upcoming.map((event) => (\n                  <UpcomingCard key={event.id} event={event} />\n                ))}\n              </div>\n            </section>\n          )}\n\n          <section>\n            <SectionHead\n              title={mode === 'meetings' ? 'All notes' : 'Previous'}\n              count={mode === 'meetings' ? filtered.length : previous.length}\n              action={\n                mode === 'meetings' ? (\n                  <div className=\"relative\">\n                    <Search\n                      className=\"pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 size-[12px]\"\n                      style={{ color: 'var(--fg-muted)' }}\n                    />\n                    <input\n                      ref={searchInputRef}\n                      type=\"text\"\n                      value={search}\n                      onChange={(e) => setSearch(e.target.value)}\n                      placeholder=\"Search notes\"\n                      aria-label=\"Search notes\"\n                      className=\"h-[26px] w-[180px] rounded-md border-0 pl-7 pr-7 text-[12.5px] outline-none transition-colors focus:shadow-[inset_0_0_0_1px_hsl(var(--border))]\"\n                      style={{\n                        background: 'rgba(27,27,25,0.04)',\n                        color: 'var(--fg-1)',\n                        fontFamily: 'var(--font-sans)',\n                      }}\n                    />\n                    {search && (\n                      <button\n                        type=\"button\"\n                        onClick={() => {\n                          setSearch('');\n                          searchInputRef.current?.focus();\n                        }}\n                        aria-label=\"Clear search\"\n                        className=\"absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex size-4 items-center justify-center rounded transition-colors hover:bg-[color:var(--surface-hover)]\"\n                        style={{ color: 'var(--fg-muted)' }}\n                      >\n                        <X className=\"size-[11px]\" />\n                      </button>\n                    )}\n                  </div>\n                ) : undefined\n              }\n            />\n            {groups.length === 0 && mode === 'meetings' && search.trim() ? (\n              <div\n                className=\"px-6 py-12 text-center text-[13px]\"\n                style={{ color: 'var(--fg-2)' }}\n              >\n                No meetings match &ldquo;{search.trim()}&rdquo;.\n              </div>\n            ) : (\n              groups.map((g) => (\n                <div key={g.label}>\n                  <div\n                    className=\"pb-2 pt-4 text-[11.5px] font-medium tracking-[0.02em]\"\n                    style={{ color: 'var(--fg-2)' }}\n                  >\n                    {g.label}\n                  </div>\n                  <div>\n                    {g.items.map((m) => (\n                      <PreviousRow\n                        key={m.session_info.summary_file}\n                        meeting={m}\n                        folderName={firstFolderName(m, folderName)}\n                      />\n                    ))}\n                  </div>\n                </div>\n              ))\n            )}\n          </section>\n        </>\n      )}\n    </MeetingsShell>\n  );\n}\n\ninterface SectionHeadProps {\n  title: string;\n  count: number;\n  action?: React.ReactNode;\n}\n\nfunction SectionHead({ title, count, action }: SectionHeadProps) {\n  return (\n    <div\n      className=\"mb-3.5 flex items-baseline justify-between pb-2.5\"\n      style={{ borderBottom: '1px solid var(--border-subtle)' }}\n    >\n      <div className=\"flex items-baseline gap-2.5\">\n        <h2\n          className=\"text-sm font-medium tracking-[-0.005em]\"\n          style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n        >\n          {title}\n        </h2>\n        <span\n          className=\"text-[12.5px] tabular-nums\"\n          style={{ color: 'var(--fg-muted)' }}\n        >\n          {count}\n        </span>\n      </div>\n      {action}\n    </div>\n  );\n}\n\nfunction summaryLine(_upcomingCount: number): string {\n  return `Start recording from the top-right, or from anywhere with ${shortcut('⌘⇧R', 'Ctrl+Shift+R')}.`;\n}\n\nfunction firstFolderName(\n  m: Meeting,\n  folderName: Map<string, string>,\n): string | undefined {\n  const id = m.folders?.[0] ?? m.session_info.folders?.[0];\n  if (!id) return undefined;\n  return folderName.get(id);\n}\n\ninterface Group {\n  label: string;\n  items: Meeting[];\n}\n\nfunction groupPrevious(meetings: Meeting[]): Group[] {\n  const groups: Record<string, Meeting[]> = {};\n  const order: string[] = [];\n  const now = new Date();\n  const sorted = [...meetings].sort((a, b) => {\n    const ta = new Date(a.session_info.processed_at ?? a.session_info.updated_at ?? 0).getTime();\n    const tb = new Date(b.session_info.processed_at ?? b.session_info.updated_at ?? 0).getTime();\n    return tb - ta;\n  });\n  for (const m of sorted) {\n    const raw = m.session_info.processed_at ?? m.session_info.updated_at;\n    const label = raw ? groupLabel(new Date(raw), now) : 'Earlier';\n    if (!groups[label]) {\n      groups[label] = [];\n      order.push(label);\n    }\n    groups[label].push(m);\n  }\n  return order.map((label) => ({ label, items: groups[label] }));\n}\n\nfunction groupLabel(d: Date, now: Date): string {\n  const sameDay = (a: Date, b: Date) =>\n    a.getFullYear() === b.getFullYear() &&\n    a.getMonth() === b.getMonth() &&\n    a.getDate() === b.getDate();\n  if (sameDay(d, now)) return 'Today';\n  const yesterday = new Date(now);\n  yesterday.setDate(now.getDate() - 1);\n  if (sameDay(d, yesterday)) return 'Yesterday';\n  const age = now.getTime() - d.getTime();\n  if (age < 7 * 24 * 60 * 60 * 1000) {\n    return d.toLocaleDateString(undefined, { weekday: 'long' });\n  }\n  return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });\n}\n"
  },
  {
    "path": "app/renderer/src/routes/MeetingDetail.tsx",
    "content": "import * as React from 'react';\nimport {\n  Calendar as CalendarIcon,\n  Check,\n  ChevronLeft,\n  Clock,\n  Copy,\n  Folder as FolderIcon,\n  MoreHorizontal,\n  RefreshCw,\n  Trash2,\n  Users,\n} from 'lucide-react';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { useQueryClient } from '@tanstack/react-query';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { Chip } from '@/components/ui/chip';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectSeparator,\n} from '@/components/ui/select';\nimport { useMeeting, useReprocessMeeting, useDeleteMeeting, meetingsKeys } from '@/hooks/useMeetings';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n  DialogClose,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipTrigger,\n  TooltipContent,\n} from '@/components/ui/tooltip';\nimport {\n  useFolders,\n  useAddMeetingToFolder,\n  useRemoveMeetingFromFolder,\n  useCreateFolder,\n} from '@/hooks/useFolders';\nimport { useCalendarEvents } from '@/hooks/useCalendarEvents';\nimport { useActiveMeeting } from '@/lib/askBarContext';\nimport { ipc, type CalendarEvent, type Meeting } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result';\nimport { cn } from '@/lib/utils';\nimport { navigate } from '@/lib/router';\nimport {\n  pendingTitleRegens,\n  streamCache,\n  type StreamPhase,\n} from '@/lib/meetingDetailState';\n\nconst LAST_OPENED_KEY = 'steno-last-opened-meeting';\n\ninterface MeetingDetailProps {\n  summaryFile: string;\n}\n\nexport function MeetingDetail({ summaryFile }: MeetingDetailProps) {\n  const meeting = useMeeting(summaryFile);\n  useActiveMeeting(summaryFile, meeting.data?.session_info.name ?? null);\n\n  React.useEffect(() => {\n    if (meeting.data) {\n      localStorage.setItem(LAST_OPENED_KEY, summaryFile);\n    }\n  }, [meeting.data, summaryFile]);\n\n  return (\n    <MeetingsShell activeSummaryFile={summaryFile}>\n      {meeting.isLoading || (meeting.isFetching && !meeting.data) ? (\n        <div className=\"flex min-h-[40vh] items-center justify-center text-[color:var(--fg-2)]\">\n          Loading meeting…\n        </div>\n      ) : !meeting.data ? (\n        <div className=\"space-y-4 text-center\">\n          <h1 className=\"mv-title\">Note not found.</h1>\n          <p className=\"text-[17px] leading-[1.55]\" style={{ color: 'var(--fg-2)' }}>\n            This recording may have been deleted. Pick another from the sidebar.\n          </p>\n          <button\n            type=\"button\"\n            className=\"mv-chip\"\n            onClick={() => navigate('/meetings')}\n          >\n            Back to meetings\n          </button>\n        </div>\n      ) : (\n        <DetailContent key={summaryFile} meeting={meeting.data} />\n      )}\n    </MeetingsShell>\n  );\n}\n\nfunction DetailContent({ meeting }: { meeting: Meeting }) {\n  const info = meeting.session_info;\n  const summaryFile = info.summary_file;\n  const date = formatDetailDate(info);\n  const duration = formatDuration(info.duration_seconds);\n  const [copied, setCopied] = React.useState(false);\n  const [deleteOpen, setDeleteOpen] = React.useState(false);\n  const deleteMeeting = useDeleteMeeting();\n  const reprocess = useReprocessMeeting();\n  const [titleRegening, setTitleRegening] = React.useState(() =>\n    pendingTitleRegens.has(summaryFile),\n  );\n  const [isEditingTitle, setIsEditingTitle] = React.useState(false);\n  const [titleError, setTitleError] = React.useState<string | null>(null);\n  const titleEditRef = React.useRef<HTMLSpanElement>(null);\n\n  React.useEffect(() => {\n    const pending = pendingTitleRegens.get(summaryFile);\n    if (!pending) return;\n    setTitleRegening(true);\n    let cancelled = false;\n    pending.finally(() => {\n      if (!cancelled) setTitleRegening(false);\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [summaryFile]);\n\n  React.useEffect(() => {\n    if (isEditingTitle && titleEditRef.current) {\n      const el = titleEditRef.current;\n      el.focus();\n      const range = document.createRange();\n      range.selectNodeContents(el);\n      range.collapse(false);\n      const sel = window.getSelection();\n      sel?.removeAllRanges();\n      sel?.addRange(range);\n    }\n  }, [isEditingTitle]);\n\n  const [titleKey, setTitleKey] = React.useState(0);\n  const prevNameRef = React.useRef(info.name);\n  React.useEffect(() => {\n    if (info.name !== prevNameRef.current) {\n      prevNameRef.current = info.name;\n      setTitleKey((k) => k + 1);\n      setTitleRegening(false);\n      setTitleError(null);\n    }\n  }, [info.name]);\n\n  const cached = streamCache.get(summaryFile);\n  const [streamText, setStreamText] = React.useState(cached?.text ?? '');\n  const [streamPhase, setStreamPhase] = React.useState<StreamPhase>(cached?.phase ?? 'idle');\n  const qc = useQueryClient();\n\n  React.useEffect(() => {\n    streamCache.set(summaryFile, { text: streamText, phase: streamPhase });\n  }, [summaryFile, streamText, streamPhase]);\n\n  React.useEffect(() => {\n    const sessionName = info.name;\n    const offChunk = ipc().on.summaryChunk((e) => {\n      if (e.sessionName !== sessionName) return;\n      setStreamPhase((prev) => (prev === 'analyzing' ? 'generating' : prev));\n      setStreamText((prev) => prev + e.chunk);\n    });\n    const offComplete = ipc().on.summaryComplete((e) => {\n      if (e.sessionName !== sessionName) return;\n      setStreamPhase('done');\n    });\n    const offProcessing = ipc().on.processingComplete((e) => {\n      if (e.sessionName !== sessionName) return;\n      void qc.invalidateQueries({ queryKey: meetingsKeys.all });\n      setTimeout(() => {\n        setStreamText('');\n        setStreamPhase('idle');\n        streamCache.delete(summaryFile);\n      }, 400);\n    });\n    return () => {\n      offChunk();\n      offComplete();\n      offProcessing();\n    };\n  }, [info.name, summaryFile, qc]);\n\n  const copyNotes = () => {\n    const lines: string[] = [info.name];\n    const meta = [formatDetailDate(info), formatDuration(info.duration_seconds)]\n      .filter(Boolean)\n      .join(' · ');\n    if (meta) lines.push(meta);\n    const summary = meeting.summary?.trim();\n    if (summary) {\n      lines.push('', 'SUMMARY', summary);\n    }\n    const dAreas = asDiscussionAreas(meeting.discussion_areas);\n    if (dAreas.length) {\n      lines.push('', 'KEY TOPICS');\n      dAreas.forEach((a) => lines.push(`- ${a.title}${a.analysis ? `: ${a.analysis}` : ''}`));\n    }\n    const kp = meeting.key_points ?? [];\n    if (kp.length) {\n      lines.push('', 'KEY POINTS');\n      kp.forEach((p) => lines.push(`- ${p}`));\n    }\n    const ai = asStringArray(meeting.action_items);\n    if (ai.length) {\n      lines.push('', 'ACTION ITEMS');\n      ai.forEach((a) => lines.push(`- ${a}`));\n    }\n    const parts = asStringArray(meeting.participants);\n    if (parts.length) {\n      lines.push('', 'PARTICIPANTS', parts.join(', '));\n    }\n    void navigator.clipboard.writeText(lines.join('\\n'));\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  const summary = meeting.summary?.trim();\n  const participants = asStringArray(meeting.participants);\n  const keyPoints = meeting.key_points ?? [];\n  const actionItems = asStringArray(meeting.action_items);\n  const discussionAreas = asDiscussionAreas(meeting.discussion_areas);\n\n  return (\n    <article data-testid=\"meeting-detail\" className=\"space-y-9\">\n      <header\n        className=\"flex flex-col gap-4 pb-6\"\n        style={{ borderBottom: '1px solid var(--border-subtle)' }}\n      >\n        <div className=\"flex items-center justify-between gap-3\">\n          <button\n            type=\"button\"\n            onClick={() => navigate('/')}\n            aria-label=\"Back to home\"\n            className=\"inline-flex items-center gap-1 rounded-md px-2 py-1 text-[12.5px] transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]\"\n            style={{ color: 'var(--fg-2)' }}\n          >\n            <ChevronLeft className=\"size-[15px]\" />\n            Home\n          </button>\n          <div className=\"flex items-center gap-1\">\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <ActionIconButton\n                  label={copied ? 'Copied' : 'Copy notes'}\n                  onClick={copyNotes}\n                >\n                  {copied ? <Check className=\"size-[13px]\" /> : <Copy className=\"size-[13px]\" />}\n                </ActionIconButton>\n              </TooltipTrigger>\n              <TooltipContent side=\"bottom\">{copied ? 'Copied!' : 'Copy notes'}</TooltipContent>\n            </Tooltip>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <ActionIconButton\n                  label=\"Regenerate notes\"\n                  onClick={() => {\n                    setStreamText('');\n                    setStreamPhase('analyzing');\n                    streamCache.set(summaryFile, { text: '', phase: 'analyzing' });\n                    reprocess.mutate({ summaryFile, regenTitle: false, name: info.name });\n                  }}\n                  disabled={reprocess.isPending || streamPhase !== 'idle'}\n                >\n                  <RefreshCw\n                    className={cn(\n                      'size-[13px]',\n                      (reprocess.isPending ||\n                        streamPhase === 'analyzing' ||\n                        streamPhase === 'generating') &&\n                        'animate-spin',\n                    )}\n                  />\n                </ActionIconButton>\n              </TooltipTrigger>\n              <TooltipContent side=\"bottom\">Regenerate notes</TooltipContent>\n            </Tooltip>\n            <Popover>\n              <PopoverTrigger asChild>\n                <button\n                  type=\"button\"\n                  aria-label=\"More options\"\n                  title=\"More options\"\n                  className=\"inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]\"\n                  style={{ color: 'var(--fg-2)' }}\n                >\n                  <MoreHorizontal className=\"size-[14px]\" />\n                </button>\n              </PopoverTrigger>\n              <PopoverContent align=\"end\" className=\"w-52 p-1\">\n                <button\n                  type=\"button\"\n                  className=\"flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-hover)]\"\n                  style={{ color: 'var(--fg-1)' }}\n                  onClick={() => {\n                    const file = info.summary_file || info.transcript_file || info.audio_file;\n                    if (file) ipc().meetings.revealFolder(file);\n                  }}\n                >\n                  <FolderIcon className=\"size-[13px] shrink-0\" style={{ color: 'var(--fg-2)' }} />\n                  View containing folder\n                </button>\n                <button\n                  type=\"button\"\n                  className=\"flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-hover)]\"\n                  style={{ color: 'var(--danger)' }}\n                  onClick={() => setDeleteOpen(true)}\n                >\n                  <Trash2 className=\"size-[13px] shrink-0\" />\n                  Delete note\n                </button>\n              </PopoverContent>\n            </Popover>\n          </div>\n        </div>\n\n        <h1\n          data-testid=\"meeting-detail-title\"\n          key={titleKey}\n          className={cn('mv-title group', titleKey > 0 && 'animate-title-in')}\n        >\n          <button\n            type=\"button\"\n            onClick={async () => {\n              if (pendingTitleRegens.has(summaryFile)) return;\n              setTitleError(null);\n              setTitleRegening(true);\n              const task = (async () => {\n                try {\n                  unwrap(await ipc().meetings.regenTitle(summaryFile, info.name));\n                  await qc.invalidateQueries({ queryKey: meetingsKeys.all });\n                } finally {\n                  pendingTitleRegens.delete(summaryFile);\n                }\n              })();\n              pendingTitleRegens.set(summaryFile, task);\n              try {\n                await task;\n              } catch (error) {\n                setTitleError(getErrorMessage(error));\n              } finally {\n                setTitleRegening(false);\n              }\n            }}\n            disabled={\n              titleRegening ||\n              reprocess.isPending ||\n              streamPhase !== 'idle' ||\n              isEditingTitle\n            }\n            aria-label=\"Regenerate title\"\n            title=\"Regenerate title\"\n            className={cn(\n              'inline-flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100 disabled:pointer-events-none',\n              titleRegening && 'opacity-100',\n            )}\n            style={{\n              verticalAlign: 'middle',\n              color: 'var(--fg-1)',\n              width: '1em',\n              height: '1em',\n              marginRight: '0.15em',\n              marginLeft: '-1.15em',\n            }}\n          >\n            <RefreshCw className={cn('size-[0.45em]', titleRegening && 'animate-spin')} />\n          </button>\n          {isEditingTitle ? (\n            <span\n              ref={titleEditRef}\n              contentEditable\n              suppressContentEditableWarning\n              onBlur={(e) => {\n                const next = (e.currentTarget.textContent ?? '').trim();\n                setIsEditingTitle(false);\n                if (!next || next === info.name) return;\n                void ipc()\n                  .meetings.update(summaryFile, { name: next })\n                  .then(() => qc.invalidateQueries({ queryKey: meetingsKeys.all }));\n              }}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  e.preventDefault();\n                  (e.currentTarget as HTMLSpanElement).blur();\n                } else if (e.key === 'Escape') {\n                  e.preventDefault();\n                  if (titleEditRef.current) titleEditRef.current.textContent = info.name;\n                  setIsEditingTitle(false);\n                }\n              }}\n              className=\"outline-none\"\n            >\n              {info.name}\n            </span>\n          ) : (\n            <span className=\"cursor-text\" onClick={() => setIsEditingTitle(true)}>\n              {info.name}\n            </span>\n          )}\n        </h1>\n\n        <div className=\"flex flex-wrap gap-1.5\">\n          {date && (\n            <ChipV2 icon={<CalendarIcon className=\"size-[11px]\" />}>{date}</ChipV2>\n          )}\n          {duration && (\n            <ChipV2 icon={<Clock className=\"size-[11px]\" />}>{duration}</ChipV2>\n          )}\n          <FolderPicker\n            summaryFile={summaryFile}\n            assignedFolderIds={meeting.folders ?? meeting.session_info.folders ?? []}\n          />\n          {participants.length > 0 && (\n            <ChipV2 icon={<Users className=\"size-[11px]\" />}>\n              {participants.length} {participants.length === 1 ? 'person' : 'people'}\n            </ChipV2>\n          )}\n          {meeting.is_diarised && (\n            <ChipV2 icon={<FolderIcon className=\"size-[11px]\" />}>Diarised</ChipV2>\n          )}\n        </div>\n\n        {titleError && (\n          <p className=\"text-sm\" style={{ color: 'var(--danger)' }}>\n            {titleError}\n          </p>\n        )}\n      </header>\n\n      {streamPhase !== 'idle' ? (\n        <StreamingView text={streamText} phase={streamPhase} />\n      ) : (\n        <div className=\"flex flex-col gap-9\">\n          {summary ? (\n            <section className=\"flex flex-col gap-3\">\n              <SectionTitle>Summary</SectionTitle>\n              <div data-testid=\"tab-summary-content\">\n                {summary.split(/\\n{2,}/).map((para, i) => (\n                  <p\n                    key={i}\n                    className=\"text-[15.5px] leading-[1.65]\"\n                    style={{ color: 'var(--fg-1)', maxWidth: '64ch' }}\n                  >\n                    {para}\n                  </p>\n                ))}\n              </div>\n            </section>\n          ) : (\n            <p className=\"py-2 text-sm\" style={{ color: 'var(--fg-2)' }}>\n              No summary available for this meeting.\n            </p>\n          )}\n\n          {discussionAreas.length > 0 && (\n            <section className=\"flex flex-col gap-3\">\n              <SectionTitle>Key topics</SectionTitle>\n              <div className=\"mv-topics\">\n                {discussionAreas.map((area, i) => (\n                  <div key={i} className=\"flex flex-col gap-1\">\n                    <div className=\"mv-topic-title\">{area.title}</div>\n                    {area.analysis && <div className=\"mv-topic-body\">{area.analysis}</div>}\n                  </div>\n                ))}\n              </div>\n            </section>\n          )}\n\n          {keyPoints.length > 0 && (\n            <section className=\"flex flex-col gap-3\">\n              <SectionTitle>Key points</SectionTitle>\n              <ul className=\"mv-bullets\">\n                {keyPoints.map((p, i) => (\n                  <li key={i}>{p}</li>\n                ))}\n              </ul>\n            </section>\n          )}\n\n          {actionItems.length > 0 && (\n            <section className=\"flex flex-col gap-3\">\n              <SectionTitle>Action items</SectionTitle>\n              <ul className=\"mv-bullets\">\n                {actionItems.map((a, i) => (\n                  <li key={i}>{a}</li>\n                ))}\n              </ul>\n            </section>\n          )}\n\n          {participants.length > 0 && (\n            <section className=\"flex flex-col gap-3\">\n              <SectionTitle>Participants</SectionTitle>\n              <div className=\"flex flex-wrap gap-1.5\">\n                {participants.map((p, i) => (\n                  <ChipV2 key={i}>{p}</ChipV2>\n                ))}\n              </div>\n            </section>\n          )}\n\n          <CalendarSection meeting={meeting} />\n        </div>\n      )}\n\n      <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n        <DialogContent className=\"max-w-sm\">\n          <DialogHeader>\n            <DialogTitle>Delete this note?</DialogTitle>\n            <DialogDescription>\n              This will permanently delete the recording, transcript, and summary. This can't be undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <DialogClose asChild>\n              <Button variant=\"ghost\">Cancel</Button>\n            </DialogClose>\n            <Button\n              variant=\"destructive\"\n              disabled={deleteMeeting.isPending}\n              onClick={async () => {\n                await deleteMeeting.mutateAsync(meeting);\n                navigate('/');\n              }}\n            >\n              Delete\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </article>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Subcomponents\n// ---------------------------------------------------------------------------\n\nfunction SectionTitle({ children }: { children: React.ReactNode }) {\n  return (\n    <h2\n      className=\"text-[13px] font-semibold tracking-[0.01em]\"\n      style={{\n        color: 'var(--fg-2)',\n        fontFamily: 'var(--font-sans)',\n        margin: 0,\n      }}\n    >\n      {children}\n    </h2>\n  );\n}\n\ninterface ChipV2Props {\n  icon?: React.ReactNode;\n  children: React.ReactNode;\n  onClick?: () => void;\n}\n\nfunction ChipV2({ icon, children, onClick }: ChipV2Props) {\n  return (\n    <button\n      type=\"button\"\n      className=\"mv-chip\"\n      onClick={onClick}\n      style={onClick ? undefined : { cursor: 'default' }}\n    >\n      {icon}\n      <span>{children}</span>\n    </button>\n  );\n}\n\nconst ActionIconButton = React.forwardRef<\n  HTMLButtonElement,\n  React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }\n>(function ActionIconButton({ label, children, ...rest }, ref) {\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      aria-label={label}\n      className=\"inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)] disabled:opacity-50\"\n      style={{ color: 'var(--fg-2)' }}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n});\n\n// ---------------------------------------------------------------------------\n// Streaming view (kept in this file because StreamingView's pinned-indicator\n// reads #ask-bar-slot dimensions; staying close to MeetingDetail keeps the\n// scroll/position math explicit).\n// ---------------------------------------------------------------------------\n\ninterface MarkdownBlock {\n  type: 'heading' | 'bullet' | 'paragraph';\n  text: string;\n}\n\nfunction parseMarkdownBlocks(text: string): MarkdownBlock[] {\n  const lines = text.split('\\n');\n  const blocks: MarkdownBlock[] = [];\n  let bulletBuffer: string[] = [];\n\n  const flushBullets = () => {\n    if (bulletBuffer.length === 0) return;\n    bulletBuffer.forEach((t) => blocks.push({ type: 'bullet', text: t }));\n    bulletBuffer = [];\n  };\n\n  for (const raw of lines) {\n    const line = raw.trimEnd();\n    if (/^#{1,3}\\s+/.test(line)) {\n      flushBullets();\n      blocks.push({ type: 'heading', text: line.replace(/^#{1,3}\\s+/, '') });\n    } else if (/^[-*]\\s+/.test(line)) {\n      bulletBuffer.push(line.replace(/^[-*]\\s+/, ''));\n    } else if (line.trim() === '') {\n      flushBullets();\n    } else {\n      flushBullets();\n      blocks.push({ type: 'paragraph', text: line });\n    }\n  }\n  flushBullets();\n  return blocks.filter((b) => b.text.trim());\n}\n\nconst CHAR_TRANSITION = 'top 0.12s ease-out';\nconst ROW_TRANSITION = 'top 0.35s cubic-bezier(0.45, 0, 0.55, 1)';\n\nfunction StreamingView({ text, phase }: { text: string; phase: StreamPhase }) {\n  const blocks = parseMarkdownBlocks(text);\n  const isStreaming = phase === 'analyzing' || phase === 'generating';\n\n  const prevBlockCountRef = React.useRef(blocks.length);\n  const firstNewIdx = prevBlockCountRef.current;\n  React.useEffect(() => {\n    prevBlockCountRef.current = blocks.length;\n  }, [blocks.length]);\n\n  const blocksContainerRef = React.useRef<HTMLDivElement>(null);\n  const indicatorRef = React.useRef<HTMLDivElement>(null);\n  const rowCountRef = React.useRef(blocks.length);\n  const rowTransitionTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const currentTransitionRef = React.useRef(CHAR_TRANSITION);\n  const naturalTopRef = React.useRef(0);\n  const isPinnedRef = React.useRef(false);\n\n  const repositionIndicator = React.useCallback(() => {\n    const container = blocksContainerRef.current;\n    const indicator = indicatorRef.current;\n    if (!container || !indicator) return;\n\n    const containerRect = container.getBoundingClientRect();\n    const naturalViewportTop = containerRect.top + naturalTopRef.current;\n    const indicatorH = 44;\n    const askBar = document.getElementById('ask-bar-slot');\n    const clearance = (askBar ? askBar.offsetHeight : 80) + 8;\n    const pinnedViewportTop = window.innerHeight - indicatorH - clearance;\n\n    if (naturalViewportTop <= pinnedViewportTop) {\n      if (isPinnedRef.current) {\n        isPinnedRef.current = false;\n        indicator.style.transition = 'none';\n        indicator.style.position = 'absolute';\n        indicator.style.top = `${naturalTopRef.current}px`;\n        indicator.style.left = '-12px';\n        indicator.style.right = '-12px';\n        indicator.style.width = '';\n        requestAnimationFrame(() => requestAnimationFrame(() => {\n          if (indicatorRef.current) indicatorRef.current.style.transition = currentTransitionRef.current;\n        }));\n        return;\n      }\n      indicator.style.position = 'absolute';\n      indicator.style.top = `${naturalTopRef.current}px`;\n      indicator.style.left = '-12px';\n      indicator.style.right = '-12px';\n      indicator.style.width = '';\n    } else {\n      if (!isPinnedRef.current) {\n        isPinnedRef.current = true;\n        indicator.style.transition = 'none';\n      }\n      indicator.style.position = 'fixed';\n      indicator.style.top = `${pinnedViewportTop}px`;\n      indicator.style.left = `${containerRect.left - 12}px`;\n      indicator.style.right = 'auto';\n      indicator.style.width = `${container.offsetWidth + 24}px`;\n    }\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const container = blocksContainerRef.current;\n    const indicator = indicatorRef.current;\n    if (!container || !indicator) return;\n\n    const newTop = container.offsetHeight;\n    const isNewRow = blocks.length !== rowCountRef.current;\n\n    if (isNewRow) {\n      rowCountRef.current = blocks.length;\n      currentTransitionRef.current = ROW_TRANSITION;\n      if (rowTransitionTimerRef.current) clearTimeout(rowTransitionTimerRef.current);\n      rowTransitionTimerRef.current = setTimeout(() => {\n        currentTransitionRef.current = CHAR_TRANSITION;\n        if (indicatorRef.current && !isPinnedRef.current) {\n          indicatorRef.current.style.transition = CHAR_TRANSITION;\n        }\n      }, 360);\n    }\n\n    naturalTopRef.current = newTop;\n    if (!isPinnedRef.current) {\n      indicator.style.transition = currentTransitionRef.current;\n    }\n    repositionIndicator();\n  });\n\n  React.useEffect(() => {\n    if (!isStreaming) return;\n    window.addEventListener('scroll', repositionIndicator, { passive: true });\n    return () => window.removeEventListener('scroll', repositionIndicator);\n  }, [isStreaming, repositionIndicator]);\n\n  React.useEffect(() => () => {\n    if (rowTransitionTimerRef.current) clearTimeout(rowTransitionTimerRef.current);\n  }, []);\n\n  const indicatorLabel = phase === 'analyzing' ? 'Analysing transcript' : 'Generating notes';\n\n  return (\n    <div className=\"relative\">\n      <div ref={blocksContainerRef} className=\"space-y-4\">\n        {blocks.map((block, i) => {\n          const animate = i >= firstNewIdx;\n          if (block.type === 'heading') {\n            return (\n              <SectionTitle key={i}>\n                {block.text}\n              </SectionTitle>\n            );\n          }\n          if (block.type === 'bullet') {\n            return (\n              <div key={i} className={cn('flex gap-2', animate && 'animate-fade-in')}>\n                <span className=\"mt-[0.45em] size-1 flex-shrink-0 rounded-full bg-[color:var(--fg-2)]\" />\n                <p className=\"text-sm leading-[1.65]\" style={{ color: 'var(--fg-1)' }}>{block.text}</p>\n              </div>\n            );\n          }\n          return (\n            <p\n              key={i}\n              className={cn('text-[15px] leading-[1.7]', animate && 'animate-fade-in')}\n              style={{ color: 'var(--fg-1)' }}\n            >\n              {block.text}\n            </p>\n          );\n        })}\n      </div>\n      {isStreaming && (\n        <div\n          ref={indicatorRef}\n          className=\"pointer-events-none absolute -left-3 -right-3 flex h-11 items-center gap-2 rounded-lg px-3\"\n          style={{\n            top: 0,\n            background: 'var(--surface-raised)',\n            boxShadow: 'var(--shadow-md)',\n          }}\n        >\n          <span\n            className=\"inline-block size-3 animate-spin-fast rounded-full border-2 border-transparent\"\n            style={{ borderTopColor: 'var(--fg-2)' }}\n          />\n          <span className=\"text-xs\" style={{ color: 'var(--fg-1)' }}>{indicatorLabel}</span>\n        </div>\n      )}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Calendar section (related events for a meeting)\n// ---------------------------------------------------------------------------\n\nfunction CalendarSection({ meeting }: { meeting: Meeting }) {\n  const calendar = useCalendarEvents();\n  const state = calendar.data;\n\n  if (calendar.isLoading || !state || state.needsAuth) return null;\n\n  const related = findRelatedEvents(state.events, meeting);\n  if (related.length === 0) return null;\n\n  return (\n    <section className=\"flex flex-col gap-3\">\n      <SectionTitle>Calendar</SectionTitle>\n      <ul className=\"space-y-3\">\n        {related.map((event) => (\n          <li\n            key={event.id}\n            className=\"rounded-md border p-4\"\n            style={{\n              borderColor: 'var(--border-subtle)',\n              background: 'var(--surface-raised)',\n            }}\n          >\n            <div className=\"flex items-start gap-3\">\n              <CalendarIcon\n                className=\"mt-0.5 size-4 flex-shrink-0\"\n                style={{ color: 'var(--fg-2)' }}\n              />\n              <div className=\"flex-1 space-y-1\">\n                <div className=\"font-medium\" style={{ color: 'var(--fg-1)' }}>{event.title}</div>\n                <div className=\"text-xs\" style={{ color: 'var(--fg-2)' }}>\n                  {formatEventTime(event)}\n                </div>\n                {event.location && (\n                  <div className=\"text-xs\" style={{ color: 'var(--fg-2)' }}>{event.location}</div>\n                )}\n                {event.attendees && event.attendees.length > 0 && (\n                  <div className=\"mt-2 flex flex-wrap gap-1\">\n                    {event.attendees.slice(0, 6).map((a, i) => (\n                      <Chip key={i} variant=\"muted\">\n                        {a.name ?? a.email}\n                      </Chip>\n                    ))}\n                    {event.attendees.length > 6 && (\n                      <Chip variant=\"muted\">+{event.attendees.length - 6}</Chip>\n                    )}\n                  </div>\n                )}\n              </div>\n            </div>\n          </li>\n        ))}\n      </ul>\n    </section>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Folder picker (meeting → folder assignment via the chip dropdown)\n// ---------------------------------------------------------------------------\n\nconst FOLDER_NONE = '__none__';\nconst FOLDER_NEW = '__new__';\n\nfunction FolderPicker({ summaryFile, assignedFolderIds }: { summaryFile: string; assignedFolderIds: string[] }) {\n  const folders = useFolders();\n  const addMeeting = useAddMeetingToFolder();\n  const removeMeeting = useRemoveMeetingFromFolder();\n  const createFolder = useCreateFolder();\n  const [newFolderName, setNewFolderName] = React.useState('');\n  const [creatingFolder, setCreatingFolder] = React.useState(false);\n  const [folderError, setFolderError] = React.useState<string | null>(null);\n  const newFolderInputRef = React.useRef<HTMLInputElement>(null);\n\n  const allFolders = folders.data ?? [];\n  const serverFolderId = assignedFolderIds[0] ?? null;\n  const [localFolderId, setLocalFolderId] = React.useState<string | null>(serverFolderId);\n  React.useEffect(() => { setLocalFolderId(serverFolderId); }, [serverFolderId]);\n\n  const currentFolder = allFolders.find((f) => f.id === localFolderId) ?? null;\n\n  const assignFolder = async (nextFolderId: string | null) => {\n    const previousFolderId = localFolderId;\n    if (previousFolderId === nextFolderId) return;\n\n    setFolderError(null);\n    setLocalFolderId(nextFolderId);\n\n    try {\n      if (previousFolderId && nextFolderId) {\n        await addMeeting.mutateAsync({ summaryFile, folderId: nextFolderId });\n        try {\n          await removeMeeting.mutateAsync({ summaryFile, folderId: previousFolderId });\n        } catch (error) {\n          await removeMeeting.mutateAsync({ summaryFile, folderId: nextFolderId }).catch(() => {\n            // Query invalidation from the mutation hooks will restore server state.\n          });\n          throw error;\n        }\n        return;\n      }\n\n      if (previousFolderId) {\n        await removeMeeting.mutateAsync({ summaryFile, folderId: previousFolderId });\n      }\n      if (nextFolderId) {\n        await addMeeting.mutateAsync({ summaryFile, folderId: nextFolderId });\n      }\n    } catch (error) {\n      setLocalFolderId(previousFolderId);\n      setFolderError(getErrorMessage(error));\n    }\n  };\n\n  const handleValueChange = (value: string) => {\n    if (value === FOLDER_NEW) {\n      setFolderError(null);\n      setCreatingFolder(true);\n      setTimeout(() => newFolderInputRef.current?.focus(), 50);\n      return;\n    }\n    void assignFolder(value === FOLDER_NONE ? null : value);\n  };\n\n  const submitNewFolder = async () => {\n    const name = newFolderName.trim();\n    if (!name) {\n      setCreatingFolder(false);\n      setFolderError(null);\n      return;\n    }\n    setFolderError(null);\n\n    try {\n      const result = await createFolder.mutateAsync({ name });\n      setNewFolderName('');\n      setCreatingFolder(false);\n      await assignFolder(result.folder.id);\n    } catch (error) {\n      setFolderError(getErrorMessage(error));\n    }\n  };\n\n  if (creatingFolder) {\n    return (\n      <div className=\"space-y-1\">\n        <div className=\"inline-flex h-9 items-center gap-1 rounded-md border border-border px-3 text-sm\">\n          <input\n            ref={newFolderInputRef}\n            value={newFolderName}\n            onChange={(e) => setNewFolderName(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') { e.preventDefault(); void submitNewFolder(); }\n              if (e.key === 'Escape') {\n                setNewFolderName('');\n                setFolderError(null);\n                setCreatingFolder(false);\n              }\n            }}\n            onBlur={() => void submitNewFolder()}\n            placeholder=\"Folder name...\"\n            className=\"w-28 bg-transparent outline-none placeholder:text-muted-foreground\"\n          />\n        </div>\n        {folderError && <p className=\"text-xs text-destructive\">{folderError}</p>}\n      </div>\n    );\n  }\n\n  const currentFolderLabel = currentFolder?.name ?? 'Add to folder';\n\n  return (\n    <div className=\"space-y-1\">\n      <Select\n        value={localFolderId ?? FOLDER_NONE}\n        onValueChange={handleValueChange}\n      >\n        <SelectPrimitive.Trigger asChild>\n          <button type=\"button\" className=\"mv-chip\">\n            {currentFolderLabel}\n          </button>\n        </SelectPrimitive.Trigger>\n        <SelectContent align=\"start\">\n          <SelectItem value={FOLDER_NONE}>No folder</SelectItem>\n          <SelectSeparator />\n          {allFolders.map((f) => (\n            <SelectItem key={f.id} value={f.id}>{f.name}</SelectItem>\n          ))}\n          <SelectSeparator />\n          <SelectItem value={FOLDER_NEW}>New folder...</SelectItem>\n        </SelectContent>\n      </Select>\n      {folderError && <p className=\"text-xs text-destructive\">{folderError}</p>}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Pure formatting / parsing helpers\n// ---------------------------------------------------------------------------\n\nfunction formatDetailDate(info: { processed_at?: string; updated_at?: string }): string | undefined {\n  const raw = info.processed_at ?? info.updated_at;\n  if (!raw) return undefined;\n  const d = new Date(raw);\n  if (Number.isNaN(d.getTime())) return undefined;\n  return d.toLocaleString(undefined, {\n    weekday: 'short',\n    month: 'short',\n    day: 'numeric',\n    year: 'numeric',\n    hour: 'numeric',\n    minute: '2-digit',\n  });\n}\n\nfunction formatDuration(seconds?: number): string | undefined {\n  if (!seconds || seconds <= 0) return undefined;\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  const s = seconds % 60;\n  if (h > 0) return `${h}h ${m}m`;\n  if (m > 0) return `${m}m`;\n  return `${s}s`;\n}\n\nfunction formatEventTime(event: CalendarEvent): string {\n  try {\n    const start = new Date(event.start);\n    const end = new Date(event.end);\n    const sameDay =\n      start.getFullYear() === end.getFullYear() &&\n      start.getMonth() === end.getMonth() &&\n      start.getDate() === end.getDate();\n    const dateFmt: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };\n    const timeFmt: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };\n    if (sameDay) {\n      return `${start.toLocaleDateString(undefined, dateFmt)} · ${start.toLocaleTimeString(undefined, timeFmt)} – ${end.toLocaleTimeString(undefined, timeFmt)}`;\n    }\n    return `${start.toLocaleString(undefined, { ...dateFmt, ...timeFmt })} – ${end.toLocaleString(undefined, { ...dateFmt, ...timeFmt })}`;\n  } catch {\n    return event.start;\n  }\n}\n\nfunction findRelatedEvents(events: CalendarEvent[], meeting: Meeting): CalendarEvent[] {\n  const processedAt = meeting.session_info.processed_at;\n  if (!processedAt) return [];\n  const processed = new Date(processedAt).getTime();\n  if (Number.isNaN(processed)) return [];\n  const windowMs = 4 * 60 * 60 * 1000;\n  return events.filter((e) => {\n    const start = new Date(e.start).getTime();\n    const end = new Date(e.end).getTime();\n    if (Number.isNaN(start) || Number.isNaN(end)) return false;\n    return Math.abs(start - processed) <= windowMs || (processed >= start && processed <= end);\n  });\n}\n\nfunction asStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return [];\n  return value\n    .map((v) => {\n      if (typeof v === 'string') return v;\n      if (typeof v !== 'object' || v === null) return '';\n      const obj = v as Record<string, unknown>;\n      const desc = typeof obj.description === 'string' ? obj.description : '';\n      const owner = typeof obj.owner === 'string' ? obj.owner : '';\n      if (desc) return owner ? `${owner}: ${desc}` : desc;\n      if (typeof obj.text === 'string') return obj.text;\n      if (typeof obj.name === 'string') return obj.name;\n      return '';\n    })\n    .filter(Boolean);\n}\n\ninterface DiscussionArea {\n  title: string;\n  analysis?: string;\n}\n\nfunction asDiscussionAreas(value: unknown): DiscussionArea[] {\n  if (!Array.isArray(value)) return [];\n  return value\n    .map((v): DiscussionArea | null => {\n      if (typeof v === 'string') return { title: v };\n      if (typeof v !== 'object' || v === null) return null;\n      const obj = v as Record<string, unknown>;\n      const title = typeof obj.title === 'string' ? obj.title : '';\n      const analysis = typeof obj.analysis === 'string' ? obj.analysis : undefined;\n      if (!title && !analysis) return null;\n      return { title: title || 'Discussion topic', analysis };\n    })\n    .filter((v): v is DiscussionArea => v !== null);\n}\n\nfunction getErrorMessage(error: unknown): string {\n  if (error instanceof Error && error.message) return error.message;\n  if (typeof error === 'string' && error.trim()) return error;\n  return 'Something went wrong.';\n}\n"
  },
  {
    "path": "app/renderer/src/routes/Processing.tsx",
    "content": "import * as React from 'react';\nimport {\n  Calendar as CalendarIcon,\n  ChevronLeft,\n  Clock,\n  Loader2,\n  PencilLine,\n} from 'lucide-react';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { useNavigate } from '@/lib/router';\nimport { useRecording } from '@/hooks/useRecording';\nimport { useUpdateMeeting } from '@/hooks/useMeetings';\nimport { getLiveDraft, useLiveDraftStore } from '@/hooks/liveDraftStore';\nimport { ipc } from '@/lib/ipc';\n\ntype ProcessingStage = 'transcribing' | 'summarizing' | 'finalizing' | 'error';\n\nconst STAGE_LABEL: Record<ProcessingStage, string> = {\n  transcribing: 'Analyzing transcript',\n  summarizing: 'Generating notes',\n  finalizing: 'Almost done…',\n  error: 'Couldn’t process this recording.',\n};\n\nexport function Processing() {\n  const navigate = useNavigate();\n  const recording = useRecording();\n  const updateMeeting = useUpdateMeeting();\n\n  // Capture sessionName when we arrive on this route. The backend clears\n  // currentRecordingSessionName once processing-complete fires; we also need\n  // to remember it for the brief moment between stop and the queue catching up.\n  const [activeSession, setActiveSession] = React.useState<string | null>(\n    () => recording.sessionName,\n  );\n  React.useEffect(() => {\n    if (recording.sessionName && recording.sessionName !== activeSession) {\n      setActiveSession(recording.sessionName);\n    }\n  }, [recording.sessionName, activeSession]);\n\n  const draft = useLiveDraftStore((s) =>\n    activeSession ? s.drafts[activeSession] : undefined,\n  );\n  const startedAt = draft ? new Date(draft.startedAtMs) : null;\n  const totalElapsedSeconds = startedAt\n    ? Math.max(0, Math.floor((Date.now() - startedAt.getTime()) / 1000))\n    : 0;\n\n  const [stage, setStage] = React.useState<ProcessingStage>('transcribing');\n  const [streamText, setStreamText] = React.useState('');\n  const [streamedTitle, setStreamedTitle] = React.useState<string | null>(null);\n\n  React.useEffect(() => {\n    const offs = [\n      ipc().on.summaryChunk((e) => {\n        if (activeSession && e.sessionName !== activeSession) return;\n        setStreamText((t) => t + e.chunk);\n        setStage((s) => (s === 'transcribing' ? 'summarizing' : s));\n      }),\n      ipc().on.summaryTitle((e) => {\n        if (activeSession && e.sessionName !== activeSession) return;\n        setStreamedTitle(e.title);\n      }),\n      ipc().on.summaryComplete((e) => {\n        if (activeSession && e.sessionName !== activeSession) return;\n        setStage((s) => (s === 'error' ? s : 'finalizing'));\n      }),\n      ipc().on.processingComplete((e) => {\n        if (activeSession && e.sessionName !== activeSession) return;\n        if (!e.success) {\n          setStage('error');\n          return;\n        }\n        // Apply title rename if user edited the draft. The global useRecording\n        // listener handles navigation to /meetings/<final>.\n        const d = getLiveDraft(e.sessionName);\n        const summaryFile = e.meetingData?.session_info.summary_file;\n        if (summaryFile && d?.title && d.title !== e.sessionName) {\n          updateMeeting.mutate({\n            summaryFile,\n            patch: { name: d.title },\n          });\n        }\n      }),\n    ];\n    return () => offs.forEach((fn) => fn());\n  }, [activeSession, updateMeeting]);\n\n  const displayTitle =\n    streamedTitle ?? draft?.title ?? activeSession ?? 'Note';\n\n  return (\n    <MeetingsShell activeSummaryFile={null}>\n      <div className=\"mx-auto w-full max-w-[760px]\">\n        <header className=\"mb-8\">\n          <button\n            type=\"button\"\n            onClick={() => navigate('/')}\n            className=\"mb-6 inline-flex cursor-pointer items-center gap-1 border-0 bg-transparent text-[13px] transition-colors hover:text-[color:var(--fg-1)]\"\n            style={{ color: 'var(--fg-2)' }}\n            aria-label=\"Back to home\"\n          >\n            <ChevronLeft size={15} />\n            Home\n          </button>\n\n          <h1\n            className=\"m-0 text-[34px] transition-colors\"\n            style={{\n              fontFamily: 'var(--font-serif)',\n              letterSpacing: '-0.02em',\n              color: 'var(--fg-1)',\n              lineHeight: 1.15,\n              fontWeight: 400,\n            }}\n          >\n            {displayTitle}\n          </h1>\n\n          <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n            {startedAt && (\n              <Chip icon={<CalendarIcon size={11} />}>\n                {formatDate(startedAt)}\n              </Chip>\n            )}\n            <Chip icon={<Clock size={11} />}>\n              {formatDurationEnglish(totalElapsedSeconds)}\n            </Chip>\n            <ProcessingChip />\n          </div>\n        </header>\n\n        {stage === 'error' ? (\n          <ErrorPanel onRetry={() => setStage('transcribing')} />\n        ) : (\n          <StageCard stage={stage} streamText={streamText} />\n        )}\n\n        {draft?.notes && (\n          <section className=\"mt-8\">\n            <div\n              className=\"mb-2 inline-flex items-center gap-1.5 text-[13px]\"\n              style={{ color: 'var(--fg-2)' }}\n            >\n              <PencilLine size={13} />\n              My notes\n            </div>\n            <div\n              className=\"whitespace-pre-wrap text-[15px]\"\n              style={{\n                color: 'var(--fg-1)',\n                fontFamily: 'var(--font-sans)',\n                lineHeight: 1.6,\n              }}\n            >\n              {draft.notes}\n            </div>\n          </section>\n        )}\n      </div>\n    </MeetingsShell>\n  );\n}\n\nfunction StageCard({\n  stage,\n  streamText,\n}: {\n  stage: ProcessingStage;\n  streamText: string;\n}) {\n  return (\n    <div className=\"relative\" style={{ maxWidth: '72ch' }}>\n      {streamText && (\n        <div\n          className=\"mb-3 whitespace-pre-wrap text-[15px]\"\n          style={{\n            color: 'var(--fg-1)',\n            fontFamily: 'var(--font-sans)',\n            lineHeight: 1.6,\n          }}\n        >\n          {streamText}\n        </div>\n      )}\n\n      {/* Scanner bar — rides at the bottom of streamed text, slides down as\n          more tokens arrive, matching the legacy generation-scanner. */}\n      <div\n        className=\"flex items-center gap-2.5 rounded-lg px-3.5 py-2.5\"\n        style={{\n          background: 'var(--surface-raised)',\n          border: '1px solid var(--border-subtle)',\n          boxShadow: 'var(--shadow-md)',\n          transition: 'all 0.45s cubic-bezier(0.33, 1, 0.68, 1)',\n        }}\n      >\n        <Loader2\n          className=\"animate-spin\"\n          size={14}\n          style={{ color: 'var(--fg-2)' }}\n        />\n        <span\n          className=\"text-[13px] transition-colors\"\n          style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n        >\n          {STAGE_LABEL[stage]}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction ErrorPanel({ onRetry }: { onRetry: () => void }) {\n  return (\n    <div className=\"py-3\">\n      <p\n        className=\"text-[17px]\"\n        style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}\n      >\n        {STAGE_LABEL.error}\n      </p>\n      <p\n        className=\"mt-1 text-[14px]\"\n        style={{ color: 'var(--fg-2)' }}\n      >\n        Try again, or reprocess the recording from the meeting list once it\n        appears.\n      </p>\n      <button\n        type=\"button\"\n        onClick={onRetry}\n        className=\"mt-4 inline-flex h-9 cursor-pointer items-center rounded-[8px] border-0 px-4 text-[14px] font-medium\"\n        style={{\n          background: 'var(--fg-1)',\n          color: 'var(--fg-inverse)',\n        }}\n      >\n        Try again\n      </button>\n    </div>\n  );\n}\n\nfunction ProcessingChip() {\n  return (\n    <span\n      className=\"inline-flex items-center gap-1.5 px-2 py-1 text-[12px]\"\n      style={{\n        color: 'var(--fg-2)',\n        background: 'var(--surface-sunken)',\n        borderRadius: 'var(--radius-sm)',\n      }}\n    >\n      <Loader2 className=\"animate-spin\" size={11} />\n      Processing\n    </span>\n  );\n}\n\nfunction Chip({\n  icon,\n  children,\n}: {\n  icon: React.ReactNode;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      className=\"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px]\"\n      style={{\n        color: 'var(--fg-2)',\n        border: '1px solid var(--border-subtle)',\n        background: 'var(--surface-raised)',\n      }}\n    >\n      {icon}\n      {children}\n    </span>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Bottom-dock content for /meetings/processing — same screen slot as AskBar\n// and LiveDock so the transition feels like a content swap.\n// ---------------------------------------------------------------------------\n\nexport function ProcessingDock() {\n  const recording = useRecording();\n  const sessionName = recording.sessionName;\n  const draft = useLiveDraftStore((s) =>\n    sessionName ? s.drafts[sessionName] : undefined,\n  );\n  const startedAt = draft ? new Date(draft.startedAtMs) : null;\n  const totalElapsedSeconds = startedAt\n    ? Math.max(0, Math.floor((Date.now() - startedAt.getTime()) / 1000))\n    : 0;\n\n  return (\n    <div className=\"flex justify-center pointer-events-none\">\n      <div\n        className=\"pointer-events-auto flex items-center gap-3 rounded-full px-3 py-2\"\n        style={{\n          background: 'var(--surface-raised)',\n          border: '1px solid var(--border-subtle)',\n          boxShadow: 'var(--shadow-md)',\n        }}\n      >\n        <span\n          className=\"inline-flex items-center gap-2 px-2 text-[13px]\"\n          style={{ color: 'var(--fg-1)' }}\n        >\n          <Loader2\n            className=\"animate-spin\"\n            size={14}\n            style={{ color: 'var(--fg-2)' }}\n          />\n          <span style={{ color: 'var(--fg-2)' }}>Processing</span>\n          <span\n            className=\"tabular-nums\"\n            style={{\n              color: 'var(--fg-1)',\n              fontFamily: 'var(--font-sans)',\n              fontSize: 13,\n            }}\n          >\n            {formatDurationEnglish(totalElapsedSeconds)}\n          </span>\n        </span>\n      </div>\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction formatDate(d: Date): string {\n  return d.toLocaleDateString(undefined, {\n    weekday: 'short',\n    day: 'numeric',\n    month: 'short',\n    year: 'numeric',\n  });\n}\n\n/** Plain-English duration (\"12 min\", \"1 h 4 min\"). Mono is reserved for the live timer. */\nfunction formatDurationEnglish(seconds: number): string {\n  if (seconds < 60) return `${seconds} sec`;\n  const totalMinutes = Math.floor(seconds / 60);\n  if (totalMinutes < 60) return `${totalMinutes} min`;\n  const h = Math.floor(totalMinutes / 60);\n  const m = totalMinutes % 60;\n  if (m === 0) return `${h} h`;\n  return `${h} h ${m} min`;\n}\n"
  },
  {
    "path": "app/renderer/src/routes/Recording.tsx",
    "content": "import * as React from 'react';\nimport {\n  Calendar as CalendarIcon,\n  ChevronLeft,\n  Clock,\n  FolderPlus,\n  PencilLine,\n} from 'lucide-react';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { useNavigate } from '@/lib/router';\nimport { useRecording } from '@/hooks/useRecording';\nimport { useLiveMeeting } from '@/hooks/useLiveMeeting';\n\nexport function Recording() {\n  const navigate = useNavigate();\n  const recording = useRecording();\n  const live = useLiveMeeting();\n\n  // If we land on /recording with no active recording (e.g. cold reload after\n  // it stopped), bounce back home so we don't leave the user on a dead page.\n  // Status 'processing' is handled by the global listener which redirects to\n  // /meetings/processing.\n  React.useEffect(() => {\n    if (!recording.isLoading && !live.active && recording.status !== 'processing') {\n      navigate('/');\n    }\n  }, [recording.isLoading, live.active, recording.status, navigate]);\n\n  const startedAt = live.startedAt ?? new Date();\n\n  return (\n    <MeetingsShell activeSummaryFile={null} hideToolbar>\n      <div\n        data-testid=\"recording-page\"\n        className=\"flex h-full min-h-0 flex-1 flex-col overflow-hidden\"\n        style={{ background: 'var(--page)' }}\n      >\n        <div className=\"scrollbar-clean min-h-0 flex-1 overflow-y-auto\">\n          <div className=\"mx-auto max-w-[760px] px-12 pb-40 pt-8\">\n            <header className=\"mb-8\">\n              <button\n                type=\"button\"\n                onClick={() => navigate('/')}\n                className=\"mb-6 inline-flex cursor-pointer items-center gap-1 border-0 bg-transparent text-[13px] transition-colors hover:text-[color:var(--fg-1)]\"\n                style={{ color: 'var(--fg-2)' }}\n                aria-label=\"Back to home\"\n              >\n                <ChevronLeft size={15} />\n                Home\n              </button>\n\n              <EditableTitle\n                value={live.title}\n                onChange={live.setTitle}\n                placeholder=\"New note\"\n              />\n\n              <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n                <Chip icon={<CalendarIcon size={11} />}>\n                  {formatDate(startedAt)}\n                </Chip>\n                <Chip icon={<Clock size={11} />}>\n                  Started {formatTime(startedAt)}\n                </Chip>\n                <Chip icon={<FolderPlus size={11} />} dashed>\n                  Add to folder\n                </Chip>\n              </div>\n            </header>\n\n            <section>\n              <div\n                className=\"mb-2 inline-flex items-center gap-1.5 text-[13px]\"\n                style={{ color: 'var(--fg-2)' }}\n              >\n                <PencilLine size={13} />\n                My notes\n              </div>\n              <textarea\n                value={live.notes}\n                onChange={(e) => live.setNotes(e.target.value)}\n                placeholder=\"Type anything you want to capture — decisions, questions, follow-ups. Steno handles the transcript.\"\n                spellCheck\n                className=\"block w-full resize-none border-0 bg-transparent text-[15px] outline-none\"\n                style={{\n                  color: 'var(--fg-1)',\n                  fontFamily: 'var(--font-sans)',\n                  lineHeight: 1.6,\n                  minHeight: 320,\n                }}\n              />\n            </section>\n          </div>\n        </div>\n      </div>\n    </MeetingsShell>\n  );\n}\n\ninterface EditableTitleProps {\n  value: string;\n  onChange: (next: string) => void;\n  placeholder?: string;\n}\n\nfunction EditableTitle({ value, onChange, placeholder }: EditableTitleProps) {\n  return (\n    <input\n      value={value}\n      onChange={(e) => onChange(e.target.value)}\n      placeholder={placeholder}\n      spellCheck={false}\n      className=\"w-full border-0 bg-transparent p-0 text-[34px] outline-none\"\n      style={{\n        fontFamily: 'var(--font-serif)',\n        letterSpacing: '-0.02em',\n        color: 'var(--fg-1)',\n        lineHeight: 1.15,\n      }}\n    />\n  );\n}\n\nfunction Chip({\n  icon,\n  children,\n  dashed = false,\n}: {\n  icon: React.ReactNode;\n  children: React.ReactNode;\n  dashed?: boolean;\n}) {\n  return (\n    <span\n      className=\"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px]\"\n      style={{\n        color: 'var(--fg-2)',\n        border: dashed\n          ? '1px dashed var(--border-subtle)'\n          : '1px solid var(--border-subtle)',\n        background: dashed ? 'transparent' : 'var(--surface-raised)',\n      }}\n    >\n      {icon}\n      {children}\n    </span>\n  );\n}\n\nfunction formatDate(d: Date): string {\n  return d.toLocaleDateString(undefined, {\n    weekday: 'short',\n    day: 'numeric',\n    month: 'short',\n    year: 'numeric',\n  });\n}\n\nfunction formatTime(d: Date): string {\n  const h = d.getHours().toString().padStart(2, '0');\n  const m = d.getMinutes().toString().padStart(2, '0');\n  return `${h}:${m}`;\n}\n"
  },
  {
    "path": "app/renderer/src/routes/Sandbox.tsx",
    "content": "import * as React from 'react';\nimport { Search, FolderPlus, Plus, Copy, Check, Mic } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input, Textarea } from '@/components/ui/input';\nimport { Chip } from '@/components/ui/chip';\nimport { Row } from '@/components/ui/row';\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from '@/components/ui/card';\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport {\n  Display,\n  H1,\n  H2,\n  H3,\n  Lead,\n  Muted,\n} from '@/components/ui/typography';\nimport { useTheme } from '@/hooks/useTheme';\nimport { cn } from '@/lib/utils';\n\ninterface SectionProps {\n  id: string;\n  title: string;\n  children: React.ReactNode;\n  hint?: string;\n}\n\nfunction Section({ id, title, hint, children }: SectionProps) {\n  return (\n    <section\n      id={id}\n      data-sandbox-section={id}\n      className=\"space-y-4 border-b border-border pb-10\"\n    >\n      <header className=\"space-y-1\">\n        <H3 className=\"text-foreground\">{title}</H3>\n        {hint && <Muted>{hint}</Muted>}\n      </header>\n      {children}\n    </section>\n  );\n}\n\nfunction Stack({\n  label,\n  children,\n  className,\n}: {\n  label: string;\n  children: React.ReactNode;\n  className?: string;\n}) {\n  return (\n    <div className={cn('space-y-2', className)}>\n      <div className=\"text-[11px] font-medium uppercase tracking-wide text-muted-foreground\">\n        {label}\n      </div>\n      <div className=\"flex flex-wrap items-start gap-3\">{children}</div>\n    </div>\n  );\n}\n\nfunction Swatch({ name, value }: { name: string; value: string }) {\n  return (\n    <div className=\"flex w-40 items-center gap-3 rounded-md border border-border p-2\">\n      <div\n        aria-hidden\n        className=\"size-8 rounded\"\n        style={{ background: value }}\n      />\n      <div className=\"flex flex-col text-xs leading-tight\">\n        <span className=\"font-medium text-foreground\">{name}</span>\n        <span className=\"font-mono text-muted-foreground\">{value}</span>\n      </div>\n    </div>\n  );\n}\n\nexport function Sandbox() {\n  const { theme, setTheme, resolved } = useTheme();\n  const [dialogOpen, setDialogOpen] = React.useState(false);\n\n  return (\n    <div className=\"min-h-screen bg-background text-foreground\">\n      <div className=\"mx-auto max-w-[960px] px-8 pb-24 pt-12\">\n        <div className=\"mb-12 flex items-center justify-between gap-4\">\n          <div className=\"space-y-2\">\n            <Display>Steno sandbox</Display>\n            <Lead>\n              Visual approval surface for the core component library. Every\n              state, both themes. Sign-off gates screen assembly.\n            </Lead>\n            <Muted>\n              Active theme: <span className=\"font-medium text-foreground\">{resolved}</span>{' '}\n              (preference: {theme})\n            </Muted>\n          </div>\n          <div className=\"flex shrink-0 items-center gap-2\" data-no-screenshot>\n            <Button\n              variant={theme === 'light' ? 'default' : 'outline'}\n              size=\"sm\"\n              onClick={() => setTheme('light')}\n            >\n              Light\n            </Button>\n            <Button\n              variant={theme === 'dark' ? 'default' : 'outline'}\n              size=\"sm\"\n              onClick={() => setTheme('dark')}\n            >\n              Dark\n            </Button>\n            <Button\n              variant={theme === 'system' ? 'default' : 'outline'}\n              size=\"sm\"\n              onClick={() => setTheme('system')}\n            >\n              System\n            </Button>\n          </div>\n        </div>\n\n        <div className=\"space-y-10\">\n          <Section\n            id=\"typography\"\n            title=\"Typography\"\n            hint=\"Charter serif for display + H1/H2. Inter for body. JetBrains Mono for code.\"\n          >\n            <div className=\"space-y-3\">\n              <Display>Display — welcome back.</Display>\n              <H1>H1 — meetings overview</H1>\n              <H2>H2 — Tuesday, 14 March</H2>\n              <H3>H3 — section heading</H3>\n              <Lead>\n                Lead — this is what a paragraph lead reads like in the Steno design\n                system. It stays on muted-foreground until you need emphasis.\n              </Lead>\n              <Muted>Muted — smaller secondary copy.</Muted>\n              <p className=\"font-mono text-sm\">mono — 00:47:12 · whisper-base.en</p>\n            </div>\n          </Section>\n\n          <Section id=\"palette\" title=\"Palette\" hint=\"Paper → ink ramp + brand accents.\">\n            <Stack label=\"Paper\">\n              <Swatch name=\"paper-0\" value=\"var(--steno-paper-0)\" />\n              <Swatch name=\"paper-1\" value=\"var(--steno-paper-1)\" />\n              <Swatch name=\"paper-2\" value=\"var(--steno-paper-2)\" />\n            </Stack>\n            <Stack label=\"Ink\">\n              <Swatch name=\"ink-900\" value=\"var(--steno-ink-900)\" />\n              <Swatch name=\"ink-500\" value=\"var(--steno-ink-500)\" />\n            </Stack>\n            <Stack label=\"Signal\">\n              <Swatch name=\"recording\" value=\"var(--steno-recording)\" />\n            </Stack>\n          </Section>\n\n          <Section id=\"buttons\" title=\"Buttons\" hint=\"Variant × size × disabled matrix.\">\n            <Stack label=\"Variants (size=default)\">\n              <Button>Default</Button>\n              <Button variant=\"outline\">Outline</Button>\n              <Button variant=\"secondary\">Secondary</Button>\n              <Button variant=\"ghost\">Ghost</Button>\n              <Button variant=\"destructive\">Destructive</Button>\n              <Button variant=\"link\">Link</Button>\n            </Stack>\n            <Stack label=\"Sizes\">\n              <Button size=\"sm\">Small</Button>\n              <Button size=\"default\">Default</Button>\n              <Button size=\"lg\">Large</Button>\n              <Button size=\"icon\" aria-label=\"Add\">\n                <Plus />\n              </Button>\n            </Stack>\n            <Stack label=\"With icon\">\n              <Button variant=\"outline\">\n                <Copy /> Copy summary\n              </Button>\n              <Button variant=\"default\">\n                <Check /> Saved\n              </Button>\n              <Button variant=\"destructive\">\n                <Mic /> Record\n              </Button>\n            </Stack>\n            <Stack label=\"Disabled\">\n              <Button disabled>Default</Button>\n              <Button variant=\"outline\" disabled>\n                Outline\n              </Button>\n              <Button variant=\"destructive\" disabled>\n                Destructive\n              </Button>\n            </Stack>\n          </Section>\n\n          <Section id=\"inputs\" title=\"Inputs\" hint=\"Text entry + search + textarea.\">\n            <Stack label=\"Variants\">\n              <div className=\"w-80\">\n                <Input placeholder=\"Default input\" />\n              </div>\n              <div className=\"w-80\">\n                <Input\n                  variant=\"sunken\"\n                  iconStart={<Search className=\"size-4\" />}\n                  placeholder=\"Search meetings\"\n                />\n              </div>\n              <div className=\"w-80\">\n                <Input variant=\"inherit\" placeholder=\"Inherit — used in inline rename\" />\n              </div>\n            </Stack>\n            <Stack label=\"Sizes\">\n              <div className=\"w-64\">\n                <Input size=\"sm\" placeholder=\"Small\" />\n              </div>\n              <div className=\"w-64\">\n                <Input placeholder=\"Default\" />\n              </div>\n              <div className=\"w-64\">\n                <Input size=\"lg\" placeholder=\"Large\" />\n              </div>\n            </Stack>\n            <Stack label=\"States\">\n              <div className=\"w-64\">\n                <Input placeholder=\"Empty\" />\n              </div>\n              <div className=\"w-64\">\n                <Input defaultValue=\"Filled content\" />\n              </div>\n              <div className=\"w-64\">\n                <Input disabled placeholder=\"Disabled\" />\n              </div>\n            </Stack>\n            <Stack label=\"Textarea\">\n              <div className=\"w-[480px]\">\n                <Textarea\n                  placeholder=\"Paste transcript or type a note — auto-resizes.\"\n                  autoResize\n                />\n              </div>\n            </Stack>\n          </Section>\n\n          <Section id=\"chips\" title=\"Chips\" hint=\"Used for tags, prompts, filter pills.\">\n            <Stack label=\"Variants\">\n              <Chip>Unread</Chip>\n              <Chip variant=\"muted\">Clients</Chip>\n              <Chip variant=\"destructive\">Failed</Chip>\n            </Stack>\n            <Stack label=\"Interactive (onClick)\">\n              <Chip\n                variant=\"muted\"\n                onClick={() => {}}\n                aria-label=\"What was decided\"\n              >\n                What was decided?\n              </Chip>\n              <Chip variant=\"muted\" onClick={() => {}}>\n                Summarize action items\n              </Chip>\n              <Chip variant=\"muted\" onClick={() => {}}>\n                Who owns follow-up?\n              </Chip>\n            </Stack>\n          </Section>\n\n          <Section\n            id=\"rows\"\n            title=\"Rows\"\n            hint=\"Sidebar entries, settings nav, folder headers.\"\n          >\n            <div className=\"w-[320px] space-y-1 rounded-md border border-border p-2\">\n              <Row\n                size=\"sm\"\n                label=\"Clients\"\n                collapsible\n                open\n                trailing={2}\n                onClick={() => {}}\n                className=\"text-muted-foreground\"\n              />\n              <div className=\"pl-4\">\n                <Row\n                  label=\"Acme Corp — quarterly review\"\n                  trailing=\"Tue\"\n                  onClick={() => {}}\n                />\n                <Row\n                  label=\"Nova Labs — roadmap\"\n                  trailing=\"Apr 14\"\n                  active\n                  onClick={() => {}}\n                />\n              </div>\n              <Row\n                size=\"sm\"\n                label=\"Research\"\n                collapsible\n                trailing={1}\n                onClick={() => {}}\n                className=\"text-muted-foreground\"\n              />\n            </div>\n            <Stack label=\"Sizes\">\n              <div className=\"w-[320px] space-y-1\">\n                <Row size=\"sm\" label=\"Small row\" trailing=\"Fri\" onClick={() => {}} />\n                <Row size=\"md\" label=\"Medium row (default)\" trailing=\"3\" onClick={() => {}} />\n                <Row size=\"lg\" label=\"Large row\" trailing=\"Active\" onClick={() => {}} />\n              </div>\n            </Stack>\n            <Stack label=\"States\">\n              <div className=\"w-[320px] space-y-1\">\n                <Row label=\"Default\" onClick={() => {}} />\n                <Row label=\"Active\" active onClick={() => {}} />\n                <Row label=\"Static (no onClick)\" />\n                <Row\n                  label=\"Collapsible, closed\"\n                  collapsible\n                  trailing={4}\n                  onClick={() => {}}\n                />\n                <Row\n                  label=\"Collapsible, open\"\n                  collapsible\n                  open\n                  trailing={4}\n                  onClick={() => {}}\n                />\n              </div>\n            </Stack>\n          </Section>\n\n          <Section id=\"cards\" title=\"Cards\" hint=\"Flat by default; raised + padded as variants.\">\n            <Stack label=\"Flat (default)\">\n              <Card className=\"w-[320px]\">\n                <CardHeader>\n                  <CardTitle>Team sync — product planning</CardTitle>\n                  <CardDescription>Today · 38 min</CardDescription>\n                </CardHeader>\n                <CardContent>\n                  <Muted>\n                    Reviewed Q2 roadmap, debated the renderer rework scope, closed out\n                    the open ADRs.\n                  </Muted>\n                </CardContent>\n              </Card>\n            </Stack>\n            <Stack label=\"Raised + padded\">\n              <Card raised padded className=\"w-[320px]\">\n                <CardHeader>\n                  <CardTitle>Onboarding call with Ava</CardTitle>\n                  <CardDescription>Mon · 22 min</CardDescription>\n                </CardHeader>\n                <CardContent>\n                  <Muted>\n                    Ava walked through her customer discovery process, we mapped the\n                    ICP she has in mind to the wedge we've been debating.\n                  </Muted>\n                </CardContent>\n                <CardFooter>\n                  <Button variant=\"outline\" size=\"sm\">\n                    <Copy /> Copy summary\n                  </Button>\n                </CardFooter>\n              </Card>\n            </Stack>\n          </Section>\n\n          <Section id=\"dialog\" title=\"Dialog\" hint=\"Radix-backed modal. Open state rendered below for screenshot.\">\n            <Stack label=\"Trigger\">\n              <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>\n                <DialogTrigger asChild>\n                  <Button variant=\"outline\">\n                    <FolderPlus /> New folder\n                  </Button>\n                </DialogTrigger>\n                <DialogContent>\n                  <DialogHeader>\n                    <DialogTitle>New folder</DialogTitle>\n                    <DialogDescription>\n                      Group related meetings together. Folder names are only visible\n                      to you.\n                    </DialogDescription>\n                  </DialogHeader>\n                  <Input placeholder=\"e.g. Acme Corp\" defaultValue=\"\" autoFocus />\n                  <DialogFooter>\n                    <DialogClose asChild>\n                      <Button variant=\"outline\">Cancel</Button>\n                    </DialogClose>\n                    <Button>Create folder</Button>\n                  </DialogFooter>\n                </DialogContent>\n              </Dialog>\n            </Stack>\n          </Section>\n\n          <Section id=\"tabs\" title=\"Tabs\" hint=\"Used in settings + meeting detail.\">\n            <div className=\"w-[520px]\">\n              <Tabs defaultValue=\"summary\">\n                <TabsList>\n                  <TabsTrigger value=\"summary\">Summary</TabsTrigger>\n                  <TabsTrigger value=\"transcript\">Transcript</TabsTrigger>\n                  <TabsTrigger value=\"chat\">Chat</TabsTrigger>\n                </TabsList>\n                <TabsContent value=\"summary\">\n                  <Muted>Summary pane — headline, key points, action items.</Muted>\n                </TabsContent>\n                <TabsContent value=\"transcript\">\n                  <Muted>Transcript pane — virtualized list of segments.</Muted>\n                </TabsContent>\n                <TabsContent value=\"chat\">\n                  <Muted>Chat pane — streaming Q&A with transcript.</Muted>\n                </TabsContent>\n              </Tabs>\n            </div>\n          </Section>\n\n          <Section id=\"recording\" title=\"Recording indicator\" hint=\"Dot + pulse animation (used in titlebar).\">\n            <Stack label=\"Live\">\n              <div className=\"flex items-center gap-2 rounded-md border border-border px-3 py-2\">\n                <span className=\"recording-dot\" />\n                <span className=\"font-mono text-sm tabular-nums\">00:12:04</span>\n              </div>\n            </Stack>\n          </Section>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/routes/Settings.tsx",
    "content": "import * as React from 'react';\nimport {\n  ArrowLeft,\n  Check,\n  ChevronDown,\n  ChevronRight,\n  Copy,\n  ExternalLink,\n  Loader2,\n  X,\n} from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Switch } from '@/components/ui/switch';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { useNavigate, getLastNonSettingsRoute } from '@/lib/router';\nimport {\n  clearDebugLogs,\n  getDebugLogs,\n  subscribeDebugLogs,\n} from '@/lib/debugLogs';\nimport { cn } from '@/lib/utils';\nimport { useTheme } from '@/hooks/useTheme';\nimport {\n  useAppVersion,\n  useClearSystemState,\n  useDockIconSetting,\n  useLanguageSetting,\n  useNotificationsSetting,\n  usePickStorageFolder,\n  useSetDockIcon,\n  useSetLanguage,\n  useSetNotifications,\n  useSetStoragePath,\n  useSetSystemAudio,\n  useSetTelemetry,\n  useSetUserName,\n  useStoragePath,\n  useSystemAudioSetting,\n  useTelemetrySetting,\n  useUserName,\n} from '@/hooks/useSettings';\nimport {\n  useAiProvider,\n  useSetAiProvider,\n  useSetCloudApiKey,\n  useSetCloudApiUrl,\n  useSetCloudModel,\n  useSetCloudProvider,\n  useSetRemoteOllamaUrl,\n  useTestCloudApi,\n  useTestRemoteOllama,\n} from '@/hooks/useAi';\nimport {\n  useCurrentModel,\n  useModels,\n  usePullModel,\n  useSetCurrentModel,\n} from '@/hooks/useModels';\nimport {\n  useGoogleCalendarAuth,\n  useOutlookCalendarAuth,\n} from '@/hooks/useCalendarEvents';\nimport type { AiProvider, CloudProvider } from '@/lib/ipc';\n\n// ---------------------------------------------------------------------------\n// Local style helpers — values come straight from the new design (Pencil\n// bundle at /tmp/design-extract/.../Settings.jsx). Kept inline rather than\n// promoted to /components/ui because they only fit the Settings layout.\n// ---------------------------------------------------------------------------\n\nconst COMPACT_TRIGGER =\n  'h-[30px] min-w-[150px] rounded-[6px] bg-[color:var(--surface-raised)] px-2.5 py-0 text-[13px]';\nconst COMPACT_BTN = 'h-[30px] px-3 text-[13px]';\n\nconst LANGUAGES: Array<{ value: string; label: string }> = [\n  { value: 'auto', label: 'Auto (detect)' },\n  { value: 'en', label: 'English' },\n  { value: 'es', label: 'Spanish' },\n  { value: 'fr', label: 'French' },\n  { value: 'de', label: 'German' },\n  { value: 'nl', label: 'Dutch' },\n  { value: 'pt', label: 'Portuguese' },\n  { value: 'ja', label: 'Japanese' },\n  { value: 'zh', label: 'Chinese' },\n  { value: 'ko', label: 'Korean' },\n  { value: 'hi', label: 'Hindi' },\n  { value: 'ar', label: 'Arabic' },\n];\n\nconst TABS = [\n  { id: 'general', label: 'General' },\n  { id: 'ai', label: 'AI' },\n  { id: 'advanced', label: 'Advanced' },\n  { id: 'developer', label: 'Developer' },\n] as const;\n\ntype TabId = (typeof TABS)[number]['id'];\n\n// ---------------------------------------------------------------------------\n// File-private primitives\n// ---------------------------------------------------------------------------\n\ninterface SettingRowProps {\n  label: string;\n  description?: React.ReactNode;\n  children: React.ReactNode;\n  align?: 'center' | 'start';\n  noBorder?: boolean;\n  muted?: boolean;\n}\n\nfunction SettingRow({\n  label,\n  description,\n  children,\n  align = 'center',\n  noBorder = false,\n  muted = false,\n}: SettingRowProps) {\n  return (\n    <div\n      className={cn(\n        'flex gap-6 py-4',\n        align === 'start' ? 'items-start' : 'items-center',\n      )}\n      style={{\n        borderBottom: noBorder ? 'none' : '1px solid var(--border-subtle)',\n        opacity: muted ? 0.45 : 1,\n      }}\n    >\n      <div className=\"min-w-0 flex-1\">\n        <div\n          className=\"text-[14px] font-medium\"\n          style={{ color: 'var(--fg-1)', marginBottom: 2 }}\n        >\n          {label}\n        </div>\n        {description && (\n          <div\n            className=\"text-[13px] leading-[1.5]\"\n            style={{ color: 'var(--fg-2)' }}\n          >\n            {description}\n          </div>\n        )}\n      </div>\n      <div className=\"shrink-0\">{children}</div>\n    </div>\n  );\n}\n\n/** A read-only value with a click-to-copy button. Used for paths and IDs that\n *  users frequently need to paste into bug reports or terminal sessions. */\nfunction CopyableValue({ value, mono = false }: { value: string; mono?: boolean }) {\n  const [copied, setCopied] = React.useState(false);\n  const onCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(value);\n      setCopied(true);\n      window.setTimeout(() => setCopied(false), 1500);\n    } catch {\n      // Clipboard write may fail on systems without permission. Fail silently\n      // — the value is still visible and the user can select-copy manually.\n    }\n  };\n  return (\n    <div\n      className=\"inline-flex max-w-full items-center gap-1 rounded-[4px] pl-2 pr-1 py-[2px]\"\n      style={{ background: 'var(--surface-sunken)', color: 'var(--fg-2)' }}\n    >\n      <code\n        className={cn(\n          'flex-1 truncate select-all text-[12px]',\n          mono && 'font-mono',\n        )}\n        title={value}\n      >\n        {value}\n      </code>\n      <button\n        type=\"button\"\n        onClick={onCopy}\n        aria-label={copied ? 'Copied' : 'Copy to clipboard'}\n        title={copied ? 'Copied' : 'Copy to clipboard'}\n        className=\"inline-flex size-[22px] flex-shrink-0 items-center justify-center rounded transition-colors hover:bg-[color:var(--surface-hover)]\"\n        style={{ color: copied ? 'var(--fg-1)' : 'var(--fg-2)' }}\n      >\n        {copied ? <Check size={12} /> : <Copy size={12} />}\n      </button>\n    </div>\n  );\n}\n\nfunction SectionHeading({ children }: { children: React.ReactNode }) {\n  return (\n    <div\n      className=\"text-[11px] font-medium uppercase\"\n      style={{\n        letterSpacing: '0.06em',\n        color: 'var(--fg-muted)',\n        padding: '20px 0 8px',\n      }}\n    >\n      {children}\n    </div>\n  );\n}\n\ninterface TabButtonProps {\n  active: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}\n\nfunction TabButton({ active, onClick, children }: TabButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        'cursor-pointer border-0 bg-transparent px-3 py-1.5 text-[13px] transition-colors',\n        active ? 'font-medium' : 'font-normal hover:text-[color:var(--fg-1)]',\n      )}\n      style={{\n        color: active ? 'var(--fg-1)' : 'var(--fg-2)',\n        borderTopLeftRadius: 'var(--radius-sm)',\n        borderTopRightRadius: 'var(--radius-sm)',\n        borderBottom: active\n          ? '2px solid var(--fg-1)'\n          : '2px solid transparent',\n        marginBottom: -1,\n      }}\n    >\n      {children}\n    </button>\n  );\n}\n\ninterface ModelCardProps {\n  name: string;\n  sizeLabel?: string;\n  note?: React.ReactNode;\n  isCurrent: boolean;\n  isDefault?: boolean;\n  deprecated?: boolean;\n  isDownloading?: boolean;\n  downloadProgress?: string;\n  onSelect: () => void;\n}\n\nfunction ModelCard({\n  name,\n  sizeLabel,\n  note,\n  isCurrent,\n  isDefault = false,\n  deprecated = false,\n  isDownloading = false,\n  downloadProgress,\n  onSelect,\n}: ModelCardProps) {\n  return (\n    <div\n      className=\"mb-1.5 flex items-center gap-4 rounded-[8px] px-4 py-[13px] transition-colors\"\n      style={{\n        border: `1px solid ${\n          isCurrent ? 'var(--border-strong)' : 'var(--border-subtle)'\n        }`,\n        background: isCurrent ? 'var(--surface-raised)' : 'transparent',\n        opacity: deprecated ? 0.4 : 1,\n      }}\n    >\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"mb-[3px] flex flex-wrap items-baseline gap-2.5\">\n          <span\n            className=\"font-mono text-[13px]\"\n            style={{\n              color: 'var(--fg-1)',\n              fontWeight: isCurrent ? 500 : 400,\n            }}\n          >\n            {name}\n          </span>\n          {sizeLabel && (\n            <span\n              className=\"text-[12px] tabular-nums\"\n              style={{ color: 'var(--fg-muted)' }}\n            >\n              {sizeLabel}\n            </span>\n          )}\n          {isDefault && !isCurrent && (\n            <span\n              className=\"rounded-[3px] px-1.5 py-px text-[11px]\"\n              style={{\n                background: 'var(--surface-sunken)',\n                color: 'var(--fg-muted)',\n                border: '1px solid var(--border)',\n              }}\n            >\n              Default\n            </span>\n          )}\n          {deprecated && (\n            <span\n              className=\"rounded-[3px] px-1.5 py-px text-[11px]\"\n              style={{\n                color: 'var(--fg-muted)',\n                border: '1px solid var(--border-subtle)',\n              }}\n            >\n              Deprecated\n            </span>\n          )}\n        </div>\n        {note && (\n          <div\n            className=\"text-[13px] leading-[1.4]\"\n            style={{ color: 'var(--fg-2)' }}\n          >\n            {note}\n          </div>\n        )}\n        {isDownloading && downloadProgress && (\n          <div\n            className=\"mt-1 font-mono text-[12px]\"\n            style={{ color: 'var(--fg-2)' }}\n          >\n            {downloadProgress}\n          </div>\n        )}\n      </div>\n      {isCurrent ? (\n        <span\n          className=\"inline-flex shrink-0 items-center gap-1.5 text-[13px] font-medium\"\n          style={{ color: 'var(--fg-1)' }}\n        >\n          <Check size={13} />\n          Selected\n        </span>\n      ) : !deprecated ? (\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className=\"h-[28px] shrink-0 px-3.5 text-[13px]\"\n          disabled={isDownloading}\n          onClick={onSelect}\n        >\n          {isDownloading ? (\n            <>\n              <Loader2 className=\"mr-1.5 size-3 animate-spin\" />\n              Downloading\n            </>\n          ) : (\n            'Select'\n          )}\n        </Button>\n      ) : null}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Settings page\n// ---------------------------------------------------------------------------\n\nexport function Settings() {\n  const navigate = useNavigate();\n  const [tab, setTab] = React.useState<TabId>('general');\n  const version = useAppVersion();\n\n  return (\n    <MeetingsShell activeSummaryFile={null} bleed>\n      <div\n        data-testid=\"settings-page\"\n        className=\"flex h-full min-h-0 flex-1 flex-col overflow-hidden\"\n        style={{ background: 'var(--page)' }}\n      >\n        <header\n          style={{\n            padding: '32px 48px 0',\n            borderBottom: '1px solid var(--border-subtle)',\n          }}\n        >\n          <div className=\"mb-6 flex items-center gap-3\">\n            <button\n              type=\"button\"\n              onClick={() => navigate(getLastNonSettingsRoute() || '/')}\n              aria-label=\"Back\"\n              className=\"flex size-7 cursor-pointer items-center justify-center rounded-[6px] border-0 bg-transparent transition-colors hover:text-[color:var(--fg-1)]\"\n              style={{ color: 'var(--fg-2)' }}\n            >\n              <ArrowLeft size={14} />\n            </button>\n            <h1\n              className=\"m-0 text-[28px] font-normal\"\n              style={{\n                fontFamily: 'var(--font-serif)',\n                letterSpacing: '-0.02em',\n                color: 'var(--fg-1)',\n              }}\n            >\n              Settings\n            </h1>\n          </div>\n          <div className=\"flex gap-0.5\" role=\"tablist\">\n            {TABS.map((t) => (\n              <TabButton\n                key={t.id}\n                active={tab === t.id}\n                onClick={() => setTab(t.id)}\n              >\n                {t.label}\n              </TabButton>\n            ))}\n          </div>\n        </header>\n\n        <div\n          className=\"scrollbar-clean min-h-0 flex-1 overflow-y-auto\"\n          style={{ padding: '8px 48px 64px' }}\n        >\n          <div style={{ maxWidth: 600, paddingTop: 8 }}>\n            {tab === 'general' && <GeneralTab />}\n            {tab === 'ai' && <AiTab />}\n            {tab === 'advanced' && <AdvancedTab />}\n            {tab === 'developer' && <DeveloperTab />}\n          </div>\n          {tab === 'general' && (\n            <div\n              className=\"mt-10 text-center text-[12px]\"\n              style={{ color: 'var(--fg-muted)', maxWidth: 600 }}\n            >\n              StenoAI {version.data?.version ?? ''}\n            </div>\n          )}\n        </div>\n      </div>\n    </MeetingsShell>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// General tab\n// ---------------------------------------------------------------------------\n\nfunction GeneralTab() {\n  const { theme, setTheme } = useTheme();\n  const language = useLanguageSetting();\n  const setLanguage = useSetLanguage();\n  const notifications = useNotificationsSetting();\n  const setNotifications = useSetNotifications();\n  const systemAudio = useSystemAudioSetting();\n  const setSystemAudio = useSetSystemAudio();\n  const dockIcon = useDockIconSetting();\n  const setDockIcon = useSetDockIcon();\n  const google = useGoogleCalendarAuth();\n  const outlook = useOutlookCalendarAuth();\n  const userName = useUserName();\n  const setUserName = useSetUserName();\n  const [nameDraft, setNameDraft] = React.useState('');\n  const nameSeededRef = React.useRef(false);\n  // Wait for the real query (not the sessionStorage placeholder) before\n  // seeding — otherwise we lock onto a stale empty string and ignore the\n  // canonical value when it arrives from disk.\n  React.useEffect(() => {\n    if (nameSeededRef.current) return;\n    if (userName.isPending || userName.isPlaceholderData) return;\n    if (userName.data !== undefined) {\n      setNameDraft(userName.data);\n      nameSeededRef.current = true;\n    }\n  }, [userName.data, userName.isPending, userName.isPlaceholderData]);\n  const persistName = () => {\n    const trimmed = nameDraft.trim();\n    if (trimmed === (userName.data ?? '')) return;\n    setUserName.mutate(trimmed);\n  };\n\n  const calendarConnected =\n    google.status.data?.connected || outlook.status.data?.connected;\n  const calendarProvider = google.status.data?.connected\n    ? 'Google'\n    : outlook.status.data?.connected\n      ? 'Outlook'\n      : null;\n\n  const [oauth, setOauth] = React.useState<\n    | {\n        provider: 'google' | 'outlook';\n        state: 'pending' | 'error';\n        message?: string;\n      }\n    | null\n  >(null);\n\n  React.useEffect(() => {\n    if (!oauth) return;\n    if (oauth.provider === 'google' && google.status.data?.connected) {\n      setOauth(null);\n    }\n    if (oauth.provider === 'outlook' && outlook.status.data?.connected) {\n      setOauth(null);\n    }\n  }, [oauth, google.status.data?.connected, outlook.status.data?.connected]);\n\n  const startConnect = async (provider: 'google' | 'outlook') => {\n    setOauth({ provider, state: 'pending' });\n    try {\n      if (provider === 'google') await google.connect.mutateAsync();\n      else await outlook.connect.mutateAsync();\n    } catch (err) {\n      setOauth({\n        provider,\n        state: 'error',\n        message: err instanceof Error ? err.message : String(err),\n      });\n    }\n  };\n\n  return (\n    <section data-settings-tab=\"general\">\n      <SettingRow\n        label=\"Your name\"\n        description=\"First name only — used for in-app greetings. Stored locally.\"\n      >\n        <Input\n          value={nameDraft}\n          onChange={(e) => setNameDraft(e.target.value)}\n          onBlur={persistName}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              e.preventDefault();\n              // Just blur — onBlur runs persistName, so calling it directly\n              // here would queue a duplicate setUserName mutation.\n              (e.target as HTMLInputElement).blur();\n            }\n          }}\n          placeholder=\"Ruzin\"\n          autoComplete=\"given-name\"\n          className=\"h-[30px] w-[180px] rounded-[6px] text-[13px]\"\n          data-testid=\"user-name-input\"\n        />\n      </SettingRow>\n\n      <SettingRow\n        label=\"Appearance\"\n        description=\"Choose light, dark, or match your system\"\n      >\n        <Select\n          value={theme}\n          onValueChange={(v) => setTheme(v as 'light' | 'dark' | 'system')}\n        >\n          <SelectTrigger\n            className={COMPACT_TRIGGER}\n            data-testid=\"theme-select\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"system\">System</SelectItem>\n            <SelectItem value=\"light\">Light</SelectItem>\n            <SelectItem value=\"dark\">Dark</SelectItem>\n          </SelectContent>\n        </Select>\n      </SettingRow>\n\n      <SettingRow\n        label=\"Language\"\n        description=\"Language for transcription and summaries\"\n      >\n        <Select\n          value={language.data ?? 'en'}\n          onValueChange={(v) => setLanguage.mutate(v)}\n          disabled={!language.data}\n        >\n          <SelectTrigger className={COMPACT_TRIGGER}>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {LANGUAGES.map((l) => (\n              <SelectItem key={l.value} value={l.value}>\n                {l.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </SettingRow>\n\n      <SettingRow\n        label=\"Calendar\"\n        description={\n          calendarConnected\n            ? `Connected to ${calendarProvider}`\n            : 'Show upcoming meetings on the home screen'\n        }\n      >\n        {calendarConnected ? (\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className={COMPACT_BTN}\n            onClick={() => {\n              if (google.status.data?.connected) google.disconnect.mutate();\n              else outlook.disconnect.mutate();\n            }}\n          >\n            Disconnect\n          </Button>\n        ) : (\n          <div className=\"flex gap-2\">\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className={COMPACT_BTN}\n              onClick={() => void startConnect('google')}\n            >\n              Google\n            </Button>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className={COMPACT_BTN}\n              onClick={() => void startConnect('outlook')}\n            >\n              Outlook\n            </Button>\n          </div>\n        )}\n      </SettingRow>\n\n      <OAuthPrompt\n        state={oauth}\n        onClose={() => setOauth(null)}\n        onRetry={() => oauth && void startConnect(oauth.provider)}\n      />\n\n      <SettingRow\n        label=\"Desktop notifications\"\n        description=\"Notify when meetings finish processing\"\n      >\n        <Switch\n          checked={notifications.data ?? false}\n          onCheckedChange={(v) => setNotifications.mutate(v)}\n          disabled={notifications.data === undefined}\n        />\n      </SettingRow>\n\n      <SettingRow\n        label=\"Record system audio\"\n        description=\"Capture audio from virtual meetings (requires macOS 12.3+)\"\n      >\n        <Switch\n          checked={systemAudio.data ?? false}\n          onCheckedChange={(v) => setSystemAudio.mutate(v)}\n          disabled={systemAudio.data === undefined}\n        />\n      </SettingRow>\n\n      <SettingRow\n        label=\"Hide dock icon\"\n        description=\"Run as menu bar app only\"\n        noBorder\n      >\n        <Switch\n          checked={dockIcon.data ?? false}\n          onCheckedChange={(v) => setDockIcon.mutate(v)}\n          disabled={dockIcon.data === undefined}\n        />\n      </SettingRow>\n    </section>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// AI tab\n// ---------------------------------------------------------------------------\n\nfunction AiTab() {\n  const provider = useAiProvider();\n  const setProvider = useSetAiProvider();\n  const current = provider.data?.ai_provider ?? 'local';\n\n  return (\n    <section data-settings-tab=\"ai\">\n      <SettingRow\n        label=\"AI provider\"\n        description=\"Where models run. Local keeps all data on your Mac.\"\n      >\n        <Select\n          value={current}\n          onValueChange={(v) => setProvider.mutate(v as AiProvider)}\n          disabled={!provider.data}\n        >\n          <SelectTrigger className={cn(COMPACT_TRIGGER, 'min-w-[180px]')}>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent className=\"w-72\">\n            <SelectItem\n              value=\"local\"\n              description=\"Runs entirely on your Mac. Private and free, no internet required.\"\n            >\n              Local (on-device)\n            </SelectItem>\n            <SelectItem\n              value=\"remote\"\n              description=\"Connect to your own Ollama server. Data stays within your network.\"\n            >\n              Private Server\n            </SelectItem>\n            <SelectItem\n              value=\"cloud\"\n              description=\"Use OpenAI, Anthropic, or a compatible API. Best quality, requires a paid key.\"\n            >\n              Cloud API\n            </SelectItem>\n          </SelectContent>\n        </Select>\n      </SettingRow>\n\n      {current === 'remote' && <RemoteProviderConfig />}\n      {current === 'cloud' && <CloudProviderConfig />}\n\n      {current !== 'cloud' && (\n        <>\n          <SectionHeading>Model</SectionHeading>\n          <ModelList />\n        </>\n      )}\n    </section>\n  );\n}\n\nfunction RemoteProviderConfig() {\n  const provider = useAiProvider();\n  const setUrl = useSetRemoteOllamaUrl();\n  const testConnection = useTestRemoteOllama();\n  const [url, setLocalUrl] = React.useState('');\n\n  React.useEffect(() => {\n    if (provider.data?.remote_ollama_url) {\n      setLocalUrl(provider.data.remote_ollama_url);\n    }\n  }, [provider.data?.remote_ollama_url]);\n\n  return (\n    <div\n      className=\"space-y-3 py-4\"\n      style={{ borderBottom: '1px solid var(--border-subtle)' }}\n    >\n      <div>\n        <label\n          className=\"mb-1 block text-[12px] font-medium uppercase\"\n          style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}\n        >\n          Ollama server URL\n        </label>\n        <Input\n          value={url}\n          onChange={(e) => setLocalUrl(e.target.value)}\n          placeholder=\"http://192.168.1.100:11434\"\n          onBlur={() => url && setUrl.mutate(url)}\n          className=\"h-[30px] text-[13px]\"\n        />\n      </div>\n      <div className=\"flex items-center gap-3\">\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className={COMPACT_BTN}\n          onClick={() => testConnection.mutate(url)}\n          disabled={!url || testConnection.isPending}\n        >\n          {testConnection.isPending ? 'Testing…' : 'Test connection'}\n        </Button>\n        <ConnectionStatus\n          ok={testConnection.isSuccess ? testConnection.data?.ok ?? true : testConnection.isError ? false : undefined}\n          message={\n            testConnection.isError\n              ? testConnection.error instanceof Error\n                ? testConnection.error.message\n                : 'Failed'\n              : testConnection.data?.message\n          }\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction CloudProviderConfig() {\n  const provider = useAiProvider();\n  const setCloudProvider = useSetCloudProvider();\n  const setCloudUrl = useSetCloudApiUrl();\n  const setCloudKey = useSetCloudApiKey();\n  const setCloudModel = useSetCloudModel();\n  const testConnection = useTestCloudApi();\n\n  const cloudProvider = provider.data?.cloud_provider ?? 'openai';\n  const [apiUrl, setApiUrl] = React.useState('');\n  const [apiKey, setApiKey] = React.useState('');\n  const [model, setModel] = React.useState('gpt-4o-mini');\n  // When provider changes, the available-models cache is provider-specific\n  // and the persisted model name swaps. Track which provider the model list\n  // belongs to so we don't show OpenAI models against an Anthropic key.\n  const [modelListFor, setModelListFor] = React.useState<CloudProvider | null>(null);\n  const [customModelMode, setCustomModelMode] = React.useState(false);\n\n  // Sync apiUrl/model from server-side state (separate effect: runs on any\n  // provider.data change so the model name stays in sync after a successful\n  // setCloudModel mutation).\n  React.useEffect(() => {\n    if (provider.data) {\n      setApiUrl(provider.data.cloud_api_url);\n      setModel(provider.data.cloud_model);\n    }\n  }, [provider.data?.cloud_api_url, provider.data?.cloud_model]);\n\n  // Reset the cached model list ONLY when the provider changes — otherwise\n  // selecting a model triggers a model-name change which would dump the\n  // dropdown the user just picked from.\n  React.useEffect(() => {\n    if (provider.data) {\n      setModelListFor((prev) =>\n        prev === provider.data!.cloud_provider ? prev : null,\n      );\n      setCustomModelMode(false);\n      testConnection.reset();\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [provider.data?.cloud_provider]);\n\n  const onTest = () => {\n    setModelListFor(cloudProvider);\n    testConnection.mutate();\n  };\n\n  const availableModels =\n    modelListFor === cloudProvider && testConnection.isSuccess\n      ? testConnection.data?.models ?? []\n      : [];\n  const showModelDropdown = availableModels.length > 0 && !customModelMode;\n  // Persist the selected model immediately on change (Select doesn't fire\n  // onBlur, so the previous \"save on blur\" pattern would lose the choice).\n  const onModelSelect = (next: string) => {\n    if (next === '__custom__') {\n      setCustomModelMode(true);\n      return;\n    }\n    setModel(next);\n    setCloudModel.mutate(next);\n  };\n\n  return (\n    <div\n      className=\"space-y-3 py-4\"\n      style={{ borderBottom: '1px solid var(--border-subtle)' }}\n    >\n      <div>\n        <label\n          className=\"mb-1 block text-[12px] font-medium uppercase\"\n          style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}\n        >\n          Service\n        </label>\n        <Select\n          value={cloudProvider}\n          onValueChange={(v) => setCloudProvider.mutate(v as CloudProvider)}\n        >\n          <SelectTrigger className=\"h-[30px] text-[13px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"openai\">OpenAI</SelectItem>\n            <SelectItem value=\"anthropic\">Anthropic (Claude)</SelectItem>\n            <SelectItem value=\"custom\">Custom (OpenAI-compatible)</SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n      {cloudProvider === 'custom' && (\n        <div>\n          <label\n            className=\"mb-1 block text-[12px] font-medium uppercase\"\n            style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}\n          >\n            API base URL\n          </label>\n          <Input\n            value={apiUrl}\n            onChange={(e) => setApiUrl(e.target.value)}\n            placeholder=\"https://api.example.com/v1\"\n            onBlur={() => apiUrl && setCloudUrl.mutate(apiUrl)}\n            className=\"h-[30px] text-[13px]\"\n          />\n        </div>\n      )}\n      <div>\n        <label\n          className=\"mb-1 block text-[12px] font-medium uppercase\"\n          style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}\n        >\n          API key\n        </label>\n        <Input\n          type=\"password\"\n          value={apiKey}\n          onChange={(e) => setApiKey(e.target.value)}\n          placeholder={provider.data?.cloud_api_key_set ? '••••••••' : 'sk-…'}\n          onBlur={() => apiKey && setCloudKey.mutate(apiKey)}\n          className=\"h-[30px] text-[13px]\"\n        />\n      </div>\n      <div>\n        <label\n          className=\"mb-1 block text-[12px] font-medium uppercase\"\n          style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}\n        >\n          Model\n        </label>\n        {showModelDropdown ? (\n          <Select value={model} onValueChange={onModelSelect}>\n            <SelectTrigger className=\"h-[30px] text-[13px]\">\n              <SelectValue placeholder=\"Select a model\" />\n            </SelectTrigger>\n            <SelectContent>\n              {/* If the persisted model isn't in the fetched list (e.g. a\n                  user-typed custom name from before), still show it so the\n                  Trigger doesn't render blank. */}\n              {!availableModels.includes(model) && model && (\n                <SelectItem value={model}>{model}</SelectItem>\n              )}\n              {availableModels.map((m) => (\n                <SelectItem key={m} value={m}>\n                  {m}\n                </SelectItem>\n              ))}\n              <SelectItem value=\"__custom__\">Custom…</SelectItem>\n            </SelectContent>\n          </Select>\n        ) : (\n          <div className=\"flex items-center gap-2\">\n            <Input\n              value={model}\n              onChange={(e) => setModel(e.target.value)}\n              placeholder={cloudProvider === 'anthropic' ? 'claude-…' : 'gpt-…'}\n              onBlur={() => model && setCloudModel.mutate(model)}\n              className=\"h-[30px] text-[13px]\"\n            />\n            {availableModels.length > 0 && customModelMode && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className={COMPACT_BTN}\n                onClick={() => setCustomModelMode(false)}\n              >\n                Pick from list\n              </Button>\n            )}\n          </div>\n        )}\n        {availableModels.length === 0 && (\n          <div className=\"mt-1 text-[11.5px]\" style={{ color: 'var(--fg-muted)' }}>\n            Test connection to load the list of available models.\n          </div>\n        )}\n      </div>\n      <div className=\"flex items-center gap-3\">\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className={COMPACT_BTN}\n          onClick={onTest}\n          disabled={testConnection.isPending}\n        >\n          {testConnection.isPending ? 'Testing…' : 'Test connection'}\n        </Button>\n        <ConnectionStatus\n          ok={testConnection.isSuccess ? true : testConnection.isError ? false : undefined}\n          message={\n            testConnection.isError\n              ? testConnection.error instanceof Error\n                ? testConnection.error.message\n                : 'Failed'\n              : testConnection.isSuccess && testConnection.data?.models\n                ? `${testConnection.data.models.length} models available`\n                : undefined\n          }\n        />\n      </div>\n      <div className=\"text-[12px]\" style={{ color: 'var(--fg-2)' }}>\n        Transcripts will be sent to a third-party cloud service. No audio files\n        leave your device.\n      </div>\n    </div>\n  );\n}\n\nfunction ConnectionStatus({\n  ok,\n  message,\n}: {\n  ok: boolean | undefined;\n  message?: string;\n}) {\n  if (ok === undefined) return null;\n  return (\n    <span\n      className=\"flex items-center gap-1.5 text-[12px]\"\n      style={{ color: ok ? 'var(--fg-1)' : 'var(--danger)' }}\n    >\n      {ok ? <Check className=\"size-3.5\" /> : <X className=\"size-3.5\" />}\n      {message ?? (ok ? 'Connected' : 'Failed')}\n    </span>\n  );\n}\n\ninterface OAuthPromptProps {\n  state:\n    | {\n        provider: 'google' | 'outlook';\n        state: 'pending' | 'error';\n        message?: string;\n      }\n    | null;\n  onClose: () => void;\n  onRetry: () => void;\n}\n\nfunction OAuthPrompt({ state, onClose, onRetry }: OAuthPromptProps) {\n  const open = !!state;\n  const providerName = state?.provider === 'outlook' ? 'Outlook' : 'Google';\n  return (\n    <Dialog open={open} onOpenChange={(o) => !o && onClose()}>\n      <DialogContent className=\"max-w-md\" data-oauth-prompt>\n        <DialogHeader>\n          <DialogTitle>\n            {state?.state === 'error'\n              ? `Couldn't connect to ${providerName}`\n              : `Connecting to ${providerName}`}\n          </DialogTitle>\n          <DialogDescription>\n            {state?.state === 'error'\n              ? state.message || 'The authorization flow did not complete.'\n              : 'Complete the authorization in your browser. This dialog will close automatically once access is granted.'}\n          </DialogDescription>\n        </DialogHeader>\n\n        {state?.state === 'pending' && (\n          <div className=\"flex items-center gap-3 rounded-md border border-border bg-paper-0 p-3 text-sm text-muted-foreground dark:bg-paper-1\">\n            <Loader2 className=\"size-4 animate-spin text-foreground\" />\n            <span className=\"flex-1\">Waiting for authorization…</span>\n            <ExternalLink className=\"size-3.5\" />\n          </div>\n        )}\n\n        <DialogFooter>\n          {state?.state === 'error' ? (\n            <>\n              <Button variant=\"outline\" onClick={onClose}>\n                Close\n              </Button>\n              <Button onClick={onRetry}>Try again</Button>\n            </>\n          ) : (\n            <Button variant=\"outline\" onClick={onClose}>\n              Cancel\n            </Button>\n          )}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction ModelList() {\n  const models = useModels();\n  const current = useCurrentModel();\n  const setCurrent = useSetCurrentModel();\n  const pull = usePullModel();\n  const [showDeprecated, setShowDeprecated] = React.useState(false);\n\n  if (models.isLoading) {\n    return (\n      <div\n        className=\"flex items-center gap-2 text-[13px]\"\n        style={{ color: 'var(--fg-2)' }}\n      >\n        <Loader2 className=\"size-3.5 animate-spin\" />\n        Loading models…\n      </div>\n    );\n  }\n  if (models.isError) {\n    return (\n      <div className=\"text-[13px]\" style={{ color: 'var(--fg-2)' }}>\n        Could not reach Ollama. Run the setup wizard.\n      </div>\n    );\n  }\n  if (!models.data?.models?.length) {\n    return (\n      <div className=\"text-[13px]\" style={{ color: 'var(--fg-2)' }}>\n        No models available.\n      </div>\n    );\n  }\n\n  const isRemote = models.data.provider === 'remote';\n  const sorted = [...models.data.models].sort(\n    (a, b) => (a.deprecated ? 1 : 0) - (b.deprecated ? 1 : 0),\n  );\n  const active = sorted.filter((m) => !m.deprecated);\n  const deprecated = sorted.filter((m) => m.deprecated);\n\n  const renderCard = (m: (typeof sorted)[number]) => {\n    const isCurrent = m.name === current.data;\n    const isDownloading = Boolean(pull.progress[m.name]);\n    const downloadProgress = pull.progress[m.name];\n    const isDefault =\n      !isRemote && /\\((default|recommended)\\)/i.test(m.description ?? '');\n\n    let note: string | undefined;\n    if (isRemote && m.description) {\n      note = m.description;\n    } else if (!isRemote) {\n      const parts: string[] = [];\n      if (m.speed) parts.push(`${m.speed} speed`);\n      if (m.quality) parts.push(`${m.quality} quality`);\n      note = parts.length ? parts.join(' · ') : undefined;\n    }\n\n    const sizeLabel =\n      m.size_gb !== undefined ? `${m.size_gb.toFixed(1)} GB` : undefined;\n\n    const onSelect = () => {\n      if (m.installed) {\n        setCurrent.mutate(m.name);\n      } else {\n        pull.mutate(m.name);\n      }\n    };\n\n    return (\n      <ModelCard\n        key={m.name}\n        name={m.name}\n        sizeLabel={sizeLabel}\n        note={note}\n        isCurrent={isCurrent}\n        isDefault={isDefault}\n        deprecated={Boolean(m.deprecated)}\n        isDownloading={isDownloading}\n        downloadProgress={downloadProgress}\n        onSelect={onSelect}\n      />\n    );\n  };\n\n  return (\n    <div>\n      {active.map(renderCard)}\n\n      {deprecated.length > 0 && (\n        <>\n          <button\n            type=\"button\"\n            onClick={() => setShowDeprecated((d) => !d)}\n            className=\"mt-4 flex cursor-pointer items-center gap-1.5 border-0 bg-transparent p-0 text-[13px]\"\n            style={{ color: 'var(--fg-muted)' }}\n          >\n            {showDeprecated ? (\n              <ChevronDown size={12} />\n            ) : (\n              <ChevronRight size={12} />\n            )}\n            {showDeprecated ? 'Hide' : 'Show'} deprecated models\n          </button>\n\n          {showDeprecated && (\n            <div className=\"mt-2\">{deprecated.map(renderCard)}</div>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Advanced tab\n// ---------------------------------------------------------------------------\n\nfunction AdvancedTab() {\n  const navigate = useNavigate();\n  const storage = useStoragePath();\n  const setStorage = useSetStoragePath();\n  const pickFolder = usePickStorageFolder();\n  const clearState = useClearSystemState();\n  const telemetry = useTelemetrySetting();\n  const setTelemetry = useSetTelemetry();\n\n  const chooseFolder = async () => {\n    try {\n      const folder = await pickFolder.mutateAsync();\n      if (folder) setStorage.mutate(folder);\n    } catch {\n      // cancelled\n    }\n  };\n\n  const resetFolder = () => {\n    if (storage.data?.default_path) {\n      setStorage.mutate(storage.data.default_path);\n    }\n  };\n\n  const custom =\n    storage.data?.custom_path &&\n    storage.data.custom_path !== storage.data.default_path;\n  const path = storage.data?.storage_path ?? storage.data?.default_path;\n\n  return (\n    <section data-settings-tab=\"advanced\">\n      <div\n        className=\"flex items-start justify-between gap-6 py-4\"\n        style={{ borderBottom: '1px solid var(--border-subtle)' }}\n      >\n        <div className=\"min-w-0 flex-1\">\n          <div\n            className=\"text-[14px] font-medium\"\n            style={{ color: 'var(--fg-1)', marginBottom: 2 }}\n          >\n            Storage location\n          </div>\n          <div\n            className=\"mb-2 text-[13px]\"\n            style={{ color: 'var(--fg-2)' }}\n          >\n            Where your notes and recordings are saved\n          </div>\n          {path && <CopyableValue value={path} mono />}\n        </div>\n        <div className=\"flex shrink-0 gap-2 pt-1\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className={COMPACT_BTN}\n            onClick={chooseFolder}\n          >\n            Choose…\n          </Button>\n          {custom && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className={COMPACT_BTN}\n              onClick={resetFolder}\n            >\n              Reset\n            </Button>\n          )}\n        </div>\n      </div>\n\n      <SettingRow\n        label=\"Setup wizard\"\n        description=\"Reinstall dependencies or fix configuration\"\n      >\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className={COMPACT_BTN}\n          onClick={() => navigate('/setup')}\n        >\n          Run\n        </Button>\n      </SettingRow>\n\n      <SettingRow\n        label=\"Clear recording state\"\n        description=\"Fix stuck recordings or processing\"\n      >\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          className={COMPACT_BTN}\n          onClick={() => clearState.mutate()}\n          disabled={clearState.isPending}\n          style={{ color: 'var(--danger)' }}\n        >\n          {clearState.isPending ? 'Clearing…' : 'Clear'}\n        </Button>\n      </SettingRow>\n\n      <SettingRow\n        label=\"Anonymous usage analytics\"\n        description=\"Help improve StenoAI — no meeting content is ever sent\"\n      >\n        <Switch\n          checked={telemetry.data?.telemetry_enabled ?? false}\n          onCheckedChange={(v) => setTelemetry.mutate(v)}\n          disabled={telemetry.data === undefined}\n        />\n      </SettingRow>\n\n      {telemetry.data?.anonymous_id && (\n        <div\n          className=\"flex items-start justify-between gap-6 py-4\"\n          style={{ borderBottom: 'none' }}\n        >\n          <div className=\"min-w-0 flex-1\">\n            <div\n              className=\"text-[14px] font-medium\"\n              style={{ color: 'var(--fg-1)', marginBottom: 2 }}\n            >\n              Anonymous ID\n            </div>\n            <div\n              className=\"mb-2 text-[13px]\"\n              style={{ color: 'var(--fg-2)' }}\n            >\n              Identifies this install in analytics. Useful when reporting bugs.\n            </div>\n            <CopyableValue value={telemetry.data.anonymous_id} mono />\n          </div>\n        </div>\n      )}\n    </section>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Developer tab\n// ---------------------------------------------------------------------------\n\nfunction DeveloperTab() {\n  // Read from the global store so we get the full session backlog, not just\n  // lines emitted after this tab mounted.\n  const logs = React.useSyncExternalStore(\n    subscribeDebugLogs,\n    getDebugLogs,\n    getDebugLogs,\n  );\n\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  // Keep the textarea pinned to the most recent line whenever new logs arrive.\n  React.useEffect(() => {\n    const el = textareaRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [logs]);\n\n  const copyLogs = () => {\n    void navigator.clipboard.writeText(logs.join('\\n'));\n  };\n\n  const placeholder =\n    'StenoAI debug console\\nSession started — waiting for activity…\\n';\n\n  return (\n    <section data-settings-tab=\"developer\">\n      <div className=\"flex items-baseline justify-between pb-2 pt-1\">\n        <div>\n          <div\n            className=\"text-[14px] font-medium\"\n            style={{ color: 'var(--fg-1)', marginBottom: 2 }}\n          >\n            Debug console\n          </div>\n          <div className=\"text-[13px]\" style={{ color: 'var(--fg-2)' }}>\n            Real-time log output from backend processes.\n          </div>\n        </div>\n        <div className=\"flex gap-2\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-7 px-2.5 text-[13px]\"\n            onClick={clearDebugLogs}\n          >\n            Clear\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-7 px-2.5 text-[13px]\"\n            onClick={copyLogs}\n            disabled={!logs.length}\n          >\n            Copy\n          </Button>\n        </div>\n      </div>\n      <textarea\n        ref={textareaRef}\n        readOnly\n        value={logs.length === 0 ? placeholder : logs.join('\\n')}\n        className=\"block w-full font-mono text-[12px]\"\n        style={{\n          height: 340,\n          padding: 16,\n          lineHeight: 1.7,\n          color: 'var(--fg-2)',\n          background: 'var(--surface-sunken)',\n          border: '1px solid var(--border-subtle)',\n          borderRadius: 'var(--radius)',\n          resize: 'none',\n        }}\n      />\n    </section>\n  );\n}\n"
  },
  {
    "path": "app/renderer/src/routes/Setup.tsx",
    "content": "import * as React from 'react';\nimport { Check, Cloud, HardDrive, Mic, MessageSquare, Zap, X } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Switch } from '@/components/ui/switch';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select';\nimport { Display, Lead, Muted } from '@/components/ui/typography';\nimport { useNavigate } from '@/lib/router';\nimport { useCheckMicPermission, useRequestMicPermission, useSetupStep } from '@/hooks/useSetup';\nimport {\n  useSetTelemetry,\n  useSetUserName,\n  useTelemetrySetting,\n  useUserName,\n} from '@/hooks/useSettings';\nimport {\n  useSetAiProvider,\n  useSetCloudApiKey,\n  useSetCloudProvider,\n  useTestCloudApi,\n} from '@/hooks/useAi';\nimport { ipc, type CloudProvider } from '@/lib/ipc';\nimport { cn } from '@/lib/utils';\n\ntype StepStatus = 'waiting' | 'running' | 'done' | 'failed';\n\ninterface Step {\n  id: 'microphone' | 'whisper' | 'ollama';\n  title: string;\n  description: string;\n  icon: React.ComponentType<{ className?: string }>;\n  status: StepStatus;\n  detail?: string;\n}\n\nfunction Badge({ status }: { status: StepStatus }) {\n  const label =\n    status === 'waiting'\n      ? 'Waiting'\n      : status === 'running'\n        ? 'Running'\n        : status === 'done'\n          ? 'Done'\n          : 'Failed';\n  const cls =\n    status === 'done'\n      ? 'bg-muted text-foreground'\n      : status === 'running'\n        ? 'bg-foreground text-background'\n        : status === 'failed'\n          ? 'bg-destructive text-destructive-foreground'\n          : 'bg-transparent text-muted-foreground border border-border';\n  return (\n    <span className={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${cls}`}>\n      {label}\n    </span>\n  );\n}\n\nfunction StepCard({ step }: { step: Step }) {\n  const Icon = step.icon;\n  return (\n    <div\n      className=\"flex items-center gap-4 rounded-md border border-border p-4\"\n      data-setup-step={step.id}\n      data-setup-status={step.status}\n    >\n      <div className=\"flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-foreground\">\n        {step.status === 'done' ? <Check className=\"size-5\" /> : step.status === 'failed' ? <X className=\"size-5\" /> : <Icon className=\"size-5\" />}\n      </div>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-center gap-2\">\n          <div className=\"text-sm font-medium text-foreground\">{step.title}</div>\n          <Badge status={step.status} />\n        </div>\n        <Muted className=\"mt-0.5\">{step.detail ?? step.description}</Muted>\n      </div>\n    </div>\n  );\n}\n\nexport function Setup() {\n  const navigate = useNavigate();\n  const [statuses, setStatuses] = React.useState<Record<Step['id'], StepStatus>>({\n    microphone: 'waiting',\n    whisper: 'waiting',\n    ollama: 'waiting',\n  });\n  const [details, setDetails] = React.useState<Record<Step['id'], string | undefined>>({\n    microphone: undefined,\n    whisper: undefined,\n    ollama: undefined,\n  });\n  const [running, setRunning] = React.useState(false);\n  const [done, setDone] = React.useState(false);\n  const [debugOpen, setDebugOpen] = React.useState(false);\n  const [logs, setLogs] = React.useState<string[]>([]);\n\n  React.useEffect(() => {\n    if (typeof window === 'undefined' || !window.stenoai) return;\n    return ipc().on.debugLog((line) => {\n      setLogs((prev) => {\n        const next = [...prev, line];\n        return next.length > 500 ? next.slice(-500) : next;\n      });\n    });\n  }, []);\n\n  const checkMic = useCheckMicPermission();\n  const requestMic = useRequestMicPermission();\n  const whisperStep = useSetupStep('whisper');\n  const ollamaStep = useSetupStep('ollamaAndModel');\n\n  // Telemetry choice surfaced here so users opt in/out during onboarding\n  // instead of having to find Settings → Advanced afterwards. Persists\n  // immediately via the same backend used by the Settings page.\n  const telemetry = useTelemetrySetting();\n  const setTelemetry = useSetTelemetry();\n  const telemetryEnabled = telemetry.data?.telemetry_enabled ?? true;\n\n  // First name powers the in-app greeting (\"Hi <name>, ask anything\"). Stored\n  // locally only — never sent anywhere. Persisted on blur to avoid a write\n  // per keystroke.\n  const userName = useUserName();\n  const setUserName = useSetUserName();\n  const [name, setName] = React.useState('');\n  // Sync once when the initial fetch resolves so we don't clobber user input.\n  // Wait for the real query to settle — placeholderData (sessionStorage cache)\n  // could be stale or empty, and we don't want to seed from it and then ignore\n  // the canonical value when it arrives from disk.\n  const seededRef = React.useRef(false);\n  React.useEffect(() => {\n    if (seededRef.current) return;\n    if (userName.isPending || userName.isPlaceholderData) return;\n    if (userName.data !== undefined) {\n      setName(userName.data);\n      seededRef.current = true;\n    }\n  }, [userName.data, userName.isPending, userName.isPlaceholderData]);\n  const persistName = () => {\n    const trimmed = name.trim();\n    if (trimmed === (userName.data ?? '')) return;\n    setUserName.mutate(trimmed);\n  };\n\n  // Summarization-engine choice. 'local' downloads the bundled model\n  // (privacy + free, slower); 'cloud' wires up an API key (no download,\n  // higher quality) and skips the third install step entirely.\n  type SummaryMode = 'local' | 'cloud';\n  const [summaryMode, setSummaryMode] = React.useState<SummaryMode>('local');\n  const [cloudProvider, setCloudProviderChoice] = React.useState<CloudProvider>('openai');\n  const [cloudApiKey, setCloudApiKey] = React.useState('');\n  const setAiProvider = useSetAiProvider();\n  const setCloudProviderMut = useSetCloudProvider();\n  const setCloudKeyMut = useSetCloudApiKey();\n  const testCloudApi = useTestCloudApi();\n\n  const cloudReady = summaryMode === 'cloud' && cloudApiKey.trim().length > 0;\n  const canBegin = summaryMode === 'local' || cloudReady;\n\n  const setStatus = (id: Step['id'], s: StepStatus, detail?: string) => {\n    setStatuses((prev) => ({ ...prev, [id]: s }));\n    if (detail !== undefined) setDetails((prev) => ({ ...prev, [id]: detail }));\n  };\n\n  const runSetup = async () => {\n    setRunning(true);\n    setDone(false);\n    // Capture the snapshot so we can branch on what's already done. Skipping\n    // completed steps keeps retries fast (no re-prompting for mic permission,\n    // no re-initialising Whisper) when the user is just fixing a bad API key.\n    const snapshot = statuses;\n    try {\n      if (snapshot.microphone !== 'done') {\n        setStatus('microphone', 'running', 'Checking permission...');\n        const existing = await checkMic.mutateAsync();\n        if (existing === 'granted') {\n          setStatus('microphone', 'done', 'Permission granted');\n        } else {\n          setStatus('microphone', 'running', 'Requesting permission...');\n          const granted = await requestMic.mutateAsync();\n          if (granted) setStatus('microphone', 'done', 'Permission granted');\n          else {\n            setStatus('microphone', 'failed', 'Permission denied. Grant it in System Settings.');\n            setRunning(false);\n            return;\n          }\n        }\n      }\n\n      if (snapshot.whisper !== 'done') {\n        setStatus('whisper', 'running', 'Installing transcription engine...');\n        await whisperStep.mutateAsync();\n        setStatus('whisper', 'done', 'Transcription engine ready');\n      }\n\n      // Always re-run the summarization step on retry (the choice or key may\n      // have changed). Reset its status from 'failed' so the chooser hides\n      // while we run.\n      setStatus('ollama', 'running', '');\n\n      if (summaryMode === 'cloud') {\n        setStatus('ollama', 'running', 'Saving cloud credentials...');\n        // Persist provider preference + key, then verify with a small ping\n        // call so the user gets immediate feedback if the key is bad.\n        await setAiProvider.mutateAsync('cloud');\n        await setCloudProviderMut.mutateAsync(cloudProvider);\n        await setCloudKeyMut.mutateAsync(cloudApiKey.trim());\n        setStatus('ollama', 'running', 'Testing connection...');\n        // unwrap throws on { success: false } so reaching this line means the\n        // provider responded successfully — no extra check needed.\n        await testCloudApi.mutateAsync();\n        setStatus('ollama', 'done', `Connected to ${cloudProvider}`);\n      } else {\n        // Make sure provider is local in case the user previously had cloud\n        // configured and is re-running the wizard to switch back.\n        await setAiProvider.mutateAsync('local');\n        setStatus('ollama', 'running', 'Downloading model (~2 GB)...');\n        await ollamaStep.mutateAsync();\n        setStatus('ollama', 'done', 'Model installed');\n      }\n\n      setDone(true);\n    } catch (err) {\n      const message = err instanceof Error ? err.message : 'Setup step failed';\n      setStatuses((prev) => {\n        const failId = (Object.keys(prev) as Step['id'][]).find((k) => prev[k] === 'running');\n        if (!failId) return prev;\n        setDetails((d) => ({ ...d, [failId]: message }));\n        return { ...prev, [failId]: 'failed' };\n      });\n    } finally {\n      setRunning(false);\n    }\n  };\n\n  const steps: Step[] = [\n    {\n      id: 'microphone',\n      title: 'Microphone Access',\n      description: 'Required for recording meetings',\n      icon: Mic,\n      status: statuses.microphone,\n      detail: details.microphone,\n    },\n    {\n      id: 'whisper',\n      title: 'Transcription Engine',\n      description: 'Converts speech to text locally',\n      icon: MessageSquare,\n      status: statuses.whisper,\n      detail: details.whisper,\n    },\n    {\n      id: 'ollama',\n      title: 'Summarization Engine',\n      description:\n        summaryMode === 'cloud'\n          ? 'Cloud API — fast, no download'\n          : 'Local model (~2 GB) — private, runs on your Mac',\n      icon: summaryMode === 'cloud' ? Cloud : Zap,\n      status: statuses.ollama,\n      detail: details.ollama,\n    },\n  ];\n\n  // Show the Local/Cloud chooser before the third step has run AND after a\n  // failure, so the user can correct a bad API key (or pick the other path\n  // entirely) and retry without exiting the wizard. Hidden while running and\n  // when the step has succeeded.\n  const showSummaryChooser =\n    !running && (statuses.ollama === 'waiting' || statuses.ollama === 'failed');\n\n  return (\n    <div className=\"min-h-screen bg-background text-foreground\">\n      <div className=\"mx-auto max-w-[560px] px-8 py-16\">\n        <div className=\"mb-8 text-center\">\n          <Display className=\"mb-3\">Welcome to Steno</Display>\n          <Lead>We'll help you set up everything needed for meeting intelligence.</Lead>\n        </div>\n\n        <div\n          className=\"mb-6 flex items-center gap-4 rounded-md border border-border p-4\"\n          data-setup-name\n        >\n          <div className=\"min-w-0 flex-1\">\n            <label htmlFor=\"setup-name-input\" className=\"text-sm font-medium text-foreground\">\n              What should we call you?\n            </label>\n            <Muted className=\"mt-0.5\">\n              First name only — used for in-app greetings. Stored locally.\n            </Muted>\n          </div>\n          <Input\n            id=\"setup-name-input\"\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            onBlur={persistName}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') {\n                e.preventDefault();\n                persistName();\n                (e.target as HTMLInputElement).blur();\n              }\n            }}\n            placeholder=\"Ruzin\"\n            autoComplete=\"given-name\"\n            className=\"w-[160px]\"\n          />\n        </div>\n\n        <div className=\"space-y-3\" data-setup-steps>\n          {steps.map((step) => (\n            <StepCard key={step.id} step={step} />\n          ))}\n        </div>\n\n        {showSummaryChooser && (\n          <div\n            className=\"mt-3 rounded-md border border-border p-4\"\n            data-setup-summary-chooser\n          >\n            <div className=\"mb-3 text-sm font-medium text-foreground\">\n              How should StenoAI summarize meetings?\n            </div>\n            <div className=\"grid grid-cols-2 gap-2\">\n              <button\n                type=\"button\"\n                onClick={() => setSummaryMode('local')}\n                className={cn(\n                  'flex flex-col items-start gap-1 rounded-md border p-3 text-left transition-colors',\n                  summaryMode === 'local'\n                    ? 'border-foreground bg-muted/40'\n                    : 'border-border hover:bg-muted/20',\n                )}\n                aria-pressed={summaryMode === 'local'}\n              >\n                <div className=\"flex w-full items-center justify-between\">\n                  <div className=\"flex items-center gap-2 text-sm font-medium text-foreground\">\n                    <HardDrive className=\"size-4\" />\n                    Local\n                  </div>\n                  {summaryMode === 'local' && <Check className=\"size-4 text-foreground\" />}\n                </div>\n                <Muted className=\"text-[12px]\">\n                  Private. Free. ~2 GB download.\n                </Muted>\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => setSummaryMode('cloud')}\n                className={cn(\n                  'flex flex-col items-start gap-1 rounded-md border p-3 text-left transition-colors',\n                  summaryMode === 'cloud'\n                    ? 'border-foreground bg-muted/40'\n                    : 'border-border hover:bg-muted/20',\n                )}\n                aria-pressed={summaryMode === 'cloud'}\n              >\n                <div className=\"flex w-full items-center justify-between\">\n                  <div className=\"flex items-center gap-2 text-sm font-medium text-foreground\">\n                    <Cloud className=\"size-4\" />\n                    Cloud\n                  </div>\n                  {summaryMode === 'cloud' && <Check className=\"size-4 text-foreground\" />}\n                </div>\n                <Muted className=\"text-[12px]\">\n                  Fast. Higher quality. Bring your own API key.\n                </Muted>\n              </button>\n            </div>\n\n            {summaryMode === 'cloud' && (\n              <div className=\"mt-3 space-y-2\">\n                <div>\n                  <label\n                    className=\"mb-1 block text-[12px] font-medium text-foreground\"\n                    htmlFor=\"setup-cloud-provider\"\n                  >\n                    Provider\n                  </label>\n                  <Select\n                    value={cloudProvider}\n                    onValueChange={(v) => setCloudProviderChoice(v as CloudProvider)}\n                  >\n                    <SelectTrigger id=\"setup-cloud-provider\" className=\"h-8 text-[13px]\">\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"openai\">OpenAI</SelectItem>\n                      <SelectItem value=\"anthropic\">Anthropic</SelectItem>\n                    </SelectContent>\n                  </Select>\n                </div>\n                <div>\n                  <label\n                    className=\"mb-1 block text-[12px] font-medium text-foreground\"\n                    htmlFor=\"setup-cloud-key\"\n                  >\n                    API key\n                  </label>\n                  <Input\n                    id=\"setup-cloud-key\"\n                    type=\"password\"\n                    value={cloudApiKey}\n                    onChange={(e) => setCloudApiKey(e.target.value)}\n                    placeholder={cloudProvider === 'anthropic' ? 'sk-ant-…' : 'sk-…'}\n                    autoComplete=\"off\"\n                    spellCheck={false}\n                  />\n                  <Muted className=\"mt-1 text-[11px]\">\n                    Stored locally on this Mac. Never synced or sent anywhere\n                    except the provider you select.\n                  </Muted>\n                </div>\n              </div>\n            )}\n          </div>\n        )}\n\n        <div\n          className=\"mt-3 flex items-start gap-4 rounded-md border border-border p-4\"\n          data-setup-telemetry\n        >\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"text-sm font-medium text-foreground\">\n              Anonymous usage analytics\n            </div>\n            <Muted className=\"mt-0.5\">\n              Help improve StenoAI — meeting content is never sent. You can\n              change this any time in Settings → Advanced.\n            </Muted>\n          </div>\n          <Switch\n            checked={telemetryEnabled}\n            onCheckedChange={(v) => setTelemetry.mutate(v)}\n            disabled={telemetry.data === undefined}\n            aria-label=\"Anonymous usage analytics\"\n          />\n        </div>\n\n        <div className=\"mt-8 flex flex-col items-center gap-2\">\n          {done ? (\n            <Button size=\"lg\" onClick={() => navigate('/')}>\n              Continue to app\n            </Button>\n          ) : (\n            <Button\n              size=\"lg\"\n              onClick={runSetup}\n              disabled={running || !canBegin}\n              title={\n                !canBegin\n                  ? 'Enter your cloud API key first'\n                  : undefined\n              }\n            >\n              {running ? 'Setting up...' : 'Begin setup'}\n            </Button>\n          )}\n          {!done && !running && !canBegin && (\n            <Muted className=\"text-[12px]\">\n              Enter your API key to continue.\n            </Muted>\n          )}\n        </div>\n\n        <div className=\"mt-10 border-t border-border pt-4\">\n          <button\n            type=\"button\"\n            onClick={() => setDebugOpen((o) => !o)}\n            className=\"flex w-full items-center justify-between text-xs font-medium text-muted-foreground hover:text-foreground\"\n          >\n            <span>Debug console</span>\n            <span>{debugOpen ? '−' : '+'}</span>\n          </button>\n          {debugOpen && (\n            <pre className=\"mt-3 h-64 overflow-auto rounded border border-border bg-muted/40 p-3 font-mono text-[11px] leading-relaxed text-foreground\">\n              {logs.length === 0 ? 'StenoAI Setup\\nCommands and output will appear here...\\n' : logs.join('\\n')}\n            </pre>\n          )}\n        </div>\n\n        <div className=\"mt-8 text-center text-xs text-muted-foreground\">\n          <a\n            href=\"https://github.com/ruzin/stenoai/issues\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"hover:text-foreground\"\n          >\n            Report an issue\n          </a>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "app/renderer/tailwind.config.cjs",
    "content": "/** @type {import('tailwindcss').Config} */\nconst path = require('node:path');\nconst animate = require('tailwindcss-animate');\n\nmodule.exports = {\n  darkMode: ['class'],\n  content: [\n    path.join(__dirname, 'index.html'),\n    path.join(__dirname, 'src/**/*.{ts,tsx}'),\n  ],\n  theme: {\n    container: {\n      center: true,\n      padding: '1.5rem',\n      screens: { '2xl': '1200px' },\n    },\n    extend: {\n      fontFamily: {\n        sans: ['Inter', '-apple-system', 'Segoe UI', 'sans-serif'],\n        serif: ['Charter', 'Bitstream Charter', 'Sitka Text', 'Iowan Old Style', 'Cambria', 'Georgia', 'serif'],\n        mono: ['JetBrains Mono', 'SF Mono', 'Menlo', 'monospace'],\n      },\n      colors: {\n        border: 'hsl(var(--border))',\n        input: 'hsl(var(--input))',\n        ring: 'hsl(var(--ring))',\n        background: 'hsl(var(--background))',\n        foreground: 'hsl(var(--foreground))',\n        primary: {\n          DEFAULT: 'hsl(var(--primary))',\n          foreground: 'hsl(var(--primary-foreground))',\n        },\n        secondary: {\n          DEFAULT: 'hsl(var(--secondary))',\n          foreground: 'hsl(var(--secondary-foreground))',\n        },\n        destructive: {\n          DEFAULT: 'hsl(var(--destructive))',\n          foreground: 'hsl(var(--destructive-foreground))',\n        },\n        muted: {\n          DEFAULT: 'hsl(var(--muted))',\n          foreground: 'hsl(var(--muted-foreground))',\n        },\n        accent: {\n          DEFAULT: 'hsl(var(--accent))',\n          foreground: 'hsl(var(--accent-foreground))',\n        },\n        popover: {\n          DEFAULT: 'hsl(var(--popover))',\n          foreground: 'hsl(var(--popover-foreground))',\n        },\n        card: {\n          DEFAULT: 'hsl(var(--card))',\n          foreground: 'hsl(var(--card-foreground))',\n        },\n        'accent-primary': 'hsl(var(--accent-primary))',\n        paper: {\n          0: '#FAF9F5',\n          1: '#F5F3EC',\n          2: '#EFEBE1',\n          3: '#E5DFD1',\n        },\n        ink: {\n          900: '#1B1B19',\n          700: '#3D3D39',\n          500: '#6B6B66',\n          300: '#A8A8A0',\n          100: '#D6D4CB',\n        },\n      },\n      borderRadius: {\n        lg: 'var(--radius)',\n        md: 'calc(var(--radius) - 2px)',\n        sm: 'calc(var(--radius) - 4px)',\n        xl: 'calc(var(--radius) + 6px)',\n        '2xl': 'calc(var(--radius) + 12px)',\n      },\n      fontSize: {\n        xs: ['12px', { lineHeight: '1.3' }],\n        sm: ['14px', { lineHeight: '1.55' }],\n        base: ['15px', { lineHeight: '1.55' }],\n        md: ['17px', { lineHeight: '1.5' }],\n        lg: ['22px', { lineHeight: '1.3' }],\n        xl: ['30px', { lineHeight: '1.25', letterSpacing: '-0.01em' }],\n        '2xl': ['44px', { lineHeight: '1.1', letterSpacing: '-0.02em' }],\n        '3xl': ['64px', { lineHeight: '1.05', letterSpacing: '-0.02em' }],\n      },\n      boxShadow: {\n        sm: '0 1px 2px rgba(27, 27, 25, 0.05)',\n        DEFAULT: '0 1px 2px rgba(27, 27, 25, 0.05)',\n        md: '0 8px 24px -8px rgba(27, 27, 25, 0.14), 0 2px 4px -2px rgba(27, 27, 25, 0.06)',\n        lg: '0 24px 48px -16px rgba(27, 27, 25, 0.22), 0 4px 8px -4px rgba(27, 27, 25, 0.08)',\n      },\n      keyframes: {\n        'accordion-down': {\n          from: { height: '0' },\n          to: { height: 'var(--radix-accordion-content-height)' },\n        },\n        'accordion-up': {\n          from: { height: 'var(--radix-accordion-content-height)' },\n          to: { height: '0' },\n        },\n        'fade-in': {\n          from: { opacity: '0' },\n          to: { opacity: '1' },\n        },\n      },\n      animation: {\n        'accordion-down': 'accordion-down 200ms cubic-bezier(0.2, 0, 0, 1)',\n        'accordion-up': 'accordion-up 200ms cubic-bezier(0.2, 0, 0, 1)',\n        'fade-in': 'fade-in 0.4s ease-out both',\n        'spin-fast': 'spin 0.55s linear infinite',\n      },\n      transitionTimingFunction: {\n        steno: 'cubic-bezier(0.2, 0, 0, 1)',\n      },\n      transitionDuration: {\n        fast: '120ms',\n        DEFAULT: '200ms',\n        slow: '320ms',\n      },\n    },\n  },\n  plugins: [animate],\n};\n"
  },
  {
    "path": "app/renderer/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"jsx\": \"react-jsx\",\n\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitReturns\": true,\n    \"exactOptionalPropertyTypes\": false,\n\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n\n    \"types\": [\"vite/client\"],\n\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    },\n\n    \"noEmit\": true\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"src/**/*.d.ts\"]\n}\n"
  },
  {
    "path": "app/vite.config.ts",
    "content": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport path from 'node:path';\n\nexport default defineConfig({\n  root: 'renderer',\n  base: './',\n  plugins: [react()],\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, 'renderer/src'),\n    },\n  },\n  build: {\n    outDir: 'dist',\n    emptyOutDir: true,\n    // Sourcemaps in dev only. Shipping .map files in the packaged DMG would\n    // expose source internals and bloat the install size; on by default to\n    // help with stack-trace debugging during development.\n    sourcemap: process.env.NODE_ENV !== 'production',\n  },\n  server: {\n    port: 5173,\n    strictPort: true,\n  },\n});\n"
  },
  {
    "path": "prompt_tests/PROMPT_TESTING.md",
    "content": "# Prompt Testing Framework\n\nTest multiple prompt templates on the same transcript and compare results side-by-side.\n\n## Quick Start\n\n### 1. List available prompts\n```bash\npython test_prompts.py list-prompts\n```\n\nAvailable prompts:\n- **current** - Production prompt (matches `_create_permissive_prompt` in summarizer.py)\n- **chain_of_thought** - Step-by-step reasoning before JSON output\n\n### 2. Compare prompts on a transcript\n```bash\npython test_prompts.py compare transcripts/granola_ai_review_transcript.txt\n```\n\n### 3. Test specific prompts only\n```bash\npython test_prompts.py compare transcripts/YOUR_FILE.txt -p current -p chain_of_thought\n```\n\n### 4. Use a different model\n```bash\npython test_prompts.py compare transcripts/YOUR_FILE.txt --model llama3.1:8b\n```\n\n### 5. Preview what a prompt looks like\n```bash\npython test_prompts.py show-prompt current transcripts/YOUR_FILE.txt\n```\n\n## Understanding the Output\n\nThe comparison shows for each prompt:\n- **Duration**: How long it took to generate\n- **JSON Parse Success**: Whether the response was valid JSON\n- **Overview**: The summary overview (with character count)\n- **Participants**: List of detected participants\n- **Key Points**: Main discussion points\n- **Action Items**: Next steps with assignees\n\n## Adding Your Own Prompts\n\nEdit `test_prompts.py` and add to the `PROMPT_TEMPLATES` dictionary:\n\n```python\nPROMPT_TEMPLATES = {\n    \"my_custom_prompt\": lambda transcript: f\"\"\"Your prompt here...\n\n    TRANSCRIPT:\n    {transcript}\n    \"\"\",\n}\n```\n\n## Tips\n\n- Pay attention to:\n  - **Accuracy**: Does it capture what was actually said?\n  - **Hallucinations**: Does it infer things that weren't mentioned?\n  - **JSON reliability**: Does it consistently return valid JSON?\n- Once you find a prompt you like, update `src/summarizer.py`\n"
  },
  {
    "path": "prompt_tests/test_prompts.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nPrompt Testing Framework\n\nTest multiple prompt templates on the same transcript to compare results.\n\nUsage:\n    python test_prompts.py compare path/to/transcript.txt\n    python test_prompts.py compare transcripts/20241230_120000_Meeting_transcript.txt\n    python test_prompts.py list-prompts\n\"\"\"\n\nimport click\nimport json\nimport ollama\nfrom pathlib import Path\nfrom datetime import datetime\nfrom typing import Dict, Any\nimport logging\n\nlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\nlogger = logging.getLogger(__name__)\n\n\n# Define your prompt templates here\nPROMPT_TEMPLATES = {\n    # Current prompt - matches _create_permissive_prompt in src/summarizer.py\n    \"current\": lambda transcript: f\"\"\"You are a helpful meeting assistant. Summarise this meeting transcript into participants, discussion areas, key points and any next steps mentioned. Only base your summary on what was explicitly discussed in the transcript.\n\nIMPORTANT: Do not infer or assume information that wasn't directly mentioned.\n\nInclude a brief overview so someone can quickly understand what happened in the meeting, who were the participants, what areas/topics were discussed, what were the key points, and what are the next steps if any were mentioned.\n\nCRITICAL JSON FORMATTING RULES:\n1. ALL strings must be enclosed in double quotes \"like this\"\n2. Use null (not \"null\") for empty values\n3. NO trailing commas anywhere\n4. NO comments or extra text outside the JSON\n5. ALL array elements must be properly quoted strings\n6. If no participants, discussion areas, key points, or next steps are mentioned, return an empty array [] for that field.\n\nIMPORTANT - VARIABLE NUMBER OF ITEMS:\n- Discussion areas: Include as many as needed to organize the topics (1-2 for short meetings, 4-5 for complex discussions)\n- Key points: Extract as many as were actually discussed (2-3 for short meetings, 6-8 for detailed discussions)\n- Next steps: Include only action items that were clearly mentioned (could be 1, could be 6+)\n- The examples below are illustrative - do not feel obligated to match the exact number shown\n\nCORRECT FORMAT EXAMPLE:\n{{\n  \"participants\": [\"John Smith\", \"Sarah Wilson\"],\n  \"key_points\": [\"Budget discussion\", \"Timeline review\"]\n}}\n\nINCORRECT FORMAT (DO NOT DO THIS):\n{{\n  \"participants\": [\"John\", no other participants mentioned],\n  \"key_points\": [\"Budget\", timeline,]\n}}\n\nTRANSCRIPT:\n{transcript}\n\nReturn ONLY the response in this exact JSON format:\n{{\n  \"overview\": \"Brief overview of what happened in the meeting\",\n  \"participants\": [\"\"],\n  \"discussion_areas\": [\n    {{\n      \"title\": \"First main topic discussed\",\n      \"analysis\": \"Short paragraph about what was discussed in this topic\"\n    }},\n    {{\n      \"title\": \"Second main topic discussed\",\n      \"analysis\": \"Short paragraph about what was discussed in this topic\"\n    }},\n    {{\n      \"title\": \"Third main topic discussed\",\n      \"analysis\": \"Short paragraph about what was discussed in this topic\"\n    }}\n  ],\n  \"key_points\": [\n    \"First important point or topic discussed\",\n    \"Second key point from the meeting\",\n    \"Third key point from the meeting\",\n    \"Fourth key point from the meeting\",\n    \"Fifth key point from the meeting\"\n  ],\n  \"next_steps\": [\n    {{\n      \"description\": \"First next step or action item as explicitly mentioned\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }},\n    {{\n      \"description\": \"Second next step or action item\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }},\n    {{\n      \"description\": \"Third next step or action item\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }},\n    {{\n      \"description\": \"Fourth next step or action item\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }}\n  ]\n}}\"\"\",\n\n    # Chain-of-thought prompt - step by step reasoning\n    \"chain_of_thought\": lambda transcript: f\"\"\"You are a meeting assistant. Analyze this transcript step by step.\n\nTRANSCRIPT:\n{transcript}\n\n---\n\nAnalyze this transcript by following these steps IN ORDER. Show your reasoning for each step before providing the final JSON.\n\nSTEP 1 - IDENTIFY SPEAKERS:\nList everyone who spoke or was mentioned by name. If this is a monologue or presentation, note the speaker/presenter.\n\nSTEP 2 - LIST MAIN TOPICS:\nWhat are the 2-5 main topics or themes discussed? List them briefly.\n\nSTEP 3 - ANALYZE EACH TOPIC:\nFor each topic you identified, write 1-2 sentences summarizing what was said about it.\n\nSTEP 4 - EXTRACT KEY POINTS:\nWhat are the most important takeaways? List 3-6 concrete points.\n\nSTEP 5 - FIND ACTION ITEMS:\nWere any next steps, tasks, or action items mentioned? List them with who is responsible (if stated).\n\nSTEP 6 - WRITE OVERVIEW:\nWrite a 2-3 sentence overview that captures the essence of this meeting/discussion.\n\n---\n\nAfter completing your analysis above, provide the final summary as valid JSON (no markdown):\n\n{{\n  \"overview\": \"Your overview from Step 6\",\n  \"participants\": [\"Names from Step 1\"],\n  \"discussion_areas\": [\n    {{\"title\": \"Topic from Step 2\", \"analysis\": \"Analysis from Step 3\"}}\n  ],\n  \"key_points\": [\"Points from Step 4\"],\n  \"next_steps\": [\n    {{\"description\": \"Action from Step 5\", \"assignee\": \"Person or null\", \"deadline\": \"Date or null\"}}\n  ]\n}}\"\"\"\n}\n\n\ndef test_prompt(prompt_name: str, prompt_template: callable, transcript: str, model: str = \"llama3.2:3b\") -> Dict[str, Any]:\n    \"\"\"\n    Test a single prompt template on a transcript.\n\n    Args:\n        prompt_name: Name of the prompt template\n        prompt_template: Function that generates the prompt from transcript\n        transcript: The transcript text to analyze\n        model: Ollama model to use\n\n    Returns:\n        Dict with results including response, timing, and any errors\n    \"\"\"\n    logger.info(f\"Testing prompt: {prompt_name}\")\n\n    try:\n        # Generate the prompt\n        prompt = prompt_template(transcript)\n\n        # Time the request\n        start_time = datetime.now()\n\n        # Make the Ollama request\n        client = ollama.Client()\n        response = client.chat(\n            model=model,\n            messages=[\n                {\n                    'role': 'user',\n                    'content': prompt\n                }\n            ],\n            options={\n                'timeout': 1800  # 30 minute timeout\n            }\n        )\n\n        end_time = datetime.now()\n        duration = (end_time - start_time).total_seconds()\n\n        # Extract response text\n        response_text = response['message']['content'].strip()\n\n        # Try to clean and parse JSON\n        if response_text.startswith('```json'):\n            response_text = response_text.replace('```json', '').replace('```', '').strip()\n        elif response_text.startswith('```'):\n            response_text = response_text.replace('```', '').strip()\n\n        # Extract JSON if there's preamble text\n        if '{' in response_text and '}' in response_text:\n            json_start = response_text.find('{')\n            json_end = response_text.rfind('}') + 1\n            response_text = response_text[json_start:json_end].strip()\n\n        # Try to parse the JSON\n        try:\n            parsed_response = json.loads(response_text)\n            parse_success = True\n            parse_error = None\n        except json.JSONDecodeError as e:\n            parsed_response = None\n            parse_success = False\n            parse_error = str(e)\n\n        return {\n            \"prompt_name\": prompt_name,\n            \"success\": True,\n            \"duration_seconds\": duration,\n            \"response_text\": response_text,\n            \"parsed_response\": parsed_response,\n            \"parse_success\": parse_success,\n            \"parse_error\": parse_error\n        }\n\n    except Exception as e:\n        logger.error(f\"Error testing prompt {prompt_name}: {e}\")\n        return {\n            \"prompt_name\": prompt_name,\n            \"success\": False,\n            \"error\": str(e),\n            \"error_type\": type(e).__name__\n        }\n\n\n# Default output directory relative to this script\nDEFAULT_OUTPUT_DIR = Path(__file__).parent / \"outputs\"\n\n\ndef compare_prompts(transcript_file: str, output_dir: str = None, model: str = \"llama3.2:3b\", prompts: list = None):\n    \"\"\"\n    Compare multiple prompt templates on the same transcript.\n\n    Args:\n        transcript_file: Path to the transcript file\n        output_dir: Directory to save test results (default: prompt_tests/outputs)\n        model: Ollama model to use\n        prompts: List of prompt names to test (default: all)\n    \"\"\"\n    output_dir = output_dir or str(DEFAULT_OUTPUT_DIR)\n    # Load transcript\n    transcript_path = Path(transcript_file)\n    if not transcript_path.exists():\n        raise FileNotFoundError(f\"Transcript file not found: {transcript_file}\")\n\n    with open(transcript_path, 'r') as f:\n        transcript_content = f.read()\n\n    logger.info(f\"Loaded transcript: {transcript_path.name}\")\n    logger.info(f\"Transcript length: {len(transcript_content)} characters\")\n\n    # Determine which prompts to test\n    prompts_to_test = prompts if prompts else list(PROMPT_TEMPLATES.keys())\n\n    # Run tests\n    results = {}\n    for prompt_name in prompts_to_test:\n        if prompt_name not in PROMPT_TEMPLATES:\n            logger.warning(f\"Unknown prompt template: {prompt_name}, skipping\")\n            continue\n\n        prompt_template = PROMPT_TEMPLATES[prompt_name]\n        result = test_prompt(prompt_name, prompt_template, transcript_content, model)\n        results[prompt_name] = result\n\n    # Save results\n    output_path = Path(output_dir)\n    output_path.mkdir(parents=True, exist_ok=True)\n\n    timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n    results_file = output_path / f\"prompt_comparison_{timestamp}.json\"\n\n    comparison_data = {\n        \"transcript_file\": str(transcript_path),\n        \"transcript_length\": len(transcript_content),\n        \"model\": model,\n        \"timestamp\": timestamp,\n        \"results\": results\n    }\n\n    with open(results_file, 'w') as f:\n        json.dump(comparison_data, f, indent=2)\n\n    logger.info(f\"Results saved to: {results_file}\")\n\n    # Print comparison summary\n    print(\"\\n\" + \"=\"*80)\n    print(f\"PROMPT COMPARISON RESULTS\")\n    print(\"=\"*80)\n    print(f\"Transcript: {transcript_path.name}\")\n    print(f\"Model: {model}\")\n    print(f\"Tested {len(results)} prompts\\n\")\n\n    for prompt_name, result in results.items():\n        print(f\"\\n{'─'*80}\")\n        print(f\"PROMPT: {prompt_name}\")\n        print(f\"{'─'*80}\")\n\n        if result['success']:\n            print(f\"✓ Duration: {result['duration_seconds']:.2f}s\")\n            print(f\"✓ JSON Parse: {'Success' if result['parse_success'] else 'Failed'}\")\n\n            if result['parse_success']:\n                parsed = result['parsed_response']\n\n                # Show overview\n                overview = parsed.get('overview', '')\n                print(f\"\\nOverview ({len(overview)} chars):\")\n                print(f\"  {overview}\")\n\n                # Show participants\n                participants = parsed.get('participants', [])\n                print(f\"\\nParticipants ({len(participants)}):\")\n                for p in participants:\n                    print(f\"  - {p}\")\n\n                # Show key points/decisions\n                key_points = parsed.get('key_points', parsed.get('key_decisions', parsed.get('discussion_summary', [])))\n                print(f\"\\nKey Points ({len(key_points)}):\")\n                for kp in key_points[:5]:  # Show first 5\n                    if isinstance(kp, str):\n                        print(f\"  - {kp}\")\n                    elif isinstance(kp, dict):\n                        print(f\"  - {kp.get('decision', kp.get('topic', kp))}\")\n                if len(key_points) > 5:\n                    print(f\"  ... and {len(key_points) - 5} more\")\n\n                # Show action items\n                actions = parsed.get('next_steps', parsed.get('key_actions', parsed.get('action_items', [])))\n                print(f\"\\nAction Items ({len(actions)}):\")\n                for action in actions[:3]:  # Show first 3\n                    if isinstance(action, str):\n                        print(f\"  - {action}\")\n                    elif isinstance(action, dict):\n                        desc = action.get('description', action.get('action', ''))\n                        assignee = action.get('assignee', 'Unassigned')\n                        print(f\"  - {desc} [{assignee}]\")\n                if len(actions) > 3:\n                    print(f\"  ... and {len(actions) - 3} more\")\n\n            else:\n                print(f\"\\n✗ JSON Parse Error: {result['parse_error']}\")\n                print(f\"\\nRaw response preview:\")\n                print(f\"  {result['response_text'][:300]}...\")\n        else:\n            print(f\"✗ Error: {result['error']}\")\n\n    print(f\"\\n{'='*80}\")\n    print(f\"\\nFull results saved to: {results_file}\")\n    print(f\"{'='*80}\\n\")\n\n    return comparison_data\n\n\n@click.group()\ndef cli():\n    \"\"\"Prompt testing framework for meeting summarization\"\"\"\n    pass\n\n\n@cli.command()\n@click.argument('transcript_file', type=click.Path(exists=True))\n@click.option('--model', '-m', default='llama3.2:3b', help='Ollama model to use')\n@click.option('--output', '-o', default=None, help='Output directory for results (default: prompt_tests/outputs)')\n@click.option('--prompts', '-p', multiple=True, help='Specific prompts to test (can specify multiple)')\ndef compare(transcript_file, model, output, prompts):\n    \"\"\"Compare multiple prompt templates on a transcript\"\"\"\n    prompt_list = list(prompts) if prompts else None\n    compare_prompts(transcript_file, output, model, prompt_list)\n\n\n@cli.command()\ndef list_prompts():\n    \"\"\"List all available prompt templates\"\"\"\n    print(\"\\nAvailable Prompt Templates:\")\n    print(\"=\"*50)\n    for i, name in enumerate(PROMPT_TEMPLATES.keys(), 1):\n        print(f\"{i}. {name}\")\n    print(\"=\"*50)\n    print(f\"\\nTotal: {len(PROMPT_TEMPLATES)} templates\\n\")\n\n\n@cli.command()\n@click.argument('prompt_name')\n@click.argument('transcript_file', type=click.Path(exists=True))\ndef show_prompt(prompt_name, transcript_file):\n    \"\"\"Show what a specific prompt looks like for a transcript\"\"\"\n    if prompt_name not in PROMPT_TEMPLATES:\n        print(f\"Error: Unknown prompt template '{prompt_name}'\")\n        print(f\"Available prompts: {', '.join(PROMPT_TEMPLATES.keys())}\")\n        return\n\n    # Load transcript\n    with open(transcript_file, 'r') as f:\n        transcript = f.read()\n\n    # Show first 500 chars of transcript\n    print(f\"\\nTranscript preview ({len(transcript)} total chars):\")\n    print(\"─\"*80)\n    print(transcript[:500])\n    if len(transcript) > 500:\n        print(\"...\")\n    print(\"─\"*80)\n\n    # Generate and show prompt\n    prompt = PROMPT_TEMPLATES[prompt_name](transcript)\n\n    print(f\"\\nGenerated Prompt for '{prompt_name}':\")\n    print(\"=\"*80)\n    print(prompt)\n    print(\"=\"*80)\n\n\nif __name__ == '__main__':\n    cli()\n"
  },
  {
    "path": "requirements.txt",
    "content": "sounddevice>=0.4.6\nnumpy>=1.24.0,<2.0\npywhispercpp>=1.2.0\nollama>=0.1.7\nclick>=8.1.0\npydantic>=2.5.0\npython-dateutil>=2.8.0\nopenai>=1.0.0\nanthropic>=0.18.0\n"
  },
  {
    "path": "scripts/build-backend.sh",
    "content": "#!/bin/bash\n#\n# Build StenoAI Python backend as standalone executable\n#\n# This script bundles the Python backend using PyInstaller so that\n# users don't need Python installed to run the app.\n#\n# Prerequisites:\n#   - Python 3.9+ with pip\n#   - Virtual environment activated (optional but recommended)\n#\n# Usage:\n#   ./scripts/build-backend.sh\n#\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nPROJECT_ROOT=\"$(dirname \"$SCRIPT_DIR\")\"\n\ncd \"$PROJECT_ROOT\"\n\necho \"===================================\"\necho \"  StenoAI Backend Builder\"\necho \"===================================\"\necho \"\"\n\n# Check for Python\nif ! command -v python3 &> /dev/null; then\n    echo \"Error: Python 3 is required but not found\"\n    exit 1\nfi\n\necho \"Python version: $(python3 --version)\"\necho \"\"\n\n# Install PyInstaller if not present\nif ! python3 -c \"import PyInstaller\" 2>/dev/null; then\n    echo \"Installing PyInstaller...\"\n    pip install pyinstaller\n    echo \"\"\nfi\n\n# Install project dependencies\necho \"Installing project dependencies...\"\npip install -r requirements.txt\npip install -e .\necho \"\"\n\n# Clean previous builds\necho \"Cleaning previous builds...\"\nrm -rf build/ dist/\necho \"\"\n\n# Run PyInstaller\necho \"Building standalone executable...\"\necho \"This may take several minutes...\"\necho \"\"\n\npython3 -m PyInstaller stenoai.spec --noconfirm\n\n# Check if build succeeded\nif [ -d \"dist/stenoai\" ]; then\n    echo \"\"\n    echo \"===================================\"\n    echo \"  Build Successful!\"\n    echo \"===================================\"\n    echo \"\"\n    echo \"Bundled executable is at: dist/stenoai/\"\n    echo \"\"\n\n    # Show size\n    SIZE=$(du -sh dist/stenoai | cut -f1)\n    echo \"Bundle size: $SIZE\"\n    echo \"\"\n\n    # Test the executable\n    echo \"Testing executable...\"\n    if ./dist/stenoai/stenoai --help > /dev/null 2>&1; then\n        echo \"Executable test: PASSED\"\n    else\n        echo \"Executable test: WARNING - may need additional testing\"\n    fi\n    echo \"\"\n    echo \"To use with Electron app, update main.js to use:\"\n    echo \"  path.join(__dirname, '..', 'dist', 'stenoai', 'stenoai')\"\nelse\n    echo \"\"\n    echo \"Build FAILED!\"\n    echo \"Check the output above for errors.\"\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/download-ollama.sh",
    "content": "#!/bin/bash\n# Download Ollama and ffmpeg binaries for bundling with PyInstaller\n# Supports macOS, Linux, and Windows\n\nset -e\n\nOLLAMA_VERSION=\"v0.17.5\"\nBIN_DIR=\"$(cd \"$(dirname \"$0\")/..\" && pwd)/bin\"\n\n# --- Download ffmpeg ---\necho \"=== Downloading ffmpeg ===\"\ncase \"$(uname -s)\" in\n    Darwin)\n        FFMPEG_URL=\"https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip\"\n        mkdir -p \"$BIN_DIR\"\n        curl -L \"$FFMPEG_URL\" -o \"$BIN_DIR/ffmpeg.zip\"\n        cd \"$BIN_DIR\"\n        unzip -o ffmpeg.zip\n        rm ffmpeg.zip\n        chmod +x ffmpeg\n        echo \"ffmpeg downloaded\"\n        cd - > /dev/null\n        ;;\n    Linux)\n        # Use static build for Linux\n        FFMPEG_URL=\"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz\"\n        mkdir -p \"$BIN_DIR\"\n        curl -L \"$FFMPEG_URL\" -o \"$BIN_DIR/ffmpeg.tar.xz\"\n        cd \"$BIN_DIR\"\n        tar -xf ffmpeg.tar.xz --strip-components=1 --wildcards '*/ffmpeg'\n        rm ffmpeg.tar.xz\n        chmod +x ffmpeg\n        echo \"ffmpeg downloaded\"\n        cd - > /dev/null\n        ;;\n    *)\n        echo \"Note: ffmpeg not auto-downloaded for this platform. Please install manually.\"\n        ;;\nesac\n\n# --- Download Ollama ---\necho \"\"\necho \"=== Downloading Ollama ===\"\n\n# Detect platform\ncase \"$(uname -s)\" in\n    Darwin)\n        OLLAMA_FILE=\"ollama-darwin.tgz\"\n        ;;\n    Linux)\n        OLLAMA_FILE=\"ollama-linux-amd64.tgz\"\n        ;;\n    MINGW*|MSYS*|CYGWIN*)\n        OLLAMA_FILE=\"ollama-windows-amd64.zip\"\n        ;;\n    *)\n        echo \"Unsupported platform: $(uname -s)\"\n        exit 1\n        ;;\nesac\n\nOLLAMA_URL=\"https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/${OLLAMA_FILE}\"\n\necho \"Platform: $(uname -s)\"\necho \"Downloading Ollama ${OLLAMA_VERSION} (${OLLAMA_FILE})...\"\n\n# Create bin directory\nmkdir -p \"$BIN_DIR\"\ncd \"$BIN_DIR\"\n\n# Download\ncurl -L \"$OLLAMA_URL\" -o \"$OLLAMA_FILE\"\n\n# Extract based on file type\nif [[ \"$OLLAMA_FILE\" == *.zip ]]; then\n    unzip -o \"$OLLAMA_FILE\"\nelse\n    tar -xzf \"$OLLAMA_FILE\"\nfi\n\nrm \"$OLLAMA_FILE\"\n\necho \"Ollama downloaded to $BIN_DIR\"\nls -la \"$BIN_DIR\"\n"
  },
  {
    "path": "scripts/test_dmg_fresh_install.sh",
    "content": "#!/bin/bash\n\n# Script to simulate a completely fresh DMG install\n# This removes ALL dependencies and data to test true first-time experience\n\necho \"🚀 Simulating completely fresh DMG install...\"\necho \"This will remove EVERYTHING to test the full setup flow\"\necho \"\"\n\n# 1. Remove Python virtual environment entirely\necho \"🗑️ Removing Python virtual environment...\"\nrm -rf venv\n\n# 2. Remove all user data directories  \necho \"📁 Removing user data directories...\"\nrm -rf recordings transcripts output\n\n# 3. Remove Whisper from system Python\necho \"🎤 Removing Whisper from system Python...\"\npython3 -m pip uninstall -y openai-whisper 2>/dev/null || echo \"Whisper not in system Python\"\n\n# 4. Remove Ollama completely (binary + models)\necho \"🧠 Removing Ollama completely...\"\nif command -v ollama &> /dev/null; then\n    echo \"  - Stopping Ollama services...\"\n    pkill -f \"ollama\" 2>/dev/null || echo \"  - No Ollama processes running\"\n    \n    echo \"  - Uninstalling Ollama via Homebrew...\"\n    brew uninstall ollama 2>/dev/null || echo \"  - Ollama not installed via Homebrew\"\n    \n    echo \"  - Removing Ollama models and data...\"\n    rm -rf ~/.ollama 2>/dev/null || echo \"  - No Ollama data to remove\"\nelse\n    echo \"  - Ollama not found (already removed)\"\nfi\n\n# 5. Remove Whisper cache\necho \"🗄️ Removing Whisper cache...\"\nrm -rf ~/.cache/whisper 2>/dev/null || echo \"No Whisper cache to remove\"\n\n# 6. Optionally test without Homebrew (uncomment to test Homebrew auto-install)\n# echo \"🍺 Removing Homebrew (EXTREME TEST)...\"\n# /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)\" || echo \"Homebrew removal failed or not installed\"\n\necho \"\"\necho \"🎯 Complete fresh install simulation ready!\"\necho \"\"\necho \"Removed:\"\necho \"  ❌ Python virtual environment\"\necho \"  ❌ All user data directories\"  \necho \"  ❌ Whisper (system-wide)\"\necho \"  ❌ Ollama binary and models\"\necho \"  ❌ All caches\"\necho \"\"\necho \"Expected setup flow:\"\necho \"  1. 🖥️ System Check → Create venv, directories\"\necho \"  2. 🐍 Python → Install basic libraries\"\necho \"  3. 🎤 Whisper → Install OpenAI Whisper\"\necho \"  4. 🧠 Ollama → Install Homebrew (if needed) → Install Ollama → Download model\"\necho \"  5. ✅ Test → Verify everything works\"\necho \"\"\necho \"To test: cd app && npm start\""
  },
  {
    "path": "scripts/test_first_time_setup.sh",
    "content": "#!/bin/bash\n\n# Script to simulate first-time user experience by removing dependencies\n# This lets us test the setup wizard\n\necho \"🧪 Simulating first-time user setup...\"\n\n# Remove Whisper to test installation\nif [ -d \"venv\" ]; then\n    echo \"📦 Removing Whisper to test installation...\"\n    venv/bin/pip uninstall -y openai-whisper\n    echo \"✅ Whisper removed\"\nfi\n\n# Remove directories to test creation\necho \"📁 Removing directories to test creation...\"\nrm -rf recordings transcripts output\n\necho \"🎯 Setup test ready! Now run the Electron app to test first-time setup.\"\necho \"Expected behavior:\"\necho \"  1. App should detect missing dependencies\"\necho \"  2. Show setup wizard automatically\"\necho \"  3. Guide user through installation process\"\necho \"  4. Create all required directories\"\necho \"  5. Install Whisper and other dependencies\"\necho \"\"\necho \"To run app: cd app && npm start\""
  },
  {
    "path": "setup.py",
    "content": "from setuptools import setup, find_packages\n\nwith open(\"requirements.txt\", \"r\") as f:\n    requirements = [line.strip() for line in f if line.strip() and not line.startswith(\"#\")]\n\nsetup(\n    name=\"stenoai\",\n    version=\"0.1.0\",\n    packages=find_packages(where=\"src\"),\n    package_dir={\"\": \"src\"},\n    python_requires=\">=3.8\",\n    install_requires=requirements,\n    entry_points={\n        'console_scripts': [\n            'steno=main:cli',\n        ],\n    },\n    author=\"Your Name\",\n    description=\"AI-powered meeting transcription and analysis for Mac\",\n    long_description=open(\"README.md\").read(),\n    long_description_content_type=\"text/markdown\",\n)"
  },
  {
    "path": "simple_recorder.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nSimple Audio Recorder & Transcriber for Electron App\n\nBackend script that handles:\n1. Recording system/microphone audio\n2. Transcribing with Whisper  \n3. Summarizing with Ollama\n4. Saving everything locally\n\nUsage (called by Electron):\n    python simple_recorder.py start \"Meeting Name\"\n    python simple_recorder.py stop  \n    python simple_recorder.py process recording.wav --name \"Session\"\n    python simple_recorder.py status\n\"\"\"\n\nimport click\nimport asyncio\nimport logging\nimport json\nimport re\nimport sys\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\n# Import modules with graceful fallback for missing dependencies\ntry:\n    from src.audio_recorder import AudioRecorder\nexcept ImportError:\n    AudioRecorder = None\n\ntry:\n    from src.transcriber import WhisperTranscriber  \nexcept ImportError:\n    WhisperTranscriber = None\n    \ntry:\n    from src.summarizer import OllamaSummarizer\nexcept ImportError:\n    OllamaSummarizer = None\n\n# Setup logging\nlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\nlogger = logging.getLogger(__name__)\n\nclass SimpleRecorder:\n    \"\"\"Simple audio recorder and transcriber.\"\"\"\n    \n    def __init__(self):\n        # Only initialize if dependencies are available\n        self.audio_recorder = AudioRecorder() if AudioRecorder else None\n\n        # Only initialize transcriber/summarizer when needed to save memory\n        self.transcriber = None\n        self.summarizer = None\n\n        # Directories - centralised via get_data_dirs()\n        from src.config import get_data_dirs\n        dirs = get_data_dirs()\n        self.recordings_dir = dirs[\"recordings\"]\n        self.transcripts_dir = dirs[\"transcripts\"]\n        self.output_dir = dirs[\"output\"]\n        \n        # State file\n        self.state_file = Path(\"recorder_state.json\")\n        \n        # Global AudioRecorder instance to maintain state across CLI calls\n        self.persistent_recorder = None\n        \n    def get_state(self) -> dict:\n        \"\"\"Get current recorder state.\"\"\"\n        if self.state_file.exists():\n            try:\n                with open(self.state_file, 'r') as f:\n                    return json.load(f)\n            except:\n                pass\n        return {\"recording\": False, \"current_file\": None, \"session_name\": None}\n    \n    def save_state(self, state: dict):\n        \"\"\"Save recorder state.\"\"\"\n        with open(self.state_file, 'w') as f:\n            json.dump(state, f, indent=2)\n\n    def _resolve_output_language(self, configured_language: str, detected_language: Optional[str] = None) -> str:\n        \"\"\"Resolve which language should be used for summary/title/query output.\"\"\"\n        from src.config import get_config\n\n        if configured_language != \"auto\":\n            return configured_language\n\n        if detected_language:\n            return detected_language\n\n        return \"en\"\n\n    @staticmethod\n    def _load_user_notes(session_name: str, output_dir) -> Optional[str]:\n        \"\"\"Load user notes file saved by Electron during recording.\"\"\"\n        safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', session_name)\n        for candidate in [\n            Path(output_dir) / f\"{safe_name}_notes.txt\",\n            Path(output_dir) / f\"{session_name}_notes.txt\",\n        ]:\n            if candidate.exists():\n                try:\n                    text = candidate.read_text(encoding='utf-8').strip()\n                    if text:\n                        logger.info(f\"Loaded user notes ({len(text)} chars)\")\n                        return text\n                except Exception:\n                    pass\n                break\n        return None\n\n    @staticmethod\n    def _parse_streamed_markdown(md_text: str) -> dict:\n        \"\"\"Parse streamed markdown summary into structured fields.\"\"\"\n        summary_parts = []\n        participants = []\n        discussion_areas = []\n        key_points = []\n        action_items = []\n        current_section = None\n        current_topic_title = None\n        current_topic_lines = []\n\n        for line in md_text.split('\\n'):\n            stripped = line.strip()\n            if stripped.startswith('## Summary'):\n                current_section = 'summary'\n            elif stripped.startswith('## Participants'):\n                current_section = 'participants'\n            elif stripped.startswith('## Key Topics'):\n                current_section = 'topics'\n            elif stripped.startswith('## Key Points'):\n                current_section = 'keypoints'\n            elif stripped.startswith('## Action Items'):\n                current_section = 'actions'\n            elif stripped.startswith('### ') and current_section == 'topics':\n                if current_topic_title:\n                    discussion_areas.append({\"title\": current_topic_title, \"analysis\": '\\n'.join(current_topic_lines).strip()})\n                current_topic_title = stripped[4:]\n                current_topic_lines = []\n            elif current_section == 'summary' and stripped:\n                summary_parts.append(stripped)\n            elif current_section == 'participants' and stripped:\n                participants.extend([p.strip() for p in stripped.split(',') if p.strip()])\n            elif current_section == 'topics' and current_topic_title:\n                current_topic_lines.append(stripped)\n            elif current_section == 'keypoints' and stripped.startswith('- '):\n                key_points.append(stripped[2:])\n            elif current_section == 'actions' and stripped.startswith('- '):\n                action_items.append(stripped[2:].replace('[ ] ', '').replace('[x] ', ''))\n\n        if current_topic_title:\n            discussion_areas.append({\"title\": current_topic_title, \"analysis\": '\\n'.join(current_topic_lines).strip()})\n\n        return {\n            \"summary\": ' '.join(summary_parts),\n            \"participants\": participants,\n            \"discussion_areas\": discussion_areas,\n            \"key_points\": key_points,\n            \"action_items\": action_items,\n        }\n\n    def start_recording(self, session_name: str = \"Recording\") -> str:\n        \"\"\"Start recording audio.\"\"\"\n        state = self.get_state()\n        if state.get(\"recording\"):\n            raise Exception(f\"Already recording: {state.get('current_file', 'unknown file')}\")\n        \n        # Create filename\n        timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n        safe_name = \"\".join(c for c in session_name if c.isalnum() or c in (' ', '-', '_')).strip()\n        filename = f\"{timestamp}_{safe_name}.wav\"\n        \n        recording_path = self.recordings_dir / filename\n        \n        print(f\"🎤 Starting recording: {session_name}\")\n        print(f\"📁 File: {recording_path}\")\n        \n        # Start recording\n        self.audio_recorder.start_recording()\n        \n        # Update state\n        new_state = {\n            \"recording\": True,\n            \"current_file\": str(recording_path), \n            \"session_name\": session_name,\n            \"start_time\": datetime.now().isoformat()\n        }\n        self.save_state(new_state)\n        \n        return str(recording_path)\n    \n    def stop_recording(self) -> Optional[str]:\n        \"\"\"Stop current recording.\"\"\"\n        state = self.get_state()\n        if not state.get(\"recording\"):\n            print(\"⚠️ No active recording to stop\")\n            return None\n        \n        print(\"🔴 Stopping recording\")\n        \n        # Stop recording\n        self.audio_recorder.stop_recording()\n        \n        # Wait a moment for recording to fully stop\n        import time\n        time.sleep(0.5)\n        \n        # Get the planned file path from state\n        recording_path = state.get(\"current_file\")\n        if not recording_path:\n            print(\"⚠️ No recording file path found in state\")\n            # Try to create a default path\n            timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n            recording_path = str(self.recordings_dir / f\"{timestamp}_recording.wav\")\n        \n        # Save the recording to the planned file\n        from pathlib import Path\n        if self.audio_recorder.save_recording(Path(recording_path)):\n            print(f\"✅ Recording saved: {recording_path}\")\n        else:\n            print(\"❌ Failed to save recording\")\n            recording_path = None\n        \n        # Update state (always clear recording state)\n        new_state = {\n            \"recording\": False,\n            \"current_file\": None,\n            \"session_name\": None,\n            \"stop_time\": datetime.now().isoformat()\n        }\n        if recording_path:\n            new_state[\"last_recording\"] = recording_path\n        \n        self.save_state(new_state)\n        return recording_path\n    \n    async def transcribe_audio(self, audio_file: str, session_name: str = \"Recording\") -> dict:\n        \"\"\"Transcribe audio file.\"\"\"\n        audio_path = Path(audio_file)\n\n        if not audio_path.exists():\n            raise FileNotFoundError(f\"Audio file not found: {audio_file}\")\n\n        print(f\"📝 Transcribing: {audio_path.name}\")\n\n        from src.config import get_config\n        config = get_config()\n\n        # Initialize transcriber only when needed\n        if self.transcriber is None:\n            self.transcriber = WhisperTranscriber(model_size=config.get_whisper_model())\n\n        # Get configured language\n        configured_language = config.get_language()\n\n        # Transcribe with diarisation support (stereo → [You]/[Others])\n        transcript_result = self.transcriber.transcribe_diarised(audio_path, language=configured_language)\n\n        # Handle different return types\n        duration_seconds = None\n        detected_language = None\n        if isinstance(transcript_result, dict):\n            transcript_text = transcript_result.get(\"text\") or \"\"\n            duration_seconds = transcript_result.get(\"duration_seconds\")\n            detected_language = transcript_result.get(\"detected_language\")\n        elif hasattr(transcript_result, 'text'):\n            transcript_text = transcript_result.text\n        elif isinstance(transcript_result, str):\n            transcript_text = transcript_result\n        else:\n            transcript_text = str(transcript_result)\n\n        # Extract diarisation fields\n        is_diarised = False\n        diarised_text = None\n        if isinstance(transcript_result, dict):\n            is_diarised = transcript_result.get(\"is_diarised\", False)\n            diarised_text = transcript_result.get(\"diarised_text\")\n\n        output_language = self._resolve_output_language(configured_language, detected_language)\n        detected_language_name = config.get_language_name(detected_language) if detected_language else \"Unknown\"\n\n        # Save transcript (use diarised text if available for the saved file)\n        transcript_path = self.transcripts_dir / f\"{audio_path.stem}_transcript.txt\"\n        saved_transcript = diarised_text if diarised_text else transcript_text\n        transcript_content = f\"\"\"Session: {session_name}\nFile: {audio_path.name}\nDate: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nLanguage setting: {config.get_language_name(configured_language)}\nDetected language: {detected_language_name}\nSummary output language: {config.get_language_name(output_language)}\n\n{'='*60}\n\n{saved_transcript}\n\"\"\"\n\n        with open(transcript_path, 'w') as f:\n            f.write(transcript_content)\n\n        print(f\"📄 Transcript saved: {transcript_path}\")\n\n        return {\n            \"audio_file\": str(audio_path),\n            \"transcript_file\": str(transcript_path),\n            \"transcript_text\": transcript_text,\n            \"session_name\": session_name,\n            \"duration_seconds\": duration_seconds,\n            \"configured_language\": configured_language,\n            \"detected_language\": detected_language,\n            \"is_diarised\": is_diarised,\n            \"diarised_text\": diarised_text,\n            \"output_language\": output_language,\n        }\n\n    async def summarize_transcript(\n        self,\n        transcript_text: str,\n        session_name: str = \"Recording\",\n        duration_minutes: int = 10,\n        language: Optional[str] = None,\n        notes_text: Optional[str] = None\n    ) -> dict:\n        \"\"\"Summarize transcript text.\"\"\"\n        print(\"🧠 Generating summary...\")\n        \n        # Initialize summarizer only when needed\n        if self.summarizer is None:\n            self.summarizer = OllamaSummarizer()\n        \n        # Create summary prompt\n        prompt = f\"\"\"\nPlease analyze and summarize this audio transcript from a recording session.\n\nSession: {session_name}\n\nPlease provide:\n1. Brief overview of the content\n2. Key points discussed\n3. Important decisions or conclusions\n4. Action items (if any)\n5. Notable quotes or insights\n\nTranscript:\n{transcript_text}\n\"\"\"\n        \n        # Resolve output language\n        from src.config import get_config\n        if language is None:\n            configured_language = get_config().get_language()\n            language = self._resolve_output_language(configured_language)\n\n        # Generate summary (using correct method name and parameters)\n        summary_result = self.summarizer.summarize_transcript(transcript_text, duration_minutes, language=language, notes=notes_text)\n        \n        if summary_result is None:\n            return {\n                \"summary\": \"Failed to generate summary\",\n                \"participants\": [],\n                \"discussion_areas\": [],\n                \"key_points\": [],\n                \"action_items\": []\n            }\n        \n        # Defensive extraction from summary_result\n        try:\n            return {\n                \"summary\": getattr(summary_result, 'overview', '') or '',\n                \"participants\": getattr(summary_result, 'participants', []) or [],\n                \"discussion_areas\": [\n                    {\n                        \"title\": getattr(area, 'title', ''),\n                        \"analysis\": getattr(area, 'analysis', '')\n                    } for area in getattr(summary_result, 'discussion_areas', [])\n                ],\n                \"key_points\": [getattr(decision, 'decision', '') for decision in getattr(summary_result, 'key_points', [])],\n                \"action_items\": [getattr(action, 'description', '') for action in getattr(summary_result, 'next_steps', [])]\n            }\n        except Exception as e:\n            print(f\"⚠️ Error extracting summary data: {e}\")\n            return {\n                \"summary\": \"Summary extraction failed\",\n                \"participants\": [],\n                \"discussion_areas\": [],\n                \"key_points\": [],\n                \"action_items\": []\n            }\n    \n    async def process_recording(self, audio_file: str, session_name: str = \"Recording\", notes_text: Optional[str] = None) -> dict:\n        \"\"\"Complete processing: transcribe + summarize.\"\"\"\n        print(f\"🔄 Processing recording: {audio_file}\")\n        \n        # If no audio file provided, use the last recording\n        if not audio_file:\n            state = self.get_state()\n            audio_file = state.get(\"last_recording\")\n            if not audio_file:\n                raise Exception(\"No audio file specified and no recent recording found\")\n        \n        # Ensure we have a proper path\n        audio_file = str(audio_file)  # Convert to string if it's a Path object\n        audio_path = Path(audio_file)\n        \n        # Step 1: Transcribe (also returns duration from the converted WAV)\n        transcript_data = await self.transcribe_audio(audio_file, session_name)\n\n        # Determine duration: use transcriber's value (works for all formats)\n        duration_seconds = transcript_data.get(\"duration_seconds\")\n        if duration_seconds is not None:\n            if duration_seconds < 60:\n                duration_minutes = 0\n                print(f\"📏 Audio duration: {duration_seconds:.1f} seconds ({int(duration_seconds)}s)\")\n            else:\n                duration_minutes = int(duration_seconds / 60)\n                print(f\"📏 Audio duration: {duration_seconds:.1f} seconds ({duration_minutes}m)\")\n        else:\n            duration_minutes = 0\n            print(\"⚠️ Could not determine audio duration\")\n\n        # Step 2: Summarize — prefer diarised text so LLM sees speaker labels\n        text_for_summary = transcript_data.get(\"diarised_text\") or transcript_data[\"transcript_text\"]\n        summary_data = await self.summarize_transcript(\n            text_for_summary,\n            session_name,\n            duration_minutes,\n            language=transcript_data.get(\"output_language\"),\n            notes_text=notes_text\n        )\n\n        # Step 2b: Auto-generate title for auto-named meetings\n        if re.match(r'^(Meeting|Note)(-[A-Z0-9]{6})?$', session_name):\n            try:\n                language = transcript_data.get(\"output_language\")\n                generated_title = self.summarizer.generate_title(\n                    summary_data.get(\"summary\", \"\"),\n                    transcript_data[\"transcript_text\"],\n                    language=language\n                )\n                if generated_title:\n                    print(f\"Auto-generated title: {generated_title}\")\n                    session_name = generated_title\n            except Exception as e:\n                print(f\"Title generation skipped: {e}\")\n\n        # Step 3: Save complete summary\n        summary_path = self.output_dir / f\"{audio_path.stem}_summary.json\"\n\n        complete_data = {\n            \"session_info\": {\n                \"name\": session_name,\n                \"audio_file\": str(audio_path),\n                \"transcript_file\": transcript_data[\"transcript_file\"],\n                \"summary_file\": str(summary_path),\n                \"processed_at\": datetime.now().isoformat(),\n                \"duration_seconds\": int(duration_seconds) if duration_seconds is not None else None,\n                \"duration_minutes\": duration_minutes,\n                \"configured_language\": transcript_data.get(\"configured_language\"),\n                \"detected_language\": transcript_data.get(\"detected_language\"),\n                \"output_language\": transcript_data.get(\"output_language\"),\n            },\n            \"summary\": summary_data.get(\"summary\", \"\") or \"\",\n            \"participants\": summary_data.get(\"participants\", []) or [],\n            \"discussion_areas\": summary_data.get(\"discussion_areas\", []) or [],\n            \"key_points\": summary_data.get(\"key_points\", []) or [],\n            \"action_items\": summary_data.get(\"action_items\", []) or [],\n            \"transcript\": transcript_data[\"transcript_text\"],\n            \"is_diarised\": transcript_data.get(\"is_diarised\", False),\n            \"diarised_text\": transcript_data.get(\"diarised_text\"),\n            \"user_notes\": notes_text,\n        }\n        \n        with open(summary_path, 'w') as f:\n            json.dump(complete_data, f, indent=2)\n        \n        print(f\"✅ Complete processing saved: {summary_path}\")\n        \n        # Clean up WAV file after successful processing\n        from src.config import get_config\n        if not get_config().get_keep_recordings():\n            try:\n                audio_path.unlink()\n                print(f\"🗑️ Cleaned up audio file: {audio_path}\")\n            except Exception as e:\n                print(f\"⚠️ Could not delete audio file: {e}\")\n        \n        # Clear any recording state after successful processing\n        state_file = Path(\"recorder_state.json\")\n        if state_file.exists():\n            try:\n                state_file.unlink()\n                print(f\"🧹 Cleared recording state\")\n            except Exception as e:\n                print(f\"⚠️ Could not clear state: {e}\")\n        \n        print(f\"📋 Processing complete - meeting available in list\")\n\n        return complete_data\n\n    async def process_recording_streaming(self, audio_file: str, session_name: str = \"Recording\", notes_text: Optional[str] = None) -> dict:\n        \"\"\"Process recording with streaming summary output via CHUNK: protocol.\"\"\"\n        import base64\n        print(f\"🔄 Processing recording: {audio_file}\")\n\n        if not audio_file:\n            state = self.get_state()\n            audio_file = state.get(\"last_recording\")\n            if not audio_file:\n                raise Exception(\"No audio file specified and no recent recording found\")\n\n        audio_path = Path(audio_file)\n        if not audio_path.exists():\n            raise Exception(f\"Audio file not found: {audio_file}\")\n\n        # Step 1: Transcribe\n        transcript_data = await self.transcribe_audio(audio_file, session_name)\n        transcript_text = transcript_data.get(\"transcript_text\", \"\")\n        diarised_text = transcript_data.get(\"diarised_text\")\n        text_for_summary = diarised_text or transcript_text\n\n        duration_seconds = transcript_data.get(\"duration_seconds\")\n        duration_minutes = int(duration_seconds / 60) if duration_seconds else 0\n\n        if duration_seconds:\n            print(f\"📏 Audio duration: {duration_seconds} seconds ({int(duration_seconds)}s)\")\n\n        print(f\"TRANSCRIPTION_COMPLETE:{len(transcript_text)}\", flush=True)\n\n        # Step 2: Streaming summary\n        if self.summarizer is None:\n            self.summarizer = OllamaSummarizer()\n\n        from src.config import get_config\n        config = get_config()\n        configured_language = config.get_language()\n        output_language = self._resolve_output_language(\n            configured_language, transcript_data.get(\"detected_language\")\n        )\n\n        print(\"🧠 Generating summary...\", flush=True)\n        streamed_chunks = []\n        for chunk in self.summarizer.summarize_transcript_streaming(\n            text_for_summary, duration_minutes, output_language, notes_text\n        ):\n            encoded = base64.b64encode(chunk.encode('utf-8')).decode('ascii')\n            sys.stdout.write(f\"CHUNK:{encoded}\\n\")\n            sys.stdout.flush()\n            streamed_chunks.append(chunk)\n        streamed_md = ''.join(streamed_chunks)\n\n        print(\"STREAM_COMPLETE\", flush=True)\n\n        # Step 3: Generate title\n        if re.match(r'^(Meeting|Note)(-[A-Z0-9]{6})?$', session_name):\n            try:\n                generated_title = self.summarizer.generate_title(\n                    streamed_md, transcript_text, language=output_language\n                )\n                if generated_title:\n                    session_name = generated_title\n                    print(f\"TITLE:{session_name}\", flush=True)\n                    print(f\"Auto-generated title: {session_name}\")\n            except Exception as e:\n                logger.warning(f\"Title generation failed: {e}\")\n\n        # Step 4: Parse streamed markdown into structured JSON\n        parsed = self._parse_streamed_markdown(streamed_md)\n\n        # Step 5: Save as .md (primary format for new meetings)\n        summary_path = self.output_dir / f\"{audio_path.stem}_summary.md\"\n        processed_at = datetime.now().isoformat()\n        md_lines = ['---']\n        md_meta = {\n            'title': session_name,\n            'date': processed_at,\n            'duration_seconds': int(duration_seconds) if duration_seconds else None,\n            'language': output_language,\n            'is_diarised': transcript_data.get('is_diarised', False),\n        }\n        for k, v in md_meta.items():\n            if v is None:\n                md_lines.append(f'{k}: null')\n            elif isinstance(v, bool):\n                md_lines.append(f'{k}: {\"true\" if v else \"false\"}')\n            elif isinstance(v, int):\n                md_lines.append(f'{k}: {v}')\n            else:\n                escaped = str(v).replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n                md_lines.append(f'{k}: \"{escaped}\"')\n        md_lines.append('---')\n        md_lines.append('')\n        md_lines.append(streamed_md)\n        md_lines.append('')\n        md_lines.append('## Transcript')\n        md_lines.append('')\n        md_lines.append(diarised_text or transcript_text)\n        if notes_text:\n            md_lines.append('')\n            md_lines.append('## User Notes')\n            md_lines.append('')\n            md_lines.append(notes_text)\n        summary_path.write_text('\\n'.join(md_lines), encoding='utf-8')\n\n        # Clean up\n        from src.config import get_config\n        if not get_config().get_keep_recordings():\n            try:\n                audio_path.unlink()\n                print(f\"🗑️ Cleaned up audio file: {audio_path}\")\n            except Exception:\n                pass\n\n        state_file = Path(\"recorder_state.json\")\n        if state_file.exists():\n            try:\n                state_file.unlink()\n            except Exception:\n                pass\n\n        print(f\"✅ Complete processing saved: {summary_path}\")\n        print(f\"SAVED:{summary_path}\", flush=True)\n        return {\n            \"session_info\": {\n                \"name\": session_name,\n                \"transcript_file\": str(transcript_data.get(\"transcript_file\", \"\")),\n                \"summary_file\": str(summary_path),\n            }\n        }\n\n\n# CLI Commands for Electron\n@click.group()\ndef cli():\n    \"\"\"Simple Audio Recorder & Transcriber Backend\"\"\"\n    pass\n\n\n@cli.command()\n@click.argument('session_name', default='Recording')\ndef start(session_name):\n    \"\"\"Start recording audio (stop with Ctrl+C to auto-process)\"\"\"\n    import signal\n    import time\n    \n    recorder = SimpleRecorder()\n    recording_path = None\n    recording_started = False\n    processing_started = False\n    \n    def signal_handler(signum, frame):\n        \"\"\"Handle SIGTERM/SIGINT gracefully by stopping recording and processing\"\"\"\n        nonlocal processing_started\n        \n        # Different handling for different signals\n        signal_name = \"SIGINT\" if signum == 2 else f\"SIGTERM\" if signum == 15 else f\"Signal {signum}\"\n        print(f\"\\n🛑 Received {signal_name} - stopping recording and processing...\")\n        \n        # Prevent double processing if multiple signals received\n        if processing_started:\n            print(\"⚠️ Processing already started - please wait for completion...\")\n            if signum == 15:  # SIGTERM - ignore it during processing\n                print(\"🔄 Ignoring SIGTERM during transcription/summarization\")\n                return\n            sys.exit(0)\n            \n        if recording_started and recorder:\n            processing_started = True\n            try:\n                final_path = recorder.stop_recording()\n                if final_path:\n                    print(f\"✅ Recording saved: {final_path}\")\n                    \n                    # Check file size\n                    from pathlib import Path\n                    file_size = Path(final_path).stat().st_size\n                    print(f\"📏 File size: {file_size / 1024:.1f} KB\")\n                    \n                    if file_size >= 1000:  # At least 1KB of audio data\n                        print(\"🔄 Starting transcription and summarization pipeline...\")\n                        \n                        # Process recording with proper async handling\n                        try:\n                            import asyncio\n                            loop = asyncio.new_event_loop()\n                            asyncio.set_event_loop(loop)\n                            \n                            _notes_text = recorder._load_user_notes(session_name, recorder.output_dir)\n\n                            print(\"📝 Starting transcription...\")\n                            result = loop.run_until_complete(recorder.process_recording_streaming(final_path, session_name, notes_text=_notes_text))\n\n                            print(\"✅ Complete processing finished!\", flush=True)\n                            print(f\"📄 Transcript: {result['session_info']['transcript_file']}\")\n                            print(f\"📋 Summary: {result['session_info']['summary_file']}\")\n                            print(f\"📊 Meeting: {result['session_info']['name']}\")\n\n                        except Exception as e:\n                            print(f\"❌ Processing pipeline failed: {e}\", flush=True)\n                            import traceback\n                            traceback.print_exc()\n                    else:\n                        print(\"⚠️ Recording too short - skipping processing\")\n                else:\n                    print(\"❌ No recording data to save\")\n            except Exception as e:\n                print(f\"❌ Error during signal handling: {e}\")\n                import traceback\n                traceback.print_exc()\n\n        print(\"🏁 Recording session ended\")\n        sys.exit(0)\n\n    # Register signal handlers for graceful shutdown\n    signal.signal(signal.SIGTERM, signal_handler)\n    signal.signal(signal.SIGINT, signal_handler)\n    \n    try:\n        recording_path = recorder.start_recording(session_name)\n        recording_started = True\n        print(f\"🎤 Recording '{session_name}' - Press Ctrl+C to stop and process\")\n        print(f\"📁 File: {recording_path}\")\n        print(\"📢 Speak now...\")\n        \n        # Wait indefinitely until interrupted\n        while True:\n            time.sleep(1)\n            \n    except Exception as e:\n        print(f\"ERROR: {e}\")\n        sys.exit(1)\n\n\n@cli.command()\ndef stop():\n    \"\"\"Stop current recording and trigger processing\"\"\"\n    import subprocess\n    import signal\n    import os\n    import time\n    \n    # First check if there's a recording process running\n    try:\n        # Find running start processes\n        result = subprocess.run(\n            ['pgrep', '-f', 'simple_recorder.py start'],\n            capture_output=True,\n            text=True\n        )\n        \n        if result.returncode == 0 and result.stdout.strip():\n            pids = result.stdout.strip().split('\\n')\n            print(f\"🔍 Found {len(pids)} recording process(es)\")\n            \n            for pid in pids:\n                if pid.strip():\n                    try:\n                        pid_int = int(pid.strip())\n                        print(f\"🛑 Sending SIGINT to recording process (PID: {pid_int})\")\n                        os.kill(pid_int, signal.SIGINT)\n                        \n                        print(f\"✅ Stop signal sent to process {pid_int}\")\n                        print(f\"🔄 Recording will stop and processing will begin automatically\")\n                        print(f\"💡 Processing may take a few minutes - check output files when complete\")\n                            \n                    except (ValueError, ProcessLookupError) as e:\n                        print(f\"⚠️ Could not signal process {pid}: {e}\")\n            \n            print(\"✅ Stop signal sent - recording will be processed automatically\")\n            \n        else:\n            # Fallback to old method if no start process found\n            print(\"🔍 No start process found, checking recording state...\")\n            recorder = SimpleRecorder()\n            state = recorder.get_state()\n            \n            if state.get(\"recording\"):\n                print(\"⚠️ Recording state shows active but no process found\")\n                print(\"🔧 Clearing stuck state...\")\n                recorder.save_state({\n                    \"recording\": False,\n                    \"current_file\": None,\n                    \"session_name\": None\n                })\n                print(\"✅ State cleared\")\n            else:\n                print(\"ℹ️ No active recording found\")\n                \n    except Exception as e:\n        print(f\"ERROR: {e}\")\n        sys.exit(1)\n\n\n@cli.command()\n@click.argument('audio_file', default='')\n@click.option('--name', '-n', default='Recording', help='Session name for the recording')\n@click.option('--notes', default=None, help='Path to user notes file')\ndef process(audio_file, name, notes):\n    \"\"\"Process audio file: transcribe + summarize\"\"\"\n\n    async def run_process():\n        recorder = SimpleRecorder()\n\n        # Read user notes if provided\n        notes_text = None\n        if notes:\n            try:\n                notes_text = Path(notes).read_text(encoding='utf-8').strip()\n                if notes_text:\n                    logger.info(f\"Loaded user notes ({len(notes_text)} chars)\")\n            except Exception as e:\n                logger.warning(f\"Failed to read notes file: {e}\")\n\n        try:\n            result = await recorder.process_recording(audio_file, name, notes_text=notes_text)\n            \n            print(\"SUCCESS: Processing complete!\")\n            print(f\"Transcript: {result['session_info']['transcript_file']}\")\n            print(f\"Summary: {result['session_info']['summary_file']}\")\n            \n        except Exception as e:\n            print(f\"ERROR: {e}\")\n            sys.exit(1)\n    \n    asyncio.run(run_process())\n\n\n@cli.command(name='process-streaming')\n@click.argument('audio_file', default='')\n@click.option('--name', '-n', default='Recording', help='Session name')\n@click.option('--notes', default=None, help='Path to user notes file')\ndef process_streaming(audio_file, name, notes):\n    \"\"\"Process audio with streaming summary output.\n\n    Transcribes audio, then streams the summary as CHUNK: prefixed lines\n    to stdout for Electron to relay to the renderer in real time.\n    \"\"\"\n    import sys\n\n    async def run():\n        recorder = SimpleRecorder()\n\n        # Read user notes\n        notes_text = None\n        if notes:\n            try:\n                notes_text = Path(notes).read_text(encoding='utf-8').strip()\n                if notes_text:\n                    logger.info(f\"Loaded user notes ({len(notes_text)} chars)\")\n            except Exception as e:\n                logger.warning(f\"Failed to read notes file: {e}\")\n\n        # Step 1: Transcribe\n        transcript_data = await recorder.transcribe_audio(audio_file, name)\n        transcript_text = transcript_data.get(\"transcript_text\", \"\")\n        diarised_text = transcript_data.get(\"diarised_text\")\n        text_for_summary = diarised_text or transcript_text\n\n        duration_seconds = transcript_data.get(\"duration_seconds\")\n        duration_minutes = int(duration_seconds / 60) if duration_seconds else 0\n\n        print(f\"TRANSCRIPTION_COMPLETE:{len(transcript_text)}\", flush=True)\n\n        # Step 2: Stream summary\n        if recorder.summarizer is None:\n            recorder.summarizer = OllamaSummarizer()\n\n        from src.config import get_config\n        config = get_config()\n        configured_language = config.get_language()\n        output_language = recorder._resolve_output_language(\n            configured_language, transcript_data.get(\"detected_language\")\n        )\n\n        import base64\n        streamed_chunks = []\n        for chunk in recorder.summarizer.summarize_transcript_streaming(\n            text_for_summary, duration_minutes, output_language, notes_text\n        ):\n            encoded = base64.b64encode(chunk.encode('utf-8')).decode('ascii')\n            sys.stdout.write(f\"CHUNK:{encoded}\\n\")\n            sys.stdout.flush()\n            streamed_chunks.append(chunk)\n        streamed_md = ''.join(streamed_chunks)\n\n        print(\"STREAM_COMPLETE\", flush=True)\n\n        # Step 3: Generate title\n        session_name = name\n        if re.match(r'^(Meeting|Note)(-[A-Z0-9]{6})?$', name):\n            try:\n                generated_title = recorder.summarizer.generate_title(\n                    streamed_md, transcript_text, language=output_language\n                )\n                if generated_title:\n                    session_name = generated_title\n                    print(f\"TITLE:{session_name}\", flush=True)\n            except Exception as e:\n                logger.warning(f\"Title generation failed: {e}\")\n\n        # Step 4: Save as .md\n        audio_path = Path(audio_file)\n        summary_path = recorder.output_dir / f\"{audio_path.stem}_summary.md\"\n\n        # Parse the streamed markdown for title generation\n        parsed = SimpleRecorder._parse_streamed_markdown(streamed_md)\n\n        # Save as .md only (primary format for new meetings)\n        summary_path = summary_path.with_suffix('.md')\n        processed_at = datetime.now().isoformat()\n        md_lines = ['---']\n        md_meta = {\n            'title': session_name,\n            'date': processed_at,\n            'duration_seconds': int(duration_seconds) if duration_seconds else None,\n            'language': output_language,\n            'is_diarised': transcript_data.get('is_diarised', False),\n        }\n        for k, v in md_meta.items():\n            if v is None:\n                md_lines.append(f'{k}: null')\n            elif isinstance(v, bool):\n                md_lines.append(f'{k}: {\"true\" if v else \"false\"}')\n            elif isinstance(v, int):\n                md_lines.append(f'{k}: {v}')\n            else:\n                escaped = str(v).replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n                md_lines.append(f'{k}: \"{escaped}\"')\n        md_lines.append('---')\n        md_lines.append('')\n        md_lines.append(streamed_md)\n        md_lines.append('')\n        md_lines.append('## Transcript')\n        md_lines.append('')\n        md_lines.append(diarised_text or transcript_text)\n        if notes_text:\n            md_lines.append('')\n            md_lines.append('## User Notes')\n            md_lines.append('')\n            md_lines.append(notes_text)\n        summary_path.write_text('\\n'.join(md_lines), encoding='utf-8')\n\n        # Clean up audio\n        from src.config import get_config\n        if not get_config().get_keep_recordings():\n            try:\n                audio_path.unlink()\n            except Exception:\n                pass\n\n        print(f\"SAVED:{summary_path}\", flush=True)\n\n    asyncio.run(run())\n\n\n\n\n@cli.command(name='get-whisper-model')\ndef get_whisper_model_cmd():\n    \"\"\"Get the configured Whisper model size.\"\"\"\n    from src.config import get_config\n    config = get_config()\n    print(json.dumps({\n        \"whisper_model\": config.get_whisper_model(),\n        \"supported_models\": list(config.SUPPORTED_WHISPER_MODELS),\n    }))\n\n\n@cli.command(name='set-whisper-model')\n@click.argument('model_size')\ndef set_whisper_model_cmd(model_size: str):\n    \"\"\"Set the Whisper model size.\"\"\"\n    from src.config import get_config\n    config = get_config()\n    if config.set_whisper_model(model_size):\n        print(json.dumps({\"success\": True, \"whisper_model\": model_size}))\n    else:\n        print(json.dumps({\n            \"success\": False,\n            \"error\": f\"Unsupported model: {model_size}\",\n            \"supported_models\": list(config.SUPPORTED_WHISPER_MODELS),\n        }))\n\n\n@cli.command(name='get-keep-recordings')\ndef get_keep_recordings_cmd():\n    \"\"\"Get whether recordings are kept after processing.\"\"\"\n    from src.config import get_config\n    config = get_config()\n    print(json.dumps({\"keep_recordings\": config.get_keep_recordings()}))\n\n\n@cli.command(name='set-keep-recordings')\n@click.argument('enabled', type=bool)\ndef set_keep_recordings_cmd(enabled: bool):\n    \"\"\"Set whether recordings are kept after processing.\"\"\"\n    from src.config import get_config\n    config = get_config()\n    if config.set_keep_recordings(enabled):\n        print(json.dumps({\"success\": True, \"keep_recordings\": enabled}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to persist setting\"}))\n\n\n@cli.command()\ndef status():\n    \"\"\"Show recorder status\"\"\"\n    recorder = SimpleRecorder()\n    state = recorder.get_state()\n    \n    print(\"🎙️ Steno Recorder Status\")\n    print(\"=\" * 25)\n    \n    if state.get(\"recording\"):\n        print(\"STATUS: RECORDING\")\n        print(f\"Session: {state.get('session_name')}\")\n        print(f\"File: {state.get('current_file')}\")\n        print(f\"Started: {state.get('start_time')}\")\n    else:\n        print(\"STATUS: READY\")\n    \n    # Show recent recordings\n    recordings = list(recorder.recordings_dir.glob(\"*.wav\"))\n    if recordings:\n        recent = sorted(recordings, key=lambda x: x.stat().st_mtime, reverse=True)[:3]\n        print(f\"\\nRecent recordings ({len(recordings)} total):\")\n        for recording in recent:\n            size_mb = recording.stat().st_size / (1024 * 1024)\n            print(f\"  • {recording.name} ({size_mb:.1f}MB)\")\n\n\n@cli.command()\n@click.argument('duration', type=int, default=10)\n@click.argument('session_name', default='Recording')\ndef record(duration, session_name):\n    \"\"\"Record audio for specified duration and process it\"\"\"\n    import signal\n    import sys\n\n    print(f\"🎤 Recording {duration} seconds of audio for '{session_name}'...\")\n\n    recorder = SimpleRecorder()\n    recording_path = None\n    recording_started = False\n    is_paused = False\n\n    def pause_handler(signum, frame):\n        \"\"\"Handle SIGUSR1 to pause recording\"\"\"\n        nonlocal is_paused\n        print(f\"📨 Received SIGUSR1 signal (pause request)\")\n        print(f\"   recording_started={recording_started}, has_recorder={recorder is not None}, has_audio={recorder.audio_recorder is not None if recorder else False}\")\n        if recording_started and recorder and recorder.audio_recorder:\n            if not is_paused:\n                recorder.audio_recorder.pause_recording()\n                is_paused = True\n                print(\"⏸️ Recording paused successfully\")\n            else:\n                print(\"⏸️ Already paused - ignoring\")\n        else:\n            print(\"⚠️ Cannot pause - recording not active\")\n\n    def resume_handler(signum, frame):\n        \"\"\"Handle SIGUSR2 to resume recording\"\"\"\n        nonlocal is_paused\n        print(f\"📨 Received SIGUSR2 signal (resume request)\")\n        print(f\"   recording_started={recording_started}, is_paused={is_paused}\")\n        if recording_started and recorder and recorder.audio_recorder:\n            if is_paused:\n                recorder.audio_recorder.resume_recording()\n                is_paused = False\n                print(\"▶️ Recording resumed successfully\")\n            else:\n                print(\"▶️ Not paused - ignoring\")\n        else:\n            print(\"⚠️ Cannot resume - recording not active\")\n\n    # Register pause/resume signal handlers (Unix only)\n    if sys.platform != 'win32':\n        try:\n            signal.signal(signal.SIGUSR1, pause_handler)\n            signal.signal(signal.SIGUSR2, resume_handler)\n        except (AttributeError, ValueError) as e:\n            print(f\"⚠️ Could not register pause/resume signals: {e}\")\n\n    def signal_handler(signum, frame):\n        \"\"\"Handle SIGTERM gracefully by stopping recording and processing\"\"\"\n        print(f\"\\n🛑 Received termination signal ({signum})\")\n        if recording_started and recorder:\n            print(\"⏹️ Stopping recording and starting processing pipeline...\")\n            try:\n                final_path = recorder.stop_recording()\n                if final_path:\n                    print(f\"✅ Recording saved: {final_path}\")\n                    \n                    # Check file size\n                    from pathlib import Path\n                    file_size = Path(final_path).stat().st_size\n                    print(f\"📏 File size: {file_size / 1024:.1f} KB\")\n                    \n                    if file_size >= 1000:  # At least 1KB of audio data\n                        print(\"🔄 Starting transcription and summarization pipeline...\")\n                        \n                        # Create new event loop for signal handler\n                        try:\n                            # Process recording synchronously in signal handler\n                            import asyncio\n                            if hasattr(asyncio, '_get_running_loop') and asyncio._get_running_loop():\n                                loop = asyncio._get_running_loop()\n                            else:\n                                loop = asyncio.new_event_loop()\n                                asyncio.set_event_loop(loop)\n                            \n                            # Load user notes if saved by Electron\n                            _notes_text = recorder._load_user_notes(session_name, recorder.output_dir)\n\n                            print(\"📝 Starting transcription...\")\n                            result = loop.run_until_complete(recorder.process_recording_streaming(final_path, session_name, notes_text=_notes_text))\n\n                            print(\"✅ Complete processing finished!\", flush=True)\n                            print(f\"📄 Transcript: {result['session_info']['transcript_file']}\")\n                            print(f\"📋 Summary: {result['session_info']['summary_file']}\")\n                            print(f\"📊 Meeting: {result['session_info']['name']}\")\n\n                        except Exception as e:\n                            print(f\"❌ Processing pipeline failed: {e}\", flush=True)\n                            import traceback\n                            traceback.print_exc()\n                    else:\n                        print(\"⚠️ Recording too short - skipping processing\")\n                else:\n                    print(\"❌ No recording data to save\")\n            except Exception as e:\n                print(f\"❌ Error during signal handling: {e}\")\n                import traceback\n                traceback.print_exc()\n        \n        print(\"🏁 Recording session ended - process complete\")\n        sys.exit(0)\n    \n    # Register signal handlers\n    signal.signal(signal.SIGTERM, signal_handler)\n    signal.signal(signal.SIGINT, signal_handler)\n    \n    try:\n        # Start recording\n        recording_path = recorder.start_recording(session_name)\n        recording_started = True\n        print(f\"📁 Recording to: {recording_path}\")\n        print(\"📢 Speak into your microphone now!\")\n        \n        # For very long durations (like 999999), just wait indefinitely until signal\n        if duration > 86400:  # More than a day\n            print(\"🔄 Recording indefinitely (until stopped)...\")\n            try:\n                while True:\n                    time.sleep(5)  # Check every 5 seconds\n            except KeyboardInterrupt:\n                signal_handler(signal.SIGINT, None)\n        else:\n            # Count down for normal durations (log every 10 minutes to reduce spam)\n            for i in range(duration, 0, -1):\n                if i % 600 == 0:  # Every 10 minutes\n                    print(f\"Recording... {i // 60} minutes remaining\")\n                time.sleep(1)\n        \n        # Normal completion (if not interrupted)\n        final_path = recorder.stop_recording()\n        if not final_path:\n            print(\"❌ Recording failed - no audio data collected\")\n            return\n            \n        print(f\"✅ Recording saved: {final_path}\")\n        \n        # Check file size\n        from pathlib import Path\n        file_size = Path(final_path).stat().st_size\n        print(f\"📏 File size: {file_size / 1024:.1f} KB\")\n        \n        if file_size < 1000:  # Less than 1KB indicates empty recording\n            print(\"⚠️ Recording appears to be empty - check microphone\")\n            return\n        \n        # Process recording\n        print(\"🔄 Processing recording (transcribe + summarize)...\")\n        \n        async def process_recording():\n            result = await recorder.process_recording(final_path, session_name)\n            print(\"✅ Processing complete!\")\n            print(f\"📄 Transcript: {result['session_info']['transcript_file']}\")  \n            print(f\"📋 Summary: {result['session_info']['summary_file']}\")\n            \n            # Show quick preview\n            if result.get('transcript'):\n                preview = result['transcript'][:200] + \"...\" if len(result['transcript']) > 200 else result['transcript']\n                print(f\"📝 Preview: {preview}\")\n        \n        asyncio.run(process_recording())\n        \n    except Exception as e:\n        print(f\"❌ Recording failed: {e}\")\n        import traceback\n        traceback.print_exc()\n        sys.exit(1)\n\n\n@cli.command()\ndef test():\n    \"\"\"Quick system test - check components can initialize\"\"\"\n    print(\"🧪 Quick system test...\")\n    \n    try:\n        # Test audio recording capability\n        print(\"🎤 Testing audio recording...\")\n        recorder = SimpleRecorder()\n        if not recorder.audio_recorder:\n            print(\"❌ Audio recording not available\")\n            print(\"ERROR: Audio dependencies missing\")\n            return\n        print(\"✅ Audio recording ready\")\n        \n        # Test transcriber availability\n        print(\"🗣️ Testing Whisper transcriber...\")\n        if not WhisperTranscriber:\n            print(\"❌ Whisper transcriber not available\")\n            print(\"ERROR: Whisper not installed\")\n            return\n            \n        try:\n            from src.config import get_config\n            transcriber = WhisperTranscriber(model_size=get_config().get_whisper_model())\n            print(\"✅ Whisper transcriber ready\")\n        except Exception as e:\n            print(f\"❌ Whisper initialization failed: {e}\")\n            print(f\"ERROR: {e}\")\n            return\n        \n        # Test Ollama availability (lightweight check)\n        print(\"🧠 Testing Ollama availability...\")\n        if not OllamaSummarizer:\n            print(\"❌ Ollama summarizer not available\")\n            print(\"ERROR: Ollama dependencies missing\")\n            return\n            \n        try:\n            # Just check if we can initialize without making API calls\n            summarizer = OllamaSummarizer()\n            print(\"✅ Ollama summarizer ready\")\n        except Exception as e:\n            print(f\"❌ Ollama initialization failed: {e}\")\n            print(f\"ERROR: {e}\")\n            return\n        \n        print(\"🎉 System check passed!\")\n        print(\"SUCCESS: All components are working correctly\")\n        \n    except Exception as e:\n        print(f\"❌ System test failed: {e}\")\n        print(f\"ERROR: {e}\")\n        return\n\n\ndef _parse_meeting_markdown(md_path):\n    \"\"\"Parse a .md meeting file into the standard meeting dict.\"\"\"\n    content = md_path.read_text(encoding='utf-8')\n\n    # Split frontmatter\n    meta = {}\n    body = content\n    if content.startswith('---'):\n        parts = content.split('---', 2)\n        if len(parts) >= 3:\n            for line in parts[1].strip().split('\\n'):\n                if ':' in line:\n                    key, _, value = line.partition(':')\n                    value = value.strip()\n                    if value.startswith('\"') and value.endswith('\"'):\n                        import re as _re\n                        value = _re.sub(r'\\\\(.)', lambda m: m.group(1), value[1:-1])\n                    elif value.startswith('['):\n                        try:\n                            value = json.loads(value)\n                        except (ValueError, TypeError):\n                            value = []\n                    elif value == 'null':\n                        value = None\n                    elif value == 'true':\n                        value = True\n                    elif value == 'false':\n                        value = False\n                    else:\n                        try:\n                            value = int(value)\n                        except (ValueError, TypeError):\n                            pass\n                    meta[key.strip()] = value\n            body = parts[2].strip()\n\n    # Parse markdown body into sections\n    sections = {}\n    current_section = None\n    current_lines = []\n\n    for line in body.split('\\n'):\n        if line.startswith('## '):\n            if current_section:\n                sections[current_section] = '\\n'.join(current_lines).strip()\n            current_section = line[3:].strip().lower()\n            current_lines = []\n        else:\n            current_lines.append(line)\n    if current_section:\n        sections[current_section] = '\\n'.join(current_lines).strip()\n\n    # Extract structured fields\n    participants = []\n    if 'participants' in sections:\n        participants = [p.strip() for p in sections['participants'].split(',') if p.strip()]\n\n    key_points = []\n    if 'key points' in sections:\n        for line in sections['key points'].split('\\n'):\n            line = line.strip()\n            if line.startswith('- '):\n                key_points.append(line[2:])\n\n    action_items = []\n    if 'action items' in sections:\n        for line in sections['action items'].split('\\n'):\n            line = line.strip()\n            if line.startswith('- '):\n                action_items.append(line[2:].replace('[ ] ', '').replace('[x] ', ''))\n\n    discussion_areas = []\n    if 'key topics' in sections:\n        current_topic = None\n        topic_lines = []\n        for line in sections['key topics'].split('\\n'):\n            if line.startswith('### '):\n                if current_topic:\n                    discussion_areas.append({\n                        'title': current_topic,\n                        'analysis': '\\n'.join(topic_lines).strip()\n                    })\n                current_topic = line[4:].strip()\n                topic_lines = []\n            else:\n                topic_lines.append(line)\n        if current_topic:\n            discussion_areas.append({\n                'title': current_topic,\n                'analysis': '\\n'.join(topic_lines).strip()\n            })\n\n    return {\n        'session_info': {\n            'name': meta.get('title', md_path.stem),\n            'processed_at': meta.get('date', ''),\n            'duration_seconds': meta.get('duration_seconds'),\n            'summary_file': str(md_path),\n            'output_language': meta.get('language'),\n        },\n        'summary': sections.get('summary', ''),\n        'participants': participants,\n        'discussion_areas': discussion_areas,\n        'key_points': key_points,\n        'action_items': action_items,\n        'transcript': sections.get('transcript', ''),\n        'is_diarised': meta.get('is_diarised', False),\n        'diarised_text': sections.get('transcript', '') if meta.get('is_diarised') else None,\n        'user_notes': sections.get('user notes'),\n        'folders': meta.get('folders', []),\n    }\n\n\n@cli.command()\ndef list_meetings():\n    \"\"\"List all processed meetings - optimized for fast loading\"\"\"\n    from src.config import get_data_dirs, get_config\n    dirs = get_data_dirs()\n    output_dir = dirs[\"output\"]\n\n    # Ensure output directory exists\n    output_dir.mkdir(parents=True, exist_ok=True)\n\n    # Collect summary files from current output dir (JSON preferred over MD)\n    seen_files = set()\n    seen_stems = set()\n    summaries = []\n    # JSON first — if both .json and .md exist, JSON wins (it has structured data)\n    for pattern in (\"*_summary.json\", \"*_summary.md\"):\n        for f in output_dir.glob(pattern):\n            stem = f.stem.replace('_summary', '')\n            if stem not in seen_stems:\n                summaries.append(f)\n                seen_files.add(f.resolve())\n                seen_stems.add(stem)\n\n    # Also scan the default location if a custom path is set,\n    # so meetings stored before the path change remain visible\n    custom = get_config().get_storage_path()\n    if custom:\n        if \"StenoAI.app\" in str(Path(__file__)) or \"Applications\" in str(Path(__file__)):\n            default_output = Path.home() / \"Library\" / \"Application Support\" / \"stenoai\" / \"output\"\n        else:\n            default_output = Path(__file__).parent / \"output\"\n        if default_output.exists():\n            for pattern in (\"*_summary.json\", \"*_summary.md\"):\n                for f in default_output.glob(pattern):\n                    stem = f.stem.replace('_summary', '')\n                    if f.resolve() not in seen_files and stem not in seen_stems:\n                        summaries.append(f)\n                        seen_files.add(f.resolve())\n                        seen_stems.add(stem)\n\n    meetings = []\n\n    # Single-pass: read each file once, extract sort key and data together\n    for summary_file in summaries:\n        try:\n            if summary_file.suffix == '.md':\n                essential_meeting = _parse_meeting_markdown(summary_file)\n                sort_key = essential_meeting.get('session_info', {}).get('processed_at', '')\n            else:\n                with open(summary_file, 'r', encoding='utf-8') as f:\n                    data = json.load(f)\n                    sort_key = data.get('session_info', {}).get('processed_at', '')\n                    essential_meeting = {\n                        \"session_info\": data.get(\"session_info\", {}),\n                        \"summary\": data.get(\"summary\", \"\"),\n                        \"participants\": data.get(\"participants\", []),\n                        \"discussion_areas\": data.get(\"discussion_areas\", []),\n                        \"key_points\": data.get(\"key_points\", []),\n                        \"action_items\": data.get(\"action_items\", []),\n                        \"transcript\": data.get(\"transcript\", \"\"),\n                        \"is_diarised\": data.get(\"is_diarised\", False),\n                        \"diarised_text\": data.get(\"diarised_text\"),\n                        \"folders\": data.get(\"folders\", [])\n                    }\n            meetings.append((sort_key, essential_meeting))\n        except Exception as e:\n            logger.warning(f\"Failed to load {summary_file}: {e}\")\n            continue\n\n    meetings.sort(key=lambda x: x[0], reverse=True)\n    meetings = [m for _, m in meetings]\n    \n    # Output as compact JSON for Electron (no indentation for speed)\n    print(json.dumps(meetings, separators=(',', ':')))\n\n\n@cli.command()\n@click.argument('summary_file', required=True)\n@click.option('--regenerate-title', is_flag=True, default=False, help='Also regenerate the meeting title')\ndef reprocess(summary_file, regenerate_title):\n    \"\"\"Reprocess a failed summary by re-running Ollama analysis on existing transcript\"\"\"\n    import json\n    from pathlib import Path\n\n    import base64\n\n    recorder = SimpleRecorder()\n    summary_path = Path(summary_file)\n\n    if not summary_path.exists():\n        print(f\"ERROR: Summary file not found: {summary_file}\")\n        sys.exit(1)\n\n    try:\n        # Load existing summary file (JSON or MD)\n        if summary_path.suffix == '.md':\n            existing_data = _parse_meeting_markdown(summary_path)\n        else:\n            with open(summary_path, 'r') as f:\n                existing_data = json.load(f)\n\n        # Get transcript from the data\n        transcript = existing_data.get('transcript', '')\n        if not transcript:\n            print(\"ERROR: No transcript found in summary file\")\n            sys.exit(1)\n\n        session_name = existing_data.get('session_info', {}).get('name', 'Reprocessed')\n        duration_minutes = existing_data.get('session_info', {}).get('duration_minutes', 10)\n        if duration_minutes is None:\n            ds = existing_data.get('session_info', {}).get('duration_seconds')\n            duration_minutes = int(ds / 60) if ds else 10\n\n        # Load user notes from the meeting data\n        notes_text = existing_data.get('user_notes')\n\n        print(f\"Reprocessing summary for: {session_name}\")\n        print(f\"Transcript length: {len(transcript)} characters\")\n        if notes_text:\n            print(f\"User notes: {len(notes_text)} characters\")\n\n        # Resolve output language\n        existing_session_info = existing_data.get(\"session_info\", {})\n        output_language = existing_session_info.get(\"output_language\")\n        if not output_language:\n            configured_language = existing_session_info.get(\"configured_language\")\n            if not configured_language:\n                from src.config import get_config\n                configured_language = get_config().get_language()\n            output_language = recorder._resolve_output_language(\n                configured_language,\n                existing_session_info.get(\"detected_language\")\n            )\n\n        # Use streaming summarization (same as new recordings)\n        if recorder.summarizer is None:\n            from src.summarizer import OllamaSummarizer\n            recorder.summarizer = OllamaSummarizer()\n\n        print(\"Generating summary...\", flush=True)\n        streamed_chunks = []\n        for chunk in recorder.summarizer.summarize_transcript_streaming(\n            transcript, duration_minutes, output_language, notes_text\n        ):\n            encoded = base64.b64encode(chunk.encode('utf-8')).decode('ascii')\n            sys.stdout.write(f\"CHUNK:{encoded}\\n\")\n            sys.stdout.flush()\n            streamed_chunks.append(chunk)\n        streamed_md = ''.join(streamed_chunks)\n\n        print(\"STREAM_COMPLETE\", flush=True)\n\n        # Regenerate title if requested\n        if regenerate_title:\n            try:\n                generated_title = recorder.summarizer.generate_title(\n                    streamed_md, transcript, language=output_language\n                )\n                if generated_title:\n                    session_name = generated_title\n                    existing_data[\"session_info\"][\"name\"] = generated_title\n                    print(f\"TITLE:{session_name}\", flush=True)\n                    print(f\"Auto-generated title: {session_name}\")\n            except Exception as e:\n                print(f\"Title regeneration skipped: {e}\")\n\n        # Add reprocess timestamp\n        existing_data[\"session_info\"][\"reprocessed_at\"] = datetime.now().isoformat()\n\n        # Save updated summary\n        if summary_path.suffix == '.md':\n            session_name = existing_data.get('session_info', {}).get('name', 'Reprocessed')\n            md_lines = ['---']\n            md_meta = {\n                'title': session_name,\n                'date': existing_data.get('session_info', {}).get('processed_at', datetime.now().isoformat()),\n                'duration_seconds': existing_data.get('session_info', {}).get('duration_seconds'),\n                'language': output_language,\n                'is_diarised': existing_data.get('is_diarised', False),\n            }\n            for k, v in md_meta.items():\n                if v is None:\n                    md_lines.append(f'{k}: null')\n                elif isinstance(v, bool):\n                    md_lines.append(f'{k}: {\"true\" if v else \"false\"}')\n                elif isinstance(v, int):\n                    md_lines.append(f'{k}: {v}')\n                else:\n                    escaped = str(v).replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n                    md_lines.append(f'{k}: \"{escaped}\"')\n            md_lines.append('---')\n            md_lines.append('')\n            # Write the raw streamed markdown (preserves LLM formatting)\n            md_lines.append(streamed_md)\n            md_lines.append('')\n            md_lines.append('## Transcript')\n            md_lines.append('')\n            md_lines.append(transcript)\n            if notes_text:\n                md_lines.append('')\n                md_lines.append('## User Notes')\n                md_lines.append('')\n                md_lines.append(notes_text)\n            summary_path.write_text('\\n'.join(md_lines), encoding='utf-8')\n        else:\n            # JSON format: parse streamed markdown into structured fields\n            parsed = recorder._parse_streamed_markdown(streamed_md)\n            existing_data.update({\n                \"summary\": parsed.get(\"summary\", \"\") or \"\",\n                \"participants\": parsed.get(\"participants\", []) or [],\n                \"discussion_areas\": parsed.get(\"discussion_areas\", []) or [],\n                \"key_points\": parsed.get(\"key_points\", []) or [],\n                \"action_items\": parsed.get(\"action_items\", []) or [],\n            })\n            with open(summary_path, 'w') as f:\n                json.dump(existing_data, f, indent=2)\n\n        print(f\"Summary reprocessed successfully: {summary_path}\")\n\n    except Exception as e:\n        print(f\"ERROR: Failed to reprocess summary: {e}\")\n        sys.exit(1)\n\n\n@cli.command('regen-title')\n@click.argument('summary_file', required=True)\ndef regen_title(summary_file):\n    \"\"\"Regenerate only the title for an existing meeting.\"\"\"\n    import json\n    from pathlib import Path\n\n    recorder = SimpleRecorder()\n    summary_path = Path(summary_file)\n\n    if not summary_path.exists():\n        print(f\"ERROR: Summary file not found: {summary_file}\")\n        sys.exit(1)\n\n    try:\n        if summary_path.suffix == '.md':\n            existing_data = _parse_meeting_markdown(summary_path)\n        else:\n            with open(summary_path, 'r') as f:\n                existing_data = json.load(f)\n\n        transcript = existing_data.get('transcript', '')\n        summary = existing_data.get('summary', '')\n        session_info = existing_data.get('session_info', {})\n        output_language = session_info.get('output_language') or session_info.get('configured_language') or 'en'\n\n        if not transcript and not summary:\n            print(\"ERROR: No transcript or summary found in file\")\n            sys.exit(1)\n\n        if recorder.summarizer is None:\n            from src.summarizer import OllamaSummarizer\n            recorder.summarizer = OllamaSummarizer()\n\n        generated_title = recorder.summarizer.generate_title(summary, transcript, language=output_language)\n        if not generated_title:\n            print(\"ERROR: Title generation returned empty result\")\n            sys.exit(1)\n\n        # Update and save\n        existing_data['session_info']['name'] = generated_title\n        if summary_path.suffix == '.md':\n            # Rewrite the title in the YAML front matter only\n            text = summary_path.read_text(encoding='utf-8')\n            import re\n            escaped = generated_title.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n            text = re.sub(r'^title:.*$', f'title: \"{escaped}\"', text, flags=re.MULTILINE)\n            summary_path.write_text(text, encoding='utf-8')\n        else:\n            with open(summary_path, 'w') as f:\n                json.dump(existing_data, f, indent=2)\n\n        print(f\"TITLE:{generated_title}\", flush=True)\n        print(f\"Title updated: {generated_title}\")\n\n    except Exception as e:\n        print(f\"ERROR: Failed to regenerate title: {e}\")\n        sys.exit(1)\n\n\n@cli.command()\n@click.argument('transcript_file')\n@click.option('--question', '-q', required=True, help='Question to ask about the transcript')\ndef query(transcript_file, question):\n    \"\"\"Query a transcript with AI.\"\"\"\n    from pathlib import Path\n\n    transcript_path = Path(transcript_file)\n    language = None\n\n    # Handle summary JSON files (extract transcript from them)\n    if transcript_file.endswith('.json'):\n        if not transcript_path.exists():\n            print(json.dumps({\"success\": False, \"error\": f\"File not found: {transcript_file}\"}))\n            return\n\n        try:\n            with open(transcript_path, 'r', encoding='utf-8') as f:\n                data = json.load(f)\n                transcript_text = data.get('transcript', '')\n                if not transcript_text:\n                    print(json.dumps({\"success\": False, \"error\": \"No transcript found in summary file\"}))\n                    return\n                session_info = data.get(\"session_info\", {})\n                language = session_info.get(\"output_language\")\n        except Exception as e:\n            print(json.dumps({\"success\": False, \"error\": f\"Failed to read summary file: {e}\"}))\n            return\n    elif transcript_file.endswith('.md'):\n        # Handle markdown summary files — parse sections\n        if not transcript_path.exists():\n            print(json.dumps({\"success\": False, \"error\": f\"File not found: {transcript_file}\"}))\n            return\n\n        try:\n            meeting_data = _parse_meeting_markdown(transcript_path)\n            raw_transcript = meeting_data.get('transcript', '')\n            # Build rich context: summary + key points + transcript\n            parts = []\n            if meeting_data.get('summary'):\n                parts.append(f\"SUMMARY:\\n{meeting_data['summary']}\")\n            if meeting_data.get('discussion_areas'):\n                topics = '\\n'.join(f\"- {d['title']}: {d['analysis']}\" for d in meeting_data['discussion_areas'])\n                parts.append(f\"KEY TOPICS:\\n{topics}\")\n            if meeting_data.get('key_points'):\n                points = '\\n'.join(f\"- {p}\" for p in meeting_data['key_points'])\n                parts.append(f\"KEY POINTS:\\n{points}\")\n            if raw_transcript:\n                parts.append(f\"TRANSCRIPT:\\n{raw_transcript}\")\n            transcript_text = '\\n\\n'.join(parts)\n            session_info = meeting_data.get(\"session_info\", {})\n            language = session_info.get(\"output_language\")\n        except Exception as e:\n            print(json.dumps({\"success\": False, \"error\": f\"Failed to read summary file: {e}\"}))\n            return\n    else:\n        # Handle plain text transcript files\n        if not transcript_path.exists():\n            print(json.dumps({\"success\": False, \"error\": f\"File not found: {transcript_file}\"}))\n            return\n\n        try:\n            with open(transcript_path, 'r', encoding='utf-8') as f:\n                transcript_text = f.read()\n        except Exception as e:\n            print(json.dumps({\"success\": False, \"error\": f\"Failed to read transcript: {e}\"}))\n            return\n\n    if not transcript_text or transcript_text.strip() == \"\":\n        print(json.dumps({\"success\": False, \"error\": \"Transcript is empty\"}))\n        return\n\n    # Use the user's configured model for all providers\n    try:\n        from src.config import get_config\n        config = get_config()\n        if not language:\n            language = config.get_language()\n        if language == \"auto\":\n            language = \"en\"\n        summarizer = OllamaSummarizer()\n        answer = summarizer.query_transcript(transcript_text, question, language=language)\n\n        if answer:\n            print(json.dumps({\"success\": True, \"answer\": answer}))\n        else:\n            print(json.dumps({\"success\": False, \"error\": \"Failed to get response from AI\"}))\n    except Exception as e:\n        print(json.dumps({\"success\": False, \"error\": f\"Query failed: {e}\"}))\n\n\n@cli.command(name='query-streaming')\n@click.argument('transcript_file')\n@click.option('--question', '-q', required=True, help='Question to ask about the transcript')\ndef query_streaming(transcript_file, question):\n    \"\"\"Query a transcript with streaming output. Emits CHUNK:base64 lines then STREAM_COMPLETE.\"\"\"\n    import sys\n    import base64\n    from pathlib import Path\n\n    transcript_path = Path(transcript_file)\n    language = None\n\n    if transcript_file.endswith('.json'):\n        if not transcript_path.exists():\n            print(f\"STREAM_ERROR:File not found: {transcript_file}\", flush=True)\n            return\n        try:\n            with open(transcript_path, 'r', encoding='utf-8') as f:\n                data = json.load(f)\n                transcript_text = data.get('transcript', '')\n                if not transcript_text:\n                    print(\"STREAM_ERROR:No transcript found in summary file\", flush=True)\n                    return\n                session_info = data.get(\"session_info\", {})\n                language = session_info.get(\"output_language\")\n        except Exception as e:\n            print(f\"STREAM_ERROR:Failed to read file: {e}\", flush=True)\n            return\n    elif transcript_file.endswith('.md'):\n        if not transcript_path.exists():\n            print(f\"STREAM_ERROR:File not found: {transcript_file}\", flush=True)\n            return\n        try:\n            meeting_data = _parse_meeting_markdown(transcript_path)\n            parts = []\n            if meeting_data.get('summary'):\n                parts.append(f\"SUMMARY:\\n{meeting_data['summary']}\")\n            if meeting_data.get('discussion_areas'):\n                topics = '\\n'.join(f\"- {d['title']}: {d['analysis']}\" for d in meeting_data['discussion_areas'])\n                parts.append(f\"KEY TOPICS:\\n{topics}\")\n            if meeting_data.get('key_points'):\n                points = '\\n'.join(f\"- {p}\" for p in meeting_data['key_points'])\n                parts.append(f\"KEY POINTS:\\n{points}\")\n            if meeting_data.get('transcript'):\n                parts.append(f\"TRANSCRIPT:\\n{meeting_data['transcript']}\")\n            transcript_text = '\\n\\n'.join(parts)\n            session_info = meeting_data.get(\"session_info\", {})\n            language = session_info.get(\"output_language\")\n        except Exception as e:\n            print(f\"STREAM_ERROR:Failed to read file: {e}\", flush=True)\n            return\n    else:\n        if not transcript_path.exists():\n            print(f\"STREAM_ERROR:File not found: {transcript_file}\", flush=True)\n            return\n        try:\n            transcript_text = transcript_path.read_text(encoding='utf-8')\n        except Exception as e:\n            print(f\"STREAM_ERROR:Failed to read file: {e}\", flush=True)\n            return\n\n    if not language:\n        from src.config import get_config\n        language = get_config().get_language()\n    if language == \"auto\":\n        language = \"en\"\n\n    try:\n        summarizer = OllamaSummarizer()\n        for chunk in summarizer.query_transcript_streaming(transcript_text, question, language=language):\n            encoded = base64.b64encode(chunk.encode('utf-8')).decode('ascii')\n            sys.stdout.write(f\"CHAT_CHUNK:{encoded}\\n\")\n            sys.stdout.flush()\n        print(\"CHAT_STREAM_COMPLETE\", flush=True)\n    except Exception as e:\n        print(f\"CHAT_STREAM_ERROR:{e}\", flush=True)\n\n\n@cli.command(name='chat-global-streaming')\n@click.option('--question', '-q', required=True, help='Question to ask across notes')\n@click.option('--folder', '-f', default=None, help='Folder ID to scope the corpus to (default: all notes)')\ndef chat_global_streaming(question, folder):\n    \"\"\"Cross-note chat: gather meeting title + summary + key points, feed as\n    context to the cloud LLM, stream the answer. Optionally scope to a single\n    folder; default queries every note.\n\n    Cloud-only. Local models can't fit a full corpus of summaries reliably,\n    and we don't have retrieval (RAG) yet — caller (main.js) is responsible\n    for gating this on ai_provider === 'cloud'.\"\"\"\n    import sys\n    import base64\n    from pathlib import Path\n    from src.config import get_config, get_data_dirs\n\n    config = get_config()\n    if config.get_ai_provider() != \"cloud\":\n        print(\"CHAT_STREAM_ERROR:Cross-note chat requires a cloud AI provider. Switch in Settings → AI.\", flush=True)\n        return\n\n    dirs = get_data_dirs()\n    output_dir = dirs[\"output\"]\n\n    # Collect every summary file, preferring .md (the new format) but reading\n    # legacy .json too so users with old recordings aren't excluded.\n    summaries: list[tuple[Path, dict]] = []\n    seen = set()\n    for f in sorted(output_dir.glob(\"*_summary.md\")):\n        try:\n            data = _parse_meeting_markdown(f)\n            summaries.append((f, data))\n            seen.add(f.stem.replace('_summary', ''))\n        except Exception:\n            continue\n    for f in sorted(output_dir.glob(\"*_summary.json\")):\n        if f.stem.replace('_summary', '') in seen:\n            continue\n        try:\n            with open(f, 'r', encoding='utf-8') as fh:\n                summaries.append((f, json.load(fh)))\n        except Exception:\n            continue\n\n    # Folder scoping. Each meeting record carries a 'folders' array of IDs;\n    # filter to only those that include the requested ID. Empty folder ID\n    # or 'all' explicitly means no filter.\n    if folder and folder != 'all':\n        summaries = [\n            (path, data) for (path, data) in summaries\n            if isinstance(data.get('folders'), list) and folder in data['folders']\n        ]\n\n    if not summaries:\n        if folder and folder != 'all':\n            print(\"CHAT_STREAM_ERROR:No notes in this folder yet. Pick another or remove the filter.\", flush=True)\n        else:\n            print(\"CHAT_STREAM_ERROR:No notes found yet. Record a meeting first.\", flush=True)\n        return\n\n    # Most-recent first so the model weights newer context higher when token\n    # budget is tight. Each block is kept compact (title + summary + key\n    # points + action items) — full transcripts would blow even a 200k window.\n    def sort_key(item):\n        _, data = item\n        return data.get(\"session_info\", {}).get(\"processed_at\") or \"\"\n\n    summaries.sort(key=sort_key, reverse=True)\n\n    # Cap the assembled corpus so a user with hundreds of meetings can't blow\n    # past the cloud model's context window. ~400k chars ≈ 100k tokens, well\n    # under both Anthropic (200k) and recent OpenAI (128k+) limits while\n    # leaving headroom for the prompt scaffold and the model's reply.\n    CORPUS_CHAR_BUDGET = 400_000\n    blocks = []\n    used_chars = 0\n    truncated = 0\n    for _, data in summaries:\n        info = data.get(\"session_info\", {}) or {}\n        name = info.get(\"name\") or \"Untitled\"\n        date = (info.get(\"processed_at\") or \"\")[:10]\n        summary = (data.get(\"summary\") or \"\").strip()\n        key_points = data.get(\"key_points\") or []\n        action_items = data.get(\"action_items\") or []\n        block = [f\"## {name}\" + (f\" — {date}\" if date else \"\")]\n        if summary:\n            block.append(summary)\n        if key_points:\n            block.append(\"Key points:\\n\" + \"\\n\".join(f\"- {p}\" for p in key_points))\n        if action_items:\n            block.append(\"Action items:\\n\" + \"\\n\".join(f\"- {a}\" for a in action_items))\n        block_text = \"\\n\".join(block)\n        # +5 accounts for the \"\\n\\n---\\n\\n\" separator added later.\n        if used_chars + len(block_text) + 5 > CORPUS_CHAR_BUDGET:\n            # If the very first block is already larger than the budget,\n            # truncate it so we still send something representative rather\n            # than blasting the model with an oversized prompt.\n            if not blocks:\n                budget_left = max(0, CORPUS_CHAR_BUDGET - used_chars - 80)\n                if budget_left > 0:\n                    truncated_block = block_text[:budget_left].rstrip() + \"\\n…(truncated)\"\n                    blocks.append(truncated_block)\n                    used_chars += len(truncated_block) + 5\n            truncated = len(summaries) - len(blocks)\n            break\n        blocks.append(block_text)\n        used_chars += len(block_text) + 5\n\n    corpus = \"\\n\\n---\\n\\n\".join(blocks)\n    if truncated:\n        corpus += (\n            f\"\\n\\n---\\n\\n_Note: {truncated} older note(s) omitted to stay within\"\n            \" the model's context window. Ask about a specific older meeting\"\n            \" to pull it in directly._\"\n        )\n\n    language = config.get_language()\n    if language == \"auto\":\n        language = \"en\"\n\n    try:\n        summarizer = OllamaSummarizer()\n        for chunk in summarizer.query_transcript_streaming(corpus, question, language=language):\n            encoded = base64.b64encode(chunk.encode('utf-8')).decode('ascii')\n            sys.stdout.write(f\"CHAT_CHUNK:{encoded}\\n\")\n            sys.stdout.flush()\n        print(\"CHAT_STREAM_COMPLETE\", flush=True)\n    except Exception as e:\n        print(f\"CHAT_STREAM_ERROR:{e}\", flush=True)\n\n\n@cli.command()\ndef list_failed():\n    \"\"\"List summary files that failed processing (have fallback summaries)\"\"\"\n    import json\n    from src.config import get_data_dirs, get_config\n    dirs = get_data_dirs()\n    output_dir = dirs[\"output\"]\n\n    # Collect from current and default locations\n    seen_files = set()\n    summaries = []\n    for f in output_dir.glob(\"*_summary.json\"):\n        summaries.append(f)\n        seen_files.add(f.resolve())\n    custom = get_config().get_storage_path()\n    if custom:\n        if \"StenoAI.app\" in str(Path(__file__)) or \"Applications\" in str(Path(__file__)):\n            default_output = Path.home() / \"Library\" / \"Application Support\" / \"stenoai\" / \"output\"\n        else:\n            default_output = Path(__file__).parent / \"output\"\n        if default_output.exists():\n            for f in default_output.glob(\"*_summary.json\"):\n                if f.resolve() not in seen_files:\n                    summaries.append(f)\n\n    failed_summaries = []\n    \n    for summary_file in summaries:\n        try:\n            with open(summary_file, 'r', encoding='utf-8') as f:\n                data = json.load(f)\n                \n                # Check for signs of failed processing\n                summary_text = data.get(\"summary\", \"\")\n                if (summary_text.startswith(\"Meeting transcript recorded but detailed analysis failed\") or \n                    summary_text.startswith(\"No transcript was generated\") or\n                    len(data.get(\"participants\", [])) == 0 and len(data.get(\"key_points\", [])) == 0):\n                    failed_summaries.append({\n                        \"file\": str(summary_file),\n                        \"name\": data.get(\"session_info\", {}).get(\"name\", \"Unknown\"),\n                        \"processed_at\": data.get(\"session_info\", {}).get(\"processed_at\", \"Unknown\"),\n                        \"summary\": summary_text[:100] + \"...\" if len(summary_text) > 100 else summary_text\n                    })\n        except Exception as e:\n            continue\n    \n    if failed_summaries:\n        print(\"🔍 Failed Summaries Found:\")\n        print(\"=\" * 50)\n        for failed in failed_summaries:\n            print(f\"📁 File: {failed['file']}\")\n            print(f\"📊 Name: {failed['name']}\")\n            print(f\"🕐 Processed: {failed['processed_at']}\")\n            print(f\"📝 Summary: {failed['summary']}\")\n            print(f\"🔄 Reprocess: python simple_recorder.py reprocess \\\"{failed['file']}\\\"\")\n            print(\"-\" * 50)\n        print(f\"Total failed summaries: {len(failed_summaries)}\")\n    else:\n        print(\"✅ No failed summaries found - all processing completed successfully!\")\n\n\n@cli.command()\ndef clear_state():\n    \"\"\"Clear recording state (useful for resetting stuck recordings)\"\"\"\n    recorder = SimpleRecorder()\n    \n    if recorder.state_file.exists():\n        recorder.state_file.unlink()\n        print(\"SUCCESS: Recording state cleared\")\n    else:\n        print(\"SUCCESS: No state file found - already clear\")\n\n\n@cli.command()\ndef setup_check():\n    \"\"\"Check system setup and dependencies\"\"\"\n    import subprocess\n    import sys\n    import os\n    \n    print(\"🔧 StenoAI Setup Check\")\n    print(\"=\" * 25)\n    \n    checks = []\n    \n    # Check Python version\n    try:\n        version = sys.version_info\n        if version.major >= 3 and version.minor >= 8:\n            checks.append((\"✅ Python\", f\"{version.major}.{version.minor}.{version.micro}\"))\n        else:\n            checks.append((\"❌ Python\", f\"{version.major}.{version.minor}.{version.micro} (need 3.8+)\"))\n    except Exception as e:\n        checks.append((\"❌ Python\", f\"Error: {e}\"))\n    \n    # Check required directories - uses centralised get_data_dirs()\n    from src.config import get_data_dirs\n    base_dirs = get_data_dirs()\n    \n    for dir_name, dir_path in base_dirs.items():\n        if dir_path.exists():\n            checks.append((f\"✅ {dir_name}/\", f\"exists at {dir_path}\"))\n        else:\n            dir_path.mkdir(parents=True, exist_ok=True)\n            checks.append((f\"✅ {dir_name}/\", f\"created at {dir_path}\"))\n    \n    # Check Ollama - use bundled or system Ollama\n    try:\n        from src.ollama_manager import get_ollama_binary\n        ollama_path = get_ollama_binary()\n        if ollama_path:\n            if 'bin/ollama' in str(ollama_path) or '_internal/ollama' in str(ollama_path):\n                checks.append((\"✅ Ollama\", \"bundled\"))\n            else:\n                checks.append((\"✅ Ollama\", f\"found at {ollama_path}\"))\n        else:\n            checks.append((\"❌ Ollama\", \"not found\"))\n    except Exception as e:\n        checks.append((\"❌ Ollama\", f\"Error: {e}\"))\n    \n    # Check ffmpeg (bundled locations first, then system)\n    try:\n        ffmpeg_found = False\n        possible_ffmpeg_paths = []\n\n        # Check bundled ffmpeg (PyInstaller bundle)\n        if getattr(sys, 'frozen', False):\n            exe_dir = Path(sys.executable).parent\n            for candidate in [\n                exe_dir / 'ffmpeg',                    # bundle root (stenoai.spec places it at '.')\n                exe_dir / '_internal' / 'ffmpeg',      # _internal subdirectory\n            ]:\n                if candidate.exists():\n                    possible_ffmpeg_paths.append(('bundled', str(candidate)))\n\n        possible_ffmpeg_paths.extend([\n            (None, 'ffmpeg'),                          # PATH\n            (None, '/opt/homebrew/bin/ffmpeg'),         # Homebrew Apple Silicon\n            (None, '/usr/local/bin/ffmpeg'),            # Homebrew Intel\n            (None, '/usr/bin/ffmpeg'),                  # System\n        ])\n\n        for label, path in possible_ffmpeg_paths:\n            try:\n                result = subprocess.run([path, '-version'],\n                                      capture_output=True, timeout=5)\n                if result.returncode == 0:\n                    checks.append((\"✅ ffmpeg\", label or f\"found at {path}\"))\n                    ffmpeg_found = True\n                    break\n            except (subprocess.TimeoutExpired, FileNotFoundError):\n                continue\n\n        if not ffmpeg_found:\n            checks.append((\"❌ ffmpeg\", \"not found - run: brew install ffmpeg\"))\n    except Exception as e:\n        checks.append((\"❌ ffmpeg\", f\"Error: {e}\"))\n    \n    # Skip Ollama model check during setup - service starts automatically when needed\n    # Just verify Ollama binary is installed\n    # The model will be downloaded during setup if needed\n    \n    # Check Python dependencies\n    try:\n        import sounddevice\n        checks.append((\"✅ sounddevice\", \"audio recording\"))\n    except ImportError:\n        checks.append((\"❌ sounddevice\", \"pip install sounddevice\"))\n    \n    # Check for whisper backend (prefer pywhispercpp, fallback to openai-whisper)\n    whisper_found = False\n    try:\n        import pywhispercpp\n        checks.append((\"✅ whisper\", \"pywhispercpp (fast)\"))\n        whisper_found = True\n    except ImportError:\n        pass\n\n    if not whisper_found:\n        try:\n            import whisper\n            checks.append((\"✅ whisper\", \"openai-whisper\"))\n            whisper_found = True\n        except ImportError:\n            pass\n\n    if not whisper_found:\n        checks.append((\"❌ whisper\", \"pip install pywhispercpp\"))\n    \n    try:\n        import ollama\n        checks.append((\"✅ ollama-python\", \"LLM client\"))\n    except ImportError:\n        checks.append((\"❌ ollama-python\", \"pip install ollama\"))\n\n    # Check if whisper model is downloaded (pywhispercpp stores in ~/Library/Application Support/pywhispercpp/models/)\n    whisper_model_path = Path.home() / \"Library\" / \"Application Support\" / \"pywhispercpp\" / \"models\"\n    whisper_models = list(whisper_model_path.glob(\"ggml-*.bin\")) if whisper_model_path.exists() else []\n    if whisper_models:\n        model_name = whisper_models[0].stem.replace(\"ggml-\", \"\")\n        checks.append((\"✅ whisper-model\", f\"{model_name} downloaded\"))\n    else:\n        checks.append((\"⚠️ whisper-model\", \"will download on first use (~500MB)\"))\n\n    # Check if LLM model is downloaded (check ~/.ollama/models/)\n    ollama_models_path = Path.home() / \".ollama\" / \"models\" / \"manifests\" / \"registry.ollama.ai\" / \"library\"\n    if ollama_models_path.exists() and any(ollama_models_path.iterdir()):\n        model_names = [d.name for d in ollama_models_path.iterdir() if d.is_dir()]\n        checks.append((\"✅ llm-model\", \", \".join(model_names[:2])))\n    else:\n        checks.append((\"❌ llm-model\", \"no model installed - needed for summaries\"))\n\n    # Print results\n    all_good = True\n    for status, detail in checks:\n        print(f\"{status:<20} {detail}\")\n        if status.startswith(\"❌\"):\n            all_good = False\n    \n    print(\"\\n\" + \"=\" * 25)\n    if all_good:\n        print(\"🎉 System check passed! Ready to record meetings.\")\n    else:\n        print(\"⚠️ Setup incomplete. Please install missing dependencies.\")\n    \n    return {\"success\": all_good, \"checks\": checks}\n\n\n@cli.command()\ndef list_models():\n    \"\"\"List all supported models with metadata\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    provider = config.get_ai_provider()\n    current_model = config.get_model()\n\n    if provider == \"remote\":\n        remote_url = config.get_remote_ollama_url()\n        if not remote_url:\n            result = {\n                \"current_model\": current_model,\n                \"supported_models\": {},\n                \"provider\": \"remote\",\n                \"error\": \"No remote Ollama URL configured\"\n            }\n            print(json.dumps(result, indent=2))\n            return\n\n        try:\n            import ollama as ollama_pkg\n            client = ollama_pkg.Client(host=remote_url)\n            response = client.list()\n            raw_models = getattr(response, 'models', []) or []\n            models = {}\n            for m in raw_models:\n                name = getattr(m, 'model', '')\n                if not name:\n                    continue\n                # Extract human-readable size\n                size_bytes = getattr(m, 'size', 0) or 0\n                if size_bytes >= 1_000_000_000:\n                    size_str = f\"{size_bytes / 1_000_000_000:.1f}GB\"\n                elif size_bytes >= 1_000_000:\n                    size_str = f\"{size_bytes / 1_000_000:.0f}MB\"\n                else:\n                    size_str = f\"{size_bytes}B\"\n\n                # Extract details string\n                details = getattr(m, 'details', None)\n                detail_parts = []\n                if details:\n                    family = getattr(details, 'family', '') or ''\n                    param_size = getattr(details, 'parameter_size', '') or ''\n                    quant = getattr(details, 'quantization_level', '') or ''\n                    if family:\n                        detail_parts.append(family)\n                    if param_size:\n                        detail_parts.append(param_size)\n                    if quant:\n                        detail_parts.append(quant)\n\n                models[name] = {\n                    \"size\": size_str,\n                    \"description\": \" / \".join(detail_parts) if detail_parts else \"\",\n                    \"installed\": True\n                }\n\n            result = {\n                \"current_model\": current_model,\n                \"supported_models\": models,\n                \"provider\": \"remote\"\n            }\n        except Exception as e:\n            error_msg = \"Could not connect to remote Ollama server\"\n            if \"Connection refused\" in str(e) or \"ConnectError\" in str(e):\n                error_msg = \"Remote Ollama server is not reachable\"\n            elif \"timed out\" in str(e).lower() or \"Timeout\" in str(e):\n                error_msg = \"Remote Ollama server timed out\"\n            result = {\n                \"current_model\": current_model,\n                \"supported_models\": {},\n                \"provider\": \"remote\",\n                \"error\": error_msg\n            }\n    else:\n        models = config.list_supported_models()\n        try:\n            import ollama as ollama_pkg\n            installed_names = {getattr(m, 'model', '') for m in (getattr(ollama_pkg.list(), 'models', []) or [])}\n        except Exception:\n            installed_names = set()\n        for model_id, info in models.items():\n            # Match exactly, or where Ollama appended extra detail after the tag\n            # e.g. \"deepseek-r1:14b\" matches \"deepseek-r1:14b-qwen-distill-q4_K_M\"\n            info['installed'] = any(\n                name == model_id or name.startswith(model_id + '-')\n                for name in installed_names\n            )\n        result = {\n            \"current_model\": current_model,\n            \"supported_models\": models,\n            \"provider\": \"local\"\n        }\n\n    print(json.dumps(result, indent=2))\n\n\n@cli.command()\ndef get_model():\n    \"\"\"Get the currently configured model\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    current_model = config.get_model()\n    model_info = config.get_model_info(current_model)\n\n    result = {\n        \"model\": current_model,\n        \"info\": model_info\n    }\n\n    print(json.dumps(result, indent=2))\n\n\n@cli.command()\n@click.argument('model_name')\ndef set_model(model_name):\n    \"\"\"Set the preferred model for summarization\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n\n    # Validate model\n    if model_name not in config.SUPPORTED_MODELS:\n        print(f\"WARNING: Model '{model_name}' is not in the recommended list.\")\n        print(f\"Supported models: {', '.join(config.SUPPORTED_MODELS.keys())}\")\n        print(f\"Setting anyway (make sure it's installed with 'ollama pull {model_name}')\")\n\n    success = config.set_model(model_name)\n\n    if success:\n        print(f\"SUCCESS: Model set to {model_name}\")\n        print(json.dumps({\"success\": True, \"model\": model_name}))\n    else:\n        print(f\"ERROR: Failed to save model configuration\")\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save config\"}))\n\n\n@cli.command()\ndef get_notifications():\n    \"\"\"Get the current notification preference\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    enabled = config.get_notifications_enabled()\n\n    result = {\n        \"notifications_enabled\": enabled\n    }\n\n    print(json.dumps(result, indent=2))\n\n\n@cli.command()\n@click.argument('enabled', type=bool)\ndef set_notifications(enabled):\n    \"\"\"Set notification preference (True/False)\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    success = config.set_notifications_enabled(enabled)\n\n    if success:\n        print(f\"SUCCESS: Notifications {'enabled' if enabled else 'disabled'}\")\n        print(json.dumps({\"success\": True, \"notifications_enabled\": enabled}))\n    else:\n        print(f\"ERROR: Failed to save notification preference\")\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save config\"}))\n\n\n@cli.command()\ndef get_dock_icon():\n    \"\"\"Get the current hide-dock-icon preference\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    enabled = config.get_hide_dock_icon()\n\n    print(json.dumps({\"hide_dock_icon\": enabled}))\n\n\n@cli.command()\n@click.argument('enabled', type=bool)\ndef set_dock_icon(enabled):\n    \"\"\"Set hide-dock-icon preference (True/False)\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    success = config.set_hide_dock_icon(enabled)\n\n    if success:\n        print(f\"SUCCESS: Hide dock icon {'enabled' if enabled else 'disabled'}\")\n        print(json.dumps({\"success\": True, \"hide_dock_icon\": enabled}))\n    else:\n        print(f\"ERROR: Failed to save hide dock icon preference\")\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save config\"}))\n\n\n@cli.command()\ndef get_telemetry():\n    \"\"\"Get the current telemetry preference and anonymous ID\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    enabled = config.get_telemetry_enabled()\n    anonymous_id = config.get_anonymous_id()\n\n    result = {\n        \"telemetry_enabled\": enabled,\n        \"anonymous_id\": anonymous_id\n    }\n\n    print(json.dumps(result, indent=2))\n\n\n@cli.command()\n@click.argument('enabled', type=bool)\ndef set_telemetry(enabled):\n    \"\"\"Set telemetry preference (True/False)\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    success = config.set_telemetry_enabled(enabled)\n\n    if success:\n        print(f\"SUCCESS: Telemetry {'enabled' if enabled else 'disabled'}\")\n        print(json.dumps({\"success\": True, \"telemetry_enabled\": enabled}))\n    else:\n        print(f\"ERROR: Failed to save telemetry preference\")\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save config\"}))\n\n\n@cli.command()\ndef get_system_audio():\n    \"\"\"Get the current system audio capture preference\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    enabled = config.get_system_audio_enabled()\n\n    print(json.dumps({\"system_audio_enabled\": enabled}))\n\n\n@cli.command()\n@click.argument('enabled', callback=lambda ctx, param, v: v.lower() == 'true')\ndef set_system_audio(enabled):\n    \"\"\"Set system audio capture preference (True/False)\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    success = config.set_system_audio_enabled(enabled)\n\n    if success:\n        print(f\"SUCCESS: System audio capture {'enabled' if enabled else 'disabled'}\")\n        print(json.dumps({\"success\": True, \"system_audio_enabled\": enabled}))\n    else:\n        print(f\"ERROR: Failed to save system audio preference\")\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save config\"}))\n\n\n@cli.command()\ndef get_language():\n    \"\"\"Get the current language setting\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n    language = config.get_language()\n    language_name = config.get_language_name(language)\n\n    print(json.dumps({\"language\": language, \"language_name\": language_name}))\n\n\n@cli.command()\n@click.argument('language_code')\ndef set_language(language_code):\n    \"\"\"Set the language for transcription and summarization\"\"\"\n    from src.config import get_config\n\n    config = get_config()\n\n    if language_code not in config.SUPPORTED_LANGUAGES:\n        print(json.dumps({\n            \"success\": False,\n            \"error\": f\"Unsupported language: {language_code}. Supported: {', '.join(config.SUPPORTED_LANGUAGES.keys())}\"\n        }))\n        return\n\n    success = config.set_language(language_code)\n\n    if success:\n        print(json.dumps({\n            \"success\": True,\n            \"language\": language_code,\n            \"language_name\": config.get_language_name(language_code)\n        }))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save language setting\"}))\n\n\n@cli.command(name='get-user-name')\ndef get_user_name_cmd():\n    \"\"\"Get the user's first name (for in-app greetings).\"\"\"\n    from src.config import get_config\n    print(json.dumps({\"user_name\": get_config().get_user_name()}))\n\n\n@cli.command(name='set-user-name')\n@click.argument('name', default='')\ndef set_user_name_cmd(name):\n    \"\"\"Set the user's first name. Empty string clears it.\"\"\"\n    from src.config import get_config\n    success = get_config().set_user_name(name)\n    if success:\n        print(json.dumps({\"success\": True, \"user_name\": name.strip()}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save user name\"}))\n\n\n@cli.command()\ndef get_storage_path():\n    \"\"\"Get the current custom storage path\"\"\"\n    from src.config import get_config\n    config = get_config()\n    storage_path = config.get_storage_path()\n    print(json.dumps({\"storage_path\": storage_path}))\n\n\n@cli.command()\n@click.argument('storage_path', default='')\ndef set_storage_path(storage_path):\n    \"\"\"Set custom storage path (empty to reset to default)\"\"\"\n    from src.config import get_config\n    config = get_config()\n    success = config.set_storage_path(storage_path)\n    if success:\n        print(json.dumps({\"success\": True, \"storage_path\": storage_path}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to set storage path\"}))\n\n\n@cli.command()\ndef list_folders():\n    \"\"\"List all folders\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    print(json.dumps({\"folders\": mgr.list_folders()}))\n\n\n@cli.command()\n@click.argument('name')\n@click.option('--color', default='#6366f1')\ndef create_folder(name, color):\n    \"\"\"Create a new folder\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    folder = mgr.create_folder(name, color)\n    if folder:\n        print(json.dumps({\"success\": True, \"folder\": folder}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to create folder\"}))\n\n\n@cli.command()\n@click.argument('folder_id')\n@click.argument('icon')\ndef update_folder_icon(folder_id, icon):\n    \"\"\"Update a folder's icon\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    success = mgr.update_icon(folder_id, icon)\n    print(json.dumps({\"success\": success}))\n\n\n@cli.command()\n@click.argument('folder_id')\n@click.argument('name')\ndef rename_folder(folder_id, name):\n    \"\"\"Rename a folder\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    success = mgr.rename_folder(folder_id, name)\n    print(json.dumps({\"success\": success}))\n\n\n@cli.command()\n@click.argument('folder_ids', nargs=-1, required=True)\ndef reorder_folders(folder_ids):\n    \"\"\"Reorder folders by providing folder IDs in desired order\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    success = mgr.reorder_folders(list(folder_ids))\n    print(json.dumps({\"success\": success}))\n\n\n@cli.command()\n@click.argument('folder_id')\ndef delete_folder(folder_id):\n    \"\"\"Delete a folder\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    success = mgr.delete_folder(folder_id)\n    print(json.dumps({\"success\": success}))\n\n\n@cli.command()\n@click.argument('summary_file')\n@click.argument('folder_id')\ndef add_meeting_to_folder(summary_file, folder_id):\n    \"\"\"Add a meeting to a folder\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    success = mgr.add_meeting_to_folder(Path(summary_file), folder_id)\n    print(json.dumps({\"success\": success}))\n\n\n@cli.command()\n@click.argument('summary_file')\n@click.argument('folder_id')\ndef remove_meeting_from_folder(summary_file, folder_id):\n    \"\"\"Remove a meeting from a folder\"\"\"\n    from src.folders import get_folders_manager\n    mgr = get_folders_manager()\n    success = mgr.remove_meeting_from_folder(Path(summary_file), folder_id)\n    print(json.dumps({\"success\": success}))\n\n\n@cli.command()\ndef get_ai_provider():\n    \"\"\"Get all AI provider configuration\"\"\"\n    from src.config import get_config\n    config = get_config()\n\n    result = {\n        \"ai_provider\": config.get_ai_provider(),\n        \"remote_ollama_url\": config.get_remote_ollama_url(),\n        \"cloud_api_url\": config.get_cloud_api_url(),\n        \"cloud_api_key_set\": bool(config.get_cloud_api_key()),\n        \"cloud_provider\": config.get_cloud_provider(),\n        \"cloud_model\": config.get_cloud_model(),\n    }\n    print(json.dumps(result))\n\n\n@cli.command()\n@click.argument('provider')\ndef set_ai_provider(provider):\n    \"\"\"Set the AI provider (local, remote, or cloud)\"\"\"\n    from src.config import get_config\n    config = get_config()\n\n    if provider not in config.VALID_AI_PROVIDERS:\n        print(json.dumps({\n            \"success\": False,\n            \"error\": f\"Invalid provider: {provider}. Must be one of: {', '.join(config.VALID_AI_PROVIDERS)}\"\n        }))\n        return\n\n    success = config.set_ai_provider(provider)\n    if success:\n        print(json.dumps({\"success\": True, \"ai_provider\": provider}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save AI provider setting\"}))\n\n\n@cli.command()\n@click.argument('url')\ndef set_remote_ollama_url(url):\n    \"\"\"Set the remote Ollama server URL\"\"\"\n    from src.config import get_config\n    config = get_config()\n    success = config.set_remote_ollama_url(url)\n    if success:\n        print(json.dumps({\"success\": True, \"remote_ollama_url\": url}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save remote Ollama URL\"}))\n\n\n@cli.command()\n@click.argument('url')\ndef set_cloud_api_url(url):\n    \"\"\"Set the cloud API URL\"\"\"\n    from src.config import get_config\n    config = get_config()\n    success = config.set_cloud_api_url(url)\n    if success:\n        print(json.dumps({\"success\": True, \"cloud_api_url\": url}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save cloud API URL\"}))\n\n\n\n@cli.command()\n@click.argument('provider')\ndef set_cloud_provider(provider):\n    \"\"\"Set the cloud provider type (openai or custom)\"\"\"\n    from src.config import get_config\n    config = get_config()\n\n    if provider not in config.VALID_CLOUD_PROVIDERS:\n        print(json.dumps({\n            \"success\": False,\n            \"error\": f\"Invalid cloud provider: {provider}. Must be one of: {', '.join(config.VALID_CLOUD_PROVIDERS)}\"\n        }))\n        return\n\n    success = config.set_cloud_provider(provider)\n    if success:\n        print(json.dumps({\"success\": True, \"cloud_provider\": provider}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save cloud provider\"}))\n\n\n@cli.command()\n@click.argument('model')\ndef set_cloud_model(model):\n    \"\"\"Set the cloud model name\"\"\"\n    from src.config import get_config\n    config = get_config()\n    success = config.set_cloud_model(model)\n    if success:\n        print(json.dumps({\"success\": True, \"cloud_model\": model}))\n    else:\n        print(json.dumps({\"success\": False, \"error\": \"Failed to save cloud model\"}))\n\n\n@cli.command()\n@click.argument('url')\ndef test_remote_ollama(url):\n    \"\"\"Test connection to a remote Ollama server\"\"\"\n    try:\n        import ollama as ollama_pkg\n        client = ollama_pkg.Client(host=url)\n        response = client.list()\n        models = [getattr(m, 'model', '') for m in getattr(response, 'models', [])]\n        print(json.dumps({\"success\": True, \"models\": models}))\n    except Exception as e:\n        print(json.dumps({\"success\": False, \"error\": str(e)}))\n\n\n@cli.command()\ndef test_cloud_api():\n    \"\"\"Test connection to the cloud API\"\"\"\n    from src.config import get_config\n    config = get_config()\n\n    cloud_api_key = config.get_cloud_api_key()\n    cloud_provider = config.get_cloud_provider()\n    cloud_api_url = config.get_cloud_api_url()\n\n    if not cloud_api_key:\n        print(json.dumps({\"success\": False, \"error\": \"No API key configured\"}))\n        return\n\n    try:\n        if cloud_provider == \"anthropic\":\n            from anthropic import Anthropic\n            client = Anthropic(api_key=cloud_api_key)\n            # Lightweight test: list models\n            models_page = client.models.list(limit=10)\n            model_ids = [m.id for m in models_page.data]\n            print(json.dumps({\"success\": True, \"models\": model_ids}))\n        else:\n            from openai import OpenAI\n            base_url = cloud_api_url if cloud_provider == \"custom\" and cloud_api_url else None\n            client = OpenAI(api_key=cloud_api_key, base_url=base_url)\n            models = client.models.list()\n            model_ids = [m.id for m in models.data[:10]]\n            print(json.dumps({\"success\": True, \"models\": model_ids}))\n    except Exception as e:\n        print(json.dumps({\"success\": False, \"error\": str(e)}))\n\n\n@cli.command()\ndef download_whisper_model():\n    \"\"\"Download the Whisper transcription model\"\"\"\n    print(\"Downloading Whisper model...\")\n\n    try:\n        from pywhispercpp.model import Model as WhisperCppModel\n\n        # This will trigger the model download if not present\n        print(\"Initializing Whisper model (will download if needed)...\")\n        from src.config import get_config\n        model_size = get_config().get_whisper_model()\n        model = WhisperCppModel(model_size)\n        print(\"SUCCESS: Whisper model ready\")\n\n    except Exception as e:\n        print(f\"ERROR: Failed to download Whisper model: {e}\")\n        import sys\n        sys.exit(1)\n\n\n@cli.command()\n@click.argument('model_name')\ndef check_model(model_name):\n    \"\"\"Check if a model is installed in Ollama (uses HTTP API).\"\"\"\n    from src.config import get_config\n    config = get_config()\n    provider = config.get_ai_provider()\n\n    if provider == \"remote\":\n        remote_url = config.get_remote_ollama_url()\n        if not remote_url:\n            print(json.dumps({\"installed\": False, \"model\": model_name, \"error\": \"No remote URL configured\"}))\n            return\n        try:\n            import ollama as ollama_pkg\n            client = ollama_pkg.Client(host=remote_url)\n            response = client.list()\n            models = getattr(response, 'models', []) or []\n            model_names = [getattr(m, 'model', '') for m in models]\n            installed = model_name in model_names\n            print(json.dumps({\"installed\": installed, \"model\": model_name}))\n        except Exception as e:\n            print(json.dumps({\"installed\": False, \"model\": model_name, \"error\": str(e)}))\n    else:\n        from src.ollama_manager import start_ollama_server\n        start_ollama_server()\n        try:\n            import ollama\n            response = ollama.list()\n            models = getattr(response, 'models', []) or []\n            model_names = [getattr(m, 'model', '') for m in models]\n            installed = model_name in model_names\n            print(json.dumps({\"installed\": installed, \"model\": model_name}))\n        except Exception as e:\n            print(json.dumps({\"installed\": False, \"model\": model_name, \"error\": str(e)}))\n\n\n@cli.command()\n@click.argument('model_name')\ndef pull_model(model_name):\n    \"\"\"Download an Ollama model (uses HTTP API).\"\"\"\n    from src.ollama_manager import start_ollama_server\n    start_ollama_server()\n    try:\n        import ollama\n        for progress in ollama.pull(model_name, stream=True):\n            status = getattr(progress, 'status', '') or ''\n            total = getattr(progress, 'total', 0) or 0\n            completed = getattr(progress, 'completed', 0) or 0\n            if total > 0:\n                pct = int(completed / total * 100)\n                print(f\"{status} {pct}%\", flush=True)\n            elif status:\n                print(status, flush=True)\n        print(json.dumps({\"success\": True, \"model\": model_name}))\n    except Exception as e:\n        print(json.dumps({\"success\": False, \"error\": str(e)}))\n\n\nif __name__ == '__main__':\n    import multiprocessing\n    multiprocessing.freeze_support()\n    cli()\n"
  },
  {
    "path": "src/__init__.py",
    "content": ""
  },
  {
    "path": "src/audio_recorder.py",
    "content": "try:\n    import sounddevice as sd\n    import numpy as np\n    AUDIO_AVAILABLE = True\nexcept ImportError:\n    sd = None\n    np = None\n    AUDIO_AVAILABLE = False\nimport wave\nimport threading\nimport time\nimport atexit\nfrom pathlib import Path\nfrom typing import Optional\nimport logging\nimport json\n\nlogger = logging.getLogger(__name__)\n\n# Global cleanup to prevent resource leaks\ndef cleanup_sounddevice():\n    \"\"\"Clean up sounddevice resources on exit\"\"\"\n    try:\n        if sd is not None:\n            sd._terminate()\n    except (AttributeError, RuntimeError, Exception) as e:\n        # Log but don't raise - this is cleanup code\n        logger.debug(f\"Error during sounddevice cleanup: {e}\")\n\natexit.register(cleanup_sounddevice)\n\n\nclass AudioRecorder:\n    def __init__(self, sample_rate: int = 44100, channels: int = 1):\n        if not AUDIO_AVAILABLE:\n            raise ImportError(\"Audio dependencies not available. Please install sounddevice and numpy.\")\n\n        self.sample_rate = sample_rate\n        self.channels = channels\n        self.recording = False\n        self.paused = False\n        self.audio_data = []\n        self.recording_thread: Optional[threading.Thread] = None\n\n        # Thread safety lock for audio_data access\n        self.audio_lock = threading.Lock()\n        # Separate lock for pause state\n        self.pause_lock = threading.Lock()\n\n        # Simple state - no persistence for now\n        self.stream = None\n    \n    def _load_state(self):\n        \"\"\"No persistence - start fresh each time.\"\"\"\n        self.recording = False\n        self.audio_data = []\n    \n    def _save_state(self):\n        \"\"\"No persistence - do nothing.\"\"\"\n        pass\n    \n    def _clear_state(self):\n        \"\"\"No persistence - do nothing.\"\"\" \n        pass\n        \n    def start_recording(self) -> None:\n        \"\"\"Start recording audio from the microphone.\"\"\"\n        if self.recording:\n            logger.warning(\"Recording is already in progress\")\n            return\n\n        self.recording = True\n\n        # Clear audio data with thread safety\n        with self.audio_lock:\n            self.audio_data = []\n\n        logger.info(\"Creating recording thread...\")\n        self.recording_thread = threading.Thread(target=self._record)\n        self.recording_thread.start()\n        logger.info(\"Started recording thread\")\n\n        # Give thread a moment to start\n        time.sleep(0.2)\n        if not self.recording:\n            logger.error(\"Recording failed to start - thread ended immediately\")\n        else:\n            logger.info(\"Recording appears to be active\")\n        \n    def stop_recording(self) -> None:\n        \"\"\"Stop recording audio.\"\"\"\n        if not self.recording:\n            logger.warning(\"No recording in progress\")\n            return\n\n        self.recording = False\n        with self.pause_lock:\n            self.paused = False\n        if self.recording_thread:\n            self.recording_thread.join(timeout=5.0)  # Add timeout to prevent hanging\n            self.recording_thread = None  # Clean up reference\n\n        logger.info(\"Stopped recording\")\n\n    def pause_recording(self) -> None:\n        \"\"\"Pause the current recording.\"\"\"\n        if not self.recording:\n            logger.warning(\"No recording in progress to pause\")\n            return\n        with self.pause_lock:\n            if self.paused:\n                logger.warning(\"Recording is already paused\")\n                return\n            self.paused = True\n        logger.info(\"Recording paused\")\n\n    def resume_recording(self) -> None:\n        \"\"\"Resume a paused recording.\"\"\"\n        if not self.recording:\n            logger.warning(\"No recording in progress to resume\")\n            return\n        with self.pause_lock:\n            if not self.paused:\n                logger.warning(\"Recording is not paused\")\n                return\n            self.paused = False\n        logger.info(\"Recording resumed\")\n\n    def is_paused(self) -> bool:\n        \"\"\"Check if recording is currently paused.\"\"\"\n        with self.pause_lock:\n            return self.paused\n        \n    def _record(self) -> None:\n        \"\"\"Internal method to handle the recording process.\"\"\"\n        stream = None\n        try:\n            logger.info(f\"Starting audio stream with sample_rate={self.sample_rate}, channels={self.channels}\")\n            stream = sd.InputStream(\n                samplerate=self.sample_rate,\n                channels=self.channels,\n                callback=self._audio_callback,\n                blocksize=1024\n            )\n            self.stream = stream  # Store reference for cleanup\n            stream.start()\n            logger.info(\"Audio stream started successfully\")\n            \n            while self.recording:\n                time.sleep(0.1)\n            logger.info(\"Recording loop ended\")\n            \n        except Exception as e:\n            logger.error(f\"Error during recording: {e}\")\n            logger.error(f\"Available audio devices: {sd.query_devices()}\")\n            self.recording = False\n        finally:\n            # Ensure stream is always properly closed\n            if stream is not None:\n                try:\n                    stream.stop()\n                    stream.close()\n                    logger.info(\"Audio stream closed\")\n                except (AttributeError, RuntimeError, Exception) as e:\n                    logger.warning(f\"Error closing audio stream: {e}\")\n            self.stream = None\n            \n    def _audio_callback(self, indata, frames, time, status):\n        \"\"\"Callback function for audio input stream.\"\"\"\n        if status:\n            logger.warning(f\"Audio callback status: {status}\")\n        if self.recording and not self.is_paused():\n            # Thread-safe append to audio_data (skip when paused)\n            with self.audio_lock:\n                self.audio_data.append(indata.copy())\n            \n    def save_recording(self, filepath: Path) -> bool:\n        \"\"\"Save the recorded audio to a WAV file.\"\"\"\n        # Thread-safe check and copy of audio data\n        with self.audio_lock:\n            if not self.audio_data:\n                logger.error(\"No audio data to save\")\n                return False\n            # Create a copy to release lock quickly\n            audio_data_copy = self.audio_data.copy()\n\n        try:\n            # Convert list of numpy arrays to single array\n            audio_array = np.concatenate(audio_data_copy, axis=0)\n\n            # Ensure the directory exists\n            filepath.parent.mkdir(parents=True, exist_ok=True)\n\n            # Save as WAV file\n            with wave.open(str(filepath), 'wb') as wav_file:\n                wav_file.setnchannels(self.channels)\n                wav_file.setsampwidth(2)  # 16-bit\n                wav_file.setframerate(self.sample_rate)\n\n                # Convert float32 to int16\n                audio_int16 = (audio_array * 32767).astype(np.int16)\n                wav_file.writeframes(audio_int16.tobytes())\n\n            logger.info(f\"Audio saved to {filepath}\")\n\n            # Clear audio data after successful save (thread-safe)\n            with self.audio_lock:\n                self.audio_data = []\n            self.recording = False\n\n            return True\n\n        except Exception as e:\n            logger.error(f\"Error saving audio: {e}\")\n            return False\n            \n    def get_recording_duration(self) -> float:\n        \"\"\"Get the duration of the current recording in seconds.\"\"\"\n        # Thread-safe read of audio data\n        with self.audio_lock:\n            if not self.audio_data:\n                return 0.0\n            total_frames = sum(len(chunk) for chunk in self.audio_data)\n        return total_frames / self.sample_rate\n        \n    def is_recording(self) -> bool:\n        \"\"\"Check if currently recording.\"\"\"\n        return self.recording\n    \n    def __del__(self):\n        \"\"\"Cleanup resources when instance is destroyed.\"\"\"\n        try:\n            if self.recording:\n                self.stop_recording()\n            if self.stream:\n                try:\n                    self.stream.stop()\n                    self.stream.close()\n                except (AttributeError, RuntimeError, Exception) as e:\n                    logger.debug(f\"Error closing stream in __del__: {e}\")\n            if self.recording_thread and self.recording_thread.is_alive():\n                self.recording_thread.join(timeout=1.0)\n        except (AttributeError, RuntimeError, Exception) as e:\n            logger.debug(f\"Error in __del__: {e}\")"
  },
  {
    "path": "src/config.py",
    "content": "\"\"\"\nConfiguration management for StenoAI.\n\nHandles storing and loading user preferences like model selection.\n\"\"\"\n\nimport json\nimport logging\nimport uuid\nfrom pathlib import Path\nfrom typing import Optional, Dict, Any\n\nlogger = logging.getLogger(__name__)\n\n\nclass Config:\n    \"\"\"Manages application configuration with file persistence.\"\"\"\n\n    DEFAULT_MODEL = \"llama3.2:3b\"\n\n    # Supported models with metadata (organized by parameter size, ascending)\n    SUPPORTED_MODELS = {\n        \"llama3.2:3b\": {\n            \"name\": \"Llama 3.2 3B\",\n            \"size\": \"2GB\",\n            \"params\": \"3B\",\n            \"description\": \"Fast and lightweight for quick meetings (default)\",\n            \"speed\": \"very fast\",\n            \"quality\": \"good\"\n        },\n        \"gemma3:4b\": {\n            \"name\": \"Gemma 3 4B\",\n            \"size\": \"2.5GB\",\n            \"params\": \"4B\",\n            \"description\": \"Lightweight and efficient\",\n            \"speed\": \"fast\",\n            \"quality\": \"good\"\n        },\n        \"qwen3.5:9b\": {\n            \"name\": \"Qwen 3.5 9B\",\n            \"size\": \"6.6GB\",\n            \"params\": \"9B\",\n            \"description\": \"Excellent at structured output and action items\",\n            \"speed\": \"medium\",\n            \"quality\": \"excellent\"\n        },\n        \"deepseek-r1:14b\": {\n            \"name\": \"DeepSeek R1 14B\",\n            \"size\": \"9.0GB\",\n            \"params\": \"14B\",\n            \"description\": \"Strong reasoning and analysis capabilities\",\n            \"speed\": \"fast\",\n            \"quality\": \"excellent\"\n        },\n        \"gpt-oss:20b\": {\n            \"name\": \"GPT-OSS 20B\",\n            \"size\": \"14GB\",\n            \"params\": \"20B\",\n            \"description\": \"OpenAI open-weight model with reasoning capabilities\",\n            \"speed\": \"medium\",\n            \"quality\": \"excellent\"\n        },\n        \"qwen3:8b\": {\n            \"name\": \"Qwen 3 8B\",\n            \"size\": \"4.7GB\",\n            \"params\": \"8B\",\n            \"description\": \"Replaced by Qwen 3.5 9B\",\n            \"speed\": \"fast\",\n            \"quality\": \"excellent\",\n            \"deprecated\": True\n        },\n        \"deepseek-r1:8b\": {\n            \"name\": \"DeepSeek R1 8B\",\n            \"size\": \"4.7GB\",\n            \"params\": \"8B\",\n            \"description\": \"Replaced by DeepSeek R1 14B\",\n            \"speed\": \"medium\",\n            \"quality\": \"excellent\",\n            \"deprecated\": True\n        }\n    }\n\n\n    SUPPORTED_WHISPER_MODELS = [\"tiny\", \"base\", \"small\", \"medium\", \"large\", \"large-v3-turbo\"]\n\n    # Languages shown in the settings dropdown (curated/tested)\n    SUPPORTED_LANGUAGES = {\n        \"auto\": \"Auto (detect)\",\n        \"en\": \"English\",\n        \"es\": \"Spanish\",\n        \"fr\": \"French\",\n        \"de\": \"German\",\n        \"nl\": \"Dutch\",\n        \"pt\": \"Portuguese\",\n        \"ja\": \"Japanese\",\n        \"zh\": \"Chinese\",\n        \"ko\": \"Korean\",\n        \"hi\": \"Hindi\",\n        \"ar\": \"Arabic\",\n    }\n\n    # Full ISO 639-1 language names for auto-detect passthrough.\n    # Whisper supports 99 languages; this maps codes to display names\n    # so the summarizer prompt gets a proper language name (e.g. \"Polish\")\n    # rather than just a code (e.g. \"pl\").\n    _LANGUAGE_NAMES = {\n        \"af\": \"Afrikaans\", \"am\": \"Amharic\", \"ar\": \"Arabic\", \"as\": \"Assamese\",\n        \"az\": \"Azerbaijani\", \"ba\": \"Bashkir\", \"be\": \"Belarusian\", \"bg\": \"Bulgarian\",\n        \"bn\": \"Bengali\", \"bo\": \"Tibetan\", \"br\": \"Breton\", \"bs\": \"Bosnian\",\n        \"ca\": \"Catalan\", \"cs\": \"Czech\", \"cy\": \"Welsh\", \"da\": \"Danish\",\n        \"de\": \"German\", \"el\": \"Greek\", \"en\": \"English\", \"es\": \"Spanish\",\n        \"et\": \"Estonian\", \"eu\": \"Basque\", \"fa\": \"Persian\", \"fi\": \"Finnish\",\n        \"fo\": \"Faroese\", \"fr\": \"French\", \"gl\": \"Galician\", \"gu\": \"Gujarati\",\n        \"ha\": \"Hausa\", \"haw\": \"Hawaiian\", \"he\": \"Hebrew\", \"hi\": \"Hindi\",\n        \"hr\": \"Croatian\", \"ht\": \"Haitian Creole\", \"hu\": \"Hungarian\", \"hy\": \"Armenian\",\n        \"id\": \"Indonesian\", \"is\": \"Icelandic\", \"it\": \"Italian\", \"ja\": \"Japanese\",\n        \"jw\": \"Javanese\", \"ka\": \"Georgian\", \"kk\": \"Kazakh\", \"km\": \"Khmer\",\n        \"kn\": \"Kannada\", \"ko\": \"Korean\", \"la\": \"Latin\", \"lb\": \"Luxembourgish\",\n        \"ln\": \"Lingala\", \"lo\": \"Lao\", \"lt\": \"Lithuanian\", \"lv\": \"Latvian\",\n        \"mg\": \"Malagasy\", \"mi\": \"Maori\", \"mk\": \"Macedonian\", \"ml\": \"Malayalam\",\n        \"mn\": \"Mongolian\", \"mr\": \"Marathi\", \"ms\": \"Malay\", \"mt\": \"Maltese\",\n        \"my\": \"Myanmar\", \"ne\": \"Nepali\", \"nl\": \"Dutch\", \"nn\": \"Nynorsk\",\n        \"no\": \"Norwegian\", \"oc\": \"Occitan\", \"pa\": \"Punjabi\", \"pl\": \"Polish\",\n        \"ps\": \"Pashto\", \"pt\": \"Portuguese\", \"ro\": \"Romanian\", \"ru\": \"Russian\",\n        \"sa\": \"Sanskrit\", \"sd\": \"Sindhi\", \"si\": \"Sinhala\", \"sk\": \"Slovak\",\n        \"sl\": \"Slovenian\", \"sn\": \"Shona\", \"so\": \"Somali\", \"sq\": \"Albanian\",\n        \"sr\": \"Serbian\", \"su\": \"Sundanese\", \"sv\": \"Swedish\", \"sw\": \"Swahili\",\n        \"ta\": \"Tamil\", \"te\": \"Telugu\", \"tg\": \"Tajik\", \"th\": \"Thai\",\n        \"tk\": \"Turkmen\", \"tl\": \"Tagalog\", \"tr\": \"Turkish\", \"tt\": \"Tatar\",\n        \"uk\": \"Ukrainian\", \"ur\": \"Urdu\", \"uz\": \"Uzbek\", \"vi\": \"Vietnamese\",\n        \"yi\": \"Yiddish\", \"yo\": \"Yoruba\", \"zh\": \"Chinese\",\n    }\n\n    def __init__(self, config_path: Optional[Path] = None):\n        \"\"\"\n        Initialize configuration manager.\n\n        Args:\n            config_path: Path to config file. If None, uses default location.\n        \"\"\"\n        if config_path is None:\n            import sys as _sys\n            if getattr(_sys, 'frozen', False) or \"StenoAI.app\" in str(Path(__file__)) or \"Applications\" in str(Path(__file__)):\n                # Bundled (PyInstaller dev or production): ~/Library/Application Support/stenoai\n                base_dir = Path.home() / \"Library\" / \"Application Support\" / \"stenoai\"\n            else:\n                # Source dev: project root\n                base_dir = Path(__file__).parent.parent\n\n            base_dir.mkdir(parents=True, exist_ok=True)\n            self.config_path = base_dir / \"config.json\"\n        else:\n            self.config_path = config_path\n\n        self._config: Dict[str, Any] = self._load()\n        self._migrate_cloud_model_map()\n\n    def _migrate_cloud_model_map(self) -> None:\n        \"\"\"One-shot migration from legacy single 'cloud_model' to per-provider\n        'cloud_models' map. Runs at load time (before any setters can change\n        the provider) so the legacy value is correctly attributed to whichever\n        provider was active when it was last saved.\"\"\"\n        if isinstance(self._config.get(\"cloud_models\"), dict):\n            return  # Already migrated.\n        legacy = self._config.get(\"cloud_model\")\n        has_legacy_value = isinstance(legacy, str) and legacy.strip()\n        if not has_legacy_value:\n            # Nothing to migrate. Don't write — if _load() returned defaults\n            # because the existing file was corrupt/unreadable, persisting an\n            # empty cloud_models map would overwrite the recoverable file.\n            self._config[\"cloud_models\"] = {}\n            return\n        current_provider = self._config.get(\"cloud_provider\", \"openai\")\n        if current_provider not in self.VALID_CLOUD_PROVIDERS:\n            current_provider = \"openai\"\n        self._config[\"cloud_models\"] = {current_provider: legacy.strip()}\n        self._save()\n\n    def _load(self) -> Dict[str, Any]:\n        \"\"\"Load configuration from file.\"\"\"\n        if not self.config_path.exists():\n            logger.info(f\"Config file not found, creating default at {self.config_path}\")\n            return self._get_default_config()\n\n        try:\n            with open(self.config_path, 'r') as f:\n                config = json.load(f)\n                logger.info(f\"Loaded config from {self.config_path}\")\n                return config\n        except Exception as e:\n            logger.error(f\"Error loading config: {e}, using defaults\")\n            return self._get_default_config()\n\n    def _save(self) -> bool:\n        \"\"\"Save configuration to file.\"\"\"\n        try:\n            with open(self.config_path, 'w') as f:\n                json.dump(self._config, f, indent=2)\n            logger.info(f\"Saved config to {self.config_path}\")\n            return True\n        except Exception as e:\n            logger.error(f\"Error saving config: {e}\")\n            return False\n\n    def _get_default_config(self) -> Dict[str, Any]:\n        \"\"\"Get default configuration.\"\"\"\n        return {\n            \"model\": self.DEFAULT_MODEL,\n            \"notifications_enabled\": True,\n            \"telemetry_enabled\": True,\n            \"system_audio_enabled\": False,\n            \"language\": \"en\",\n            \"ai_provider\": \"local\",\n            \"remote_ollama_url\": \"\",\n            \"cloud_api_url\": \"\",\n            \"cloud_provider\": \"openai\",\n            \"cloud_model\": \"gpt-4o-mini\",\n            \"anonymous_id\": str(uuid.uuid4()),\n            \"storage_path\": \"\",\n            \"keep_recordings\": False,\n            \"whisper_model\": \"small\",\n            \"version\": \"1.0\"\n        }\n\n    def get_storage_path(self) -> str:\n        \"\"\"Get the custom storage path. Empty string means use default.\"\"\"\n        return self._config.get(\"storage_path\", \"\")\n\n    def set_storage_path(self, storage_path: str) -> bool:\n        \"\"\"\n        Set custom storage path for recordings/transcripts/output.\n\n        Args:\n            storage_path: Absolute path to storage directory, or empty string to reset to default.\n\n        Returns:\n            True if saved successfully, False otherwise.\n        \"\"\"\n        if storage_path is None:\n            storage_path = \"\"\n        storage_path = storage_path.strip()\n\n        if storage_path:\n            sp = Path(storage_path)\n            if not sp.is_absolute():\n                logger.error(f\"Storage path must be absolute: {storage_path}\")\n                return False\n            # Create subdirectories at the new location. If this fails\n            # (for example due to permissions), keep existing config unchanged.\n            try:\n                for subdir in (\"recordings\", \"transcripts\", \"output\"):\n                    (sp / subdir).mkdir(parents=True, exist_ok=True)\n            except Exception as e:\n                logger.error(f\"Failed to initialize storage path {storage_path}: {e}\")\n                return False\n\n        self._config[\"storage_path\"] = storage_path\n        return self._save()\n\n    def get_model(self) -> str:\n        \"\"\"Get the configured model name.\"\"\"\n        return self._config.get(\"model\", self.DEFAULT_MODEL)\n\n    def set_model(self, model_name: str) -> bool:\n        \"\"\"\n        Set the model to use for summarization.\n\n        Args:\n            model_name: Name of the model (e.g., \"llama3.1:8b\")\n\n        Returns:\n            True if saved successfully, False otherwise\n        \"\"\"\n        # Validate model name\n        if model_name not in self.SUPPORTED_MODELS:\n            logger.warning(f\"Model {model_name} not in supported list, but allowing anyway\")\n\n        self._config[\"model\"] = model_name\n        return self._save()\n\n    def get_model_info(self, model_name: str) -> Optional[Dict[str, str]]:\n        \"\"\"\n        Get metadata about a specific model.\n\n        Args:\n            model_name: Name of the model\n\n        Returns:\n            Dictionary with model metadata or None if not found\n        \"\"\"\n        return self.SUPPORTED_MODELS.get(model_name)\n\n    def list_supported_models(self) -> Dict[str, Dict[str, str]]:\n        \"\"\"Get all supported models with their metadata.\"\"\"\n        return self.SUPPORTED_MODELS.copy()\n\n    def get_notifications_enabled(self) -> bool:\n        \"\"\"Get whether desktop notifications are enabled.\"\"\"\n        return self._config.get(\"notifications_enabled\", True)\n\n    def set_notifications_enabled(self, enabled: bool) -> bool:\n        \"\"\"\n        Set whether desktop notifications are enabled.\n\n        Args:\n            enabled: True to enable notifications, False to disable\n\n        Returns:\n            True if saved successfully, False otherwise\n        \"\"\"\n        self._config[\"notifications_enabled\"] = enabled\n        return self._save()\n\n    def get_telemetry_enabled(self) -> bool:\n        \"\"\"Get whether anonymous usage analytics are enabled.\"\"\"\n        return self._config.get(\"telemetry_enabled\", True)\n\n    def set_telemetry_enabled(self, enabled: bool) -> bool:\n        \"\"\"\n        Set whether anonymous usage analytics are enabled.\n\n        Args:\n            enabled: True to enable telemetry, False to disable\n\n        Returns:\n            True if saved successfully, False otherwise\n        \"\"\"\n        self._config[\"telemetry_enabled\"] = enabled\n        return self._save()\n\n    def get_hide_dock_icon(self) -> bool:\n        \"\"\"Get whether the dock icon should be hidden (menu bar only mode).\"\"\"\n        return self._config.get(\"hide_dock_icon\", False)\n\n    def set_hide_dock_icon(self, enabled: bool) -> bool:\n        \"\"\"\n        Set whether the dock icon should be hidden.\n\n        Args:\n            enabled: True to hide dock icon (menu bar only), False to show\n\n        Returns:\n            True if saved successfully, False otherwise\n        \"\"\"\n        self._config[\"hide_dock_icon\"] = enabled\n        return self._save()\n\n\n    def get_keep_recordings(self) -> bool:\n        \"\"\"Get whether audio recordings should be kept after processing.\"\"\"\n        return self._config.get(\"keep_recordings\", False)\n\n    def set_keep_recordings(self, enabled: bool) -> bool:\n        \"\"\"Set whether audio recordings should be kept after processing.\"\"\"\n        self._config[\"keep_recordings\"] = enabled\n        return self._save()\n\n\n    def get_whisper_model(self) -> str:\n        \"\"\"Get the configured Whisper model size.\"\"\"\n        model = self._config.get(\"whisper_model\", \"small\")\n        if model not in self.SUPPORTED_WHISPER_MODELS:\n            logger.warning(f\"Invalid Whisper model in config: {model}; falling back to small\")\n            return \"small\"\n        return model\n\n    def set_whisper_model(self, model_size: str) -> bool:\n        \"\"\"Set the Whisper model size.\"\"\"\n        if model_size not in self.SUPPORTED_WHISPER_MODELS:\n            logger.error(f\"Unsupported Whisper model: {model_size}\")\n            return False\n        self._config[\"whisper_model\"] = model_size\n        return self._save()\n\n    def get_system_audio_enabled(self) -> bool:\n        \"\"\"Get whether system audio capture is enabled.\"\"\"\n        return self._config.get(\"system_audio_enabled\", False)\n\n    def set_system_audio_enabled(self, enabled: bool) -> bool:\n        \"\"\"\n        Set whether system audio capture is enabled.\n\n        Args:\n            enabled: True to enable system audio capture, False to disable\n\n        Returns:\n            True if saved successfully, False otherwise\n        \"\"\"\n        self._config[\"system_audio_enabled\"] = enabled\n        return self._save()\n\n    def get_language(self) -> str:\n        \"\"\"Get the configured language code for transcription and summarization.\"\"\"\n        return self._config.get(\"language\", \"en\")\n\n    def set_language(self, language_code: str) -> bool:\n        \"\"\"\n        Set the language for transcription and summarization.\n\n        Args:\n            language_code: Language code (e.g., \"en\", \"de\", \"auto\")\n\n        Returns:\n            True if saved successfully, False otherwise\n        \"\"\"\n        if language_code not in self.SUPPORTED_LANGUAGES:\n            logger.error(f\"Unsupported language code: {language_code}\")\n            return False\n\n        self._config[\"language\"] = language_code\n        return self._save()\n\n    def get_language_name(self, language_code: Optional[str] = None) -> str:\n        \"\"\"Get the display name for a language code.\"\"\"\n        if language_code is None:\n            language_code = self.get_language()\n        return (\n            self.SUPPORTED_LANGUAGES.get(language_code)\n            or self._LANGUAGE_NAMES.get(language_code)\n            or (language_code.upper() if language_code else \"Unknown\")\n        )\n\n    # --- AI provider settings ---\n\n    VALID_AI_PROVIDERS = (\"local\", \"remote\", \"cloud\")\n    VALID_CLOUD_PROVIDERS = (\"openai\", \"anthropic\", \"custom\")\n\n    def get_ai_provider(self) -> str:\n        \"\"\"Get the configured AI provider ('local', 'remote', or 'cloud').\"\"\"\n        value = self._config.get(\"ai_provider\", \"local\")\n        return value if value in self.VALID_AI_PROVIDERS else \"local\"\n\n    def set_ai_provider(self, provider: str) -> bool:\n        \"\"\"Set the AI provider mode.\"\"\"\n        if provider not in self.VALID_AI_PROVIDERS:\n            logger.error(f\"Invalid AI provider: {provider}. Must be one of {self.VALID_AI_PROVIDERS}\")\n            return False\n        self._config[\"ai_provider\"] = provider\n        return self._save()\n\n    def get_remote_ollama_url(self) -> str:\n        \"\"\"Get the remote Ollama server URL.\"\"\"\n        return self._config.get(\"remote_ollama_url\", \"\")\n\n    def set_remote_ollama_url(self, url: str) -> bool:\n        \"\"\"Set the remote Ollama server URL.\"\"\"\n        self._config[\"remote_ollama_url\"] = url.strip()\n        return self._save()\n\n    def get_cloud_api_url(self) -> str:\n        \"\"\"Get the cloud API URL.\"\"\"\n        return self._config.get(\"cloud_api_url\", \"\")\n\n    def set_cloud_api_url(self, url: str) -> bool:\n        \"\"\"Set the cloud API URL.\"\"\"\n        self._config[\"cloud_api_url\"] = url.strip()\n        return self._save()\n\n    def get_cloud_api_key(self) -> str:\n        \"\"\"Get the cloud API key from env var (set by Electron via safeStorage).\"\"\"\n        import os\n        return os.environ.get(\"STENOAI_CLOUD_API_KEY\", \"\")\n\n    # Per-provider sensible defaults. Used when the user switches provider for\n    # the first time and we have no remembered model for that provider yet.\n    CLOUD_MODEL_DEFAULTS = {\n        \"openai\": \"gpt-4o-mini\",\n        \"anthropic\": \"claude-haiku-4-5-20251001\",\n        \"custom\": \"gpt-4o-mini\",\n    }\n\n    def get_cloud_provider(self) -> str:\n        \"\"\"Get the cloud provider type ('openai', 'anthropic', or 'custom').\"\"\"\n        value = self._config.get(\"cloud_provider\", \"openai\")\n        return value if value in self.VALID_CLOUD_PROVIDERS else \"openai\"\n\n    def set_cloud_provider(self, provider: str) -> bool:\n        \"\"\"Set the cloud provider type.\"\"\"\n        if provider not in self.VALID_CLOUD_PROVIDERS:\n            logger.error(f\"Invalid cloud provider: {provider}. Must be one of {self.VALID_CLOUD_PROVIDERS}\")\n            return False\n        self._config[\"cloud_provider\"] = provider\n        return self._save()\n\n    def _get_cloud_models_map(self) -> dict:\n        \"\"\"Per-provider model store. Migration is handled in __init__ so this\n        just returns the dict (or empty).\"\"\"\n        models = self._config.get(\"cloud_models\")\n        if not isinstance(models, dict):\n            models = {}\n            self._config[\"cloud_models\"] = models\n        return models\n\n    def get_cloud_model(self) -> str:\n        \"\"\"Get the cloud model for the currently selected provider. Each\n        provider has its own remembered model so switching providers doesn't\n        carry an incompatible model name across (e.g. a Claude model into\n        OpenAI). Falls back to the per-provider default on first use.\"\"\"\n        provider = self.get_cloud_provider()\n        models = self._get_cloud_models_map()\n        if provider in models and isinstance(models[provider], str) and models[provider].strip():\n            return models[provider]\n        return self.CLOUD_MODEL_DEFAULTS.get(provider, \"gpt-4o-mini\")\n\n    def set_cloud_model(self, model: str) -> bool:\n        \"\"\"Set the cloud model for the currently selected provider.\"\"\"\n        provider = self.get_cloud_provider()\n        models = self._get_cloud_models_map()\n        models[provider] = model.strip()\n        self._config[\"cloud_models\"] = models\n        # Mirror to legacy 'cloud_model' so any code still reading the flat\n        # field sees the active provider's choice. Safe to remove once no\n        # consumers reference it.\n        self._config[\"cloud_model\"] = model.strip()\n        return self._save()\n\n    def get_user_name(self) -> str:\n        \"\"\"Get the user's first name (for greetings). Empty string when unset.\"\"\"\n        value = self._config.get(\"user_name\")\n        if not isinstance(value, str):\n            return \"\"\n        return value.strip()\n\n    def set_user_name(self, name: str) -> bool:\n        \"\"\"Persist the user's first name. Trims whitespace; an empty name\n        clears the field.\"\"\"\n        cleaned = (name or \"\").strip()\n        # Cap to a sane length so a paste of someone's whole bio doesn't end\n        # up in the greeting.\n        if len(cleaned) > 60:\n            cleaned = cleaned[:60]\n        self._config[\"user_name\"] = cleaned\n        return self._save()\n\n    def get_anonymous_id(self) -> str:\n        \"\"\"Get the anonymous telemetry ID, generating one if missing.\"\"\"\n        anon_id = self._config.get(\"anonymous_id\")\n        if not anon_id:\n            anon_id = str(uuid.uuid4())\n            self._config[\"anonymous_id\"] = anon_id\n            self._save()\n        return anon_id\n\n    def get(self, key: str, default: Any = None) -> Any:\n        \"\"\"Get a configuration value.\"\"\"\n        return self._config.get(key, default)\n\n    def set(self, key: str, value: Any) -> bool:\n        \"\"\"Set a configuration value and save.\"\"\"\n        self._config[key] = value\n        return self._save()\n\n\n# Global config instance\n_config_instance: Optional[Config] = None\n\n\ndef get_config() -> Config:\n    \"\"\"Get the global config instance (singleton pattern).\"\"\"\n    global _config_instance\n    if _config_instance is None:\n        _config_instance = Config()\n    return _config_instance\n\n\ndef get_data_dirs() -> Dict[str, Path]:\n    \"\"\"\n    Centralised path resolution for recordings, transcripts, and output.\n\n    Returns dict with keys: recordings, transcripts, output.\n    Uses custom storage_path from config if set, otherwise falls back to\n    production (~/Library/Application Support/stenoai/) or development paths.\n    \"\"\"\n    config = get_config()\n    custom = config.get_storage_path()\n\n    import sys as _sys\n    if custom:\n        base = Path(custom)\n    elif getattr(_sys, 'frozen', False) or \"StenoAI.app\" in str(Path(__file__)) or \"Applications\" in str(Path(__file__)):\n        base = Path.home() / \"Library\" / \"Application Support\" / \"stenoai\"\n    else:\n        base = Path(__file__).parent.parent  # project root in dev (source)\n\n    dirs = {\n        \"recordings\": base / \"recordings\",\n        \"transcripts\": base / \"transcripts\",\n        \"output\": base / \"output\",\n    }\n\n    for d in dirs.values():\n        d.mkdir(parents=True, exist_ok=True)\n\n    return dirs\n"
  },
  {
    "path": "src/folders.py",
    "content": "\"\"\"\nFolder management for organizing meetings in StenoAI.\n\nStores folder metadata in folders.json alongside the output directory.\nMeeting-to-folder assignment is stored in each meeting's summary JSON.\n\"\"\"\n\nimport json\nimport logging\nimport uuid\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\nlogger = logging.getLogger(__name__)\n\n\nclass FoldersManager:\n    \"\"\"Manages folders for organizing meetings.\"\"\"\n\n    def __init__(self, data_dir: Path):\n        self.folders_file = data_dir / \"folders.json\"\n        self._data = self._load()\n\n    def _load(self) -> Dict:\n        if self.folders_file.exists():\n            try:\n                with open(self.folders_file, \"r\") as f:\n                    return json.load(f)\n            except Exception as e:\n                logger.error(f\"Error loading folders: {e}\")\n        return {\"folders\": []}\n\n    def _save(self) -> bool:\n        try:\n            self.folders_file.parent.mkdir(parents=True, exist_ok=True)\n            with open(self.folders_file, \"w\") as f:\n                json.dump(self._data, f, indent=2)\n            return True\n        except Exception as e:\n            logger.error(f\"Error saving folders: {e}\")\n            return False\n\n    def list_folders(self) -> List[Dict]:\n        return self._data.get(\"folders\", [])\n\n    def create_folder(self, name: str, color: str = \"#6366f1\") -> Optional[Dict]:\n        folder = {\n            \"id\": str(uuid.uuid4())[:8],\n            \"name\": name,\n            \"color\": color,\n            \"icon\": \"folder\",\n            \"created_at\": datetime.now().isoformat(),\n            \"order\": len(self._data[\"folders\"]),\n        }\n        self._data[\"folders\"].append(folder)\n        if self._save():\n            return folder\n        return None\n\n    def update_icon(self, folder_id: str, icon: str) -> bool:\n        for folder in self._data[\"folders\"]:\n            if folder[\"id\"] == folder_id:\n                folder[\"icon\"] = icon\n                return self._save()\n        return False\n\n    def rename_folder(self, folder_id: str, name: str) -> bool:\n        for folder in self._data[\"folders\"]:\n            if folder[\"id\"] == folder_id:\n                folder[\"name\"] = name\n                return self._save()\n        return False\n\n    def delete_folder(self, folder_id: str) -> bool:\n        self._data[\"folders\"] = [\n            f for f in self._data[\"folders\"] if f[\"id\"] != folder_id\n        ]\n        return self._save()\n\n    def reorder_folders(self, folder_ids: List[str]) -> bool:\n        \"\"\"Reorder folders to match the given ID order, updating each folder's order field.\"\"\"\n        existing = {f[\"id\"]: f for f in self._data[\"folders\"]}\n        reordered = []\n        for i, fid in enumerate(folder_ids):\n            if fid in existing:\n                folder = existing.pop(fid)\n                folder[\"order\"] = i\n                reordered.append(folder)\n        # Append any folders not in the provided list (shouldn't happen, but safe)\n        for folder in existing.values():\n            folder[\"order\"] = len(reordered)\n            reordered.append(folder)\n        self._data[\"folders\"] = reordered\n        return self._save()\n\n    def _update_md_folders(self, summary_path: Path, update_fn) -> bool:\n        \"\"\"Update the folders list in a .md file's YAML frontmatter.\"\"\"\n        import re as _re\n        try:\n            content = summary_path.read_text(encoding='utf-8')\n            frontmatter = ''\n            body = content\n            if content.startswith('---'):\n                parts = content.split('---', 2)\n                if len(parts) >= 3:\n                    frontmatter = parts[1]\n                    body = parts[2]\n\n            current: List[str] = []\n            m = _re.search(r'^folders:\\s*(.+)$', frontmatter, _re.MULTILINE)\n            if m:\n                try:\n                    current = json.loads(m.group(1))\n                except (ValueError, TypeError):\n                    current = []\n\n            updated = update_fn(current)\n            folders_line = f'folders: {json.dumps(updated)}'\n\n            if m:\n                frontmatter = _re.sub(r'^folders:.*$', folders_line, frontmatter, flags=_re.MULTILINE)\n            else:\n                frontmatter = frontmatter.rstrip('\\n') + f'\\n{folders_line}\\n'\n\n            summary_path.write_text(f'---{frontmatter}---{body}', encoding='utf-8')\n            return True\n        except Exception as e:\n            logger.error(f\"Error updating md folders: {e}\")\n            return False\n\n    def add_meeting_to_folder(self, summary_path: Path, folder_id: str) -> bool:\n        \"\"\"Add a folder reference to a meeting's summary file.\"\"\"\n        if summary_path.suffix == '.md':\n            return self._update_md_folders(\n                summary_path, lambda f: list({*f, folder_id})\n            )\n        try:\n            with open(summary_path, \"r\") as f:\n                data = json.load(f)\n            folders = data.get(\"folders\", [])\n            if folder_id not in folders:\n                folders.append(folder_id)\n                data[\"folders\"] = folders\n                with open(summary_path, \"w\") as f:\n                    json.dump(data, f, indent=2)\n            return True\n        except Exception as e:\n            logger.error(f\"Error adding meeting to folder: {e}\")\n            return False\n\n    def remove_meeting_from_folder(self, summary_path: Path, folder_id: str) -> bool:\n        \"\"\"Remove a folder reference from a meeting's summary file.\"\"\"\n        if summary_path.suffix == '.md':\n            return self._update_md_folders(\n                summary_path, lambda f: [x for x in f if x != folder_id]\n            )\n        try:\n            with open(summary_path, \"r\") as f:\n                data = json.load(f)\n            folders = data.get(\"folders\", [])\n            if folder_id in folders:\n                folders.remove(folder_id)\n                data[\"folders\"] = folders\n                with open(summary_path, \"w\") as f:\n                    json.dump(data, f, indent=2)\n            return True\n        except Exception as e:\n            logger.error(f\"Error removing meeting from folder: {e}\")\n            return False\n\n\ndef get_folders_manager() -> FoldersManager:\n    \"\"\"Get a FoldersManager using the current data directory.\"\"\"\n    from src.config import get_data_dirs\n    dirs = get_data_dirs()\n    # Store folders.json alongside the output directory's parent\n    data_dir = dirs[\"output\"].parent\n    return FoldersManager(data_dir)\n"
  },
  {
    "path": "src/models.py",
    "content": "from typing import List, Optional\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nimport uuid\n\n\nclass ActionItem(BaseModel):\n    description: str\n    assignee: Optional[str] = \"\"\n    deadline: Optional[str] = None\n\n\nclass Decision(BaseModel):\n    decision: str\n    assignee: Optional[str] = \"\"\n    context: str\n\n\nclass DiscussionArea(BaseModel):\n    title: str\n    analysis: str\n\n\nclass MeetingTranscript(BaseModel):\n    meeting_id: str = Field(default_factory=lambda: str(uuid.uuid4()))\n    date: str = Field(default_factory=lambda: datetime.now().strftime(\"%Y-%m-%d\"))\n    duration: str\n    overview: str\n    participants: List[str]\n    discussion_areas: List[DiscussionArea] = []  # New field - optional for backwards compatibility\n    key_points: List[Decision]\n    next_steps: List[ActionItem]\n    transcript: str\n\n    def to_json_file(self, filepath: str) -> None:\n        \"\"\"Save the meeting transcript to a JSON file.\"\"\"\n        import json\n        with open(filepath, 'w') as f:\n            json.dump(self.model_dump(), f, indent=2)\n\n    @classmethod\n    def from_json_file(cls, filepath: str) -> 'MeetingTranscript':\n        \"\"\"Load a meeting transcript from a JSON file.\"\"\"\n        import json\n        with open(filepath, 'r') as f:\n            data = json.load(f)\n        return cls(**data)"
  },
  {
    "path": "src/ollama_manager.py",
    "content": "\"\"\"\nOllama manager for bundled Ollama binary.\n\nHandles finding and running the bundled Ollama binary that ships with StenoAI,\neliminating the need for users to install Ollama separately.\n\"\"\"\n\nimport logging\nimport os\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\nfrom typing import Optional, Tuple\n\nlogger = logging.getLogger(__name__)\n\n# Ollama download URL for macOS\nOLLAMA_DOWNLOAD_URL = \"https://github.com/ollama/ollama/releases/download/v0.16.3/ollama-darwin.tgz\"\n\n\ndef get_bundled_ollama_dir() -> Optional[Path]:\n    \"\"\"\n    Get the path to the bundled Ollama directory.\n\n    Returns:\n        Path to the ollama directory, or None if not found\n    \"\"\"\n    # When running from PyInstaller bundle\n    if getattr(sys, 'frozen', False):\n        # PyInstaller sets _MEIPASS to the temp directory where files are extracted\n        base_path = Path(sys._MEIPASS)\n        ollama_dir = base_path / 'ollama'\n        if ollama_dir.exists():\n            return ollama_dir\n\n        # Also check relative to executable\n        exe_dir = Path(sys.executable).parent\n        ollama_dir = exe_dir / 'ollama'\n        if ollama_dir.exists():\n            return ollama_dir\n\n    # Development mode - check bin directory\n    dev_ollama_dir = Path(__file__).parent.parent / 'bin'\n    if dev_ollama_dir.exists() and (dev_ollama_dir / 'ollama').exists():\n        return dev_ollama_dir\n\n    return None\n\n\ndef get_ollama_binary() -> Optional[Path]:\n    \"\"\"\n    Get the path to the Ollama binary.\n\n    Checks in order:\n    1. Bundled Ollama (in PyInstaller bundle or dev bin/)\n    2. System Ollama (in PATH or common locations)\n\n    Returns:\n        Path to ollama binary, or None if not found\n    \"\"\"\n    # Check bundled first\n    bundled_dir = get_bundled_ollama_dir()\n    if bundled_dir:\n        ollama_path = bundled_dir / 'ollama'\n        if ollama_path.exists():\n            logger.info(f\"Using bundled Ollama: {ollama_path}\")\n            return ollama_path\n\n    # Fall back to system Ollama\n    system_paths = [\n        '/opt/homebrew/bin/ollama',  # Homebrew on Apple Silicon\n        '/usr/local/bin/ollama',     # Homebrew on Intel\n        '/usr/bin/ollama',           # System installation\n    ]\n\n    # Check PATH first\n    try:\n        result = subprocess.run(['which', 'ollama'], capture_output=True, text=True, timeout=5)\n        if result.returncode == 0:\n            path = Path(result.stdout.strip())\n            if path.exists():\n                logger.info(f\"Using system Ollama from PATH: {path}\")\n                return path\n    except Exception:\n        pass\n\n    # Check common locations\n    for path_str in system_paths:\n        path = Path(path_str)\n        if path.exists():\n            logger.info(f\"Using system Ollama: {path}\")\n            return path\n\n    logger.warning(\"No Ollama binary found\")\n    return None\n\n\ndef get_ollama_env() -> dict:\n    \"\"\"\n    Get environment variables needed to run bundled Ollama.\n\n    Sets up library paths for the bundled dylibs.\n\n    Returns:\n        Dictionary of environment variables\n    \"\"\"\n    env = os.environ.copy()\n\n    bundled_dir = get_bundled_ollama_dir()\n    if bundled_dir:\n        # Add bundled directory to library path for dylibs\n        ollama_dir_str = str(bundled_dir)\n\n        # macOS uses DYLD_LIBRARY_PATH\n        existing = env.get('DYLD_LIBRARY_PATH', '')\n        if existing:\n            env['DYLD_LIBRARY_PATH'] = f\"{ollama_dir_str}:{existing}\"\n        else:\n            env['DYLD_LIBRARY_PATH'] = ollama_dir_str\n\n        # Also set for Metal library\n        env['MLX_METAL_PATH'] = str(bundled_dir / 'mlx.metallib')\n\n        logger.debug(f\"Set DYLD_LIBRARY_PATH: {env['DYLD_LIBRARY_PATH']}\")\n\n    return env\n\n\ndef is_ollama_running() -> bool:\n    \"\"\"\n    Check if Ollama server is running.\n\n    Returns:\n        True if Ollama is responding, False otherwise\n    \"\"\"\n    try:\n        import httpx\n        response = httpx.get('http://127.0.0.1:11434/api/tags', timeout=2)\n        return response.status_code == 200\n    except Exception:\n        return False\n\n\n\ndef _get_pid_file() -> Path:\n    \"\"\"Get the path to the Ollama PID file.\"\"\"\n    if getattr(sys, 'frozen', False):\n        return Path(sys._MEIPASS) / 'ollama.pid'\n    return Path(__file__).parent.parent / 'ollama.pid'\n\n\ndef _write_pid(pid: int) -> None:\n    \"\"\"Write Ollama's PID to a file so Electron can kill it on quit.\"\"\"\n    try:\n        _get_pid_file().write_text(str(pid))\n    except Exception as e:\n        logger.debug(f\"Could not write Ollama PID file: {e}\")\n\n\ndef _clear_pid() -> None:\n    \"\"\"Remove the PID file.\"\"\"\n    try:\n        _get_pid_file().unlink(missing_ok=True)\n    except Exception:\n        pass\n\n\ndef start_ollama_server(wait: bool = True, timeout: int = 30) -> bool:\n    \"\"\"\n    Start the Ollama server if not already running.\n\n    Args:\n        wait: If True, wait for server to be ready\n        timeout: Maximum seconds to wait for server\n\n    Returns:\n        True if server is running, False if failed to start\n    \"\"\"\n    if is_ollama_running():\n        logger.info(\"Ollama server is already running\")\n        return True\n\n    ollama_binary = get_ollama_binary()\n    if not ollama_binary:\n        logger.error(\"Cannot start Ollama - binary not found\")\n        return False\n\n    try:\n        env = get_ollama_env()\n\n        # Start Ollama server in background\n        logger.info(f\"Starting Ollama server: {ollama_binary}\")\n        proc = subprocess.Popen(\n            [str(ollama_binary), 'serve'],\n            env=env,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n            start_new_session=True  # Detach from parent process\n        )\n        _write_pid(proc.pid)\n\n        if not wait:\n            return True\n\n        # Wait for server to be ready\n        start_time = time.time()\n        while time.time() - start_time < timeout:\n            if is_ollama_running():\n                logger.info(\"Ollama server is ready\")\n                return True\n            time.sleep(0.5)\n\n        logger.error(f\"Ollama server did not start within {timeout} seconds\")\n        return False\n\n    except Exception as e:\n        logger.error(f\"Failed to start Ollama server: {e}\")\n        return False\n\n\ndef run_ollama_command(args: list, timeout: int = 300) -> Tuple[bool, str, str]:\n    \"\"\"\n    Run an Ollama CLI command.\n\n    Args:\n        args: Command arguments (e.g., ['pull', 'llama3.2:3b'])\n        timeout: Command timeout in seconds\n\n    Returns:\n        Tuple of (success, stdout, stderr)\n    \"\"\"\n    ollama_binary = get_ollama_binary()\n    if not ollama_binary:\n        return False, \"\", \"Ollama binary not found\"\n\n    try:\n        env = get_ollama_env()\n        result = subprocess.run(\n            [str(ollama_binary)] + args,\n            env=env,\n            capture_output=True,\n            text=True,\n            timeout=timeout\n        )\n        return result.returncode == 0, result.stdout, result.stderr\n    except subprocess.TimeoutExpired:\n        return False, \"\", f\"Command timed out after {timeout} seconds\"\n    except Exception as e:\n        return False, \"\", str(e)\n\n\ndef pull_model(model_name: str, progress_callback=None) -> bool:\n    \"\"\"\n    Pull an Ollama model.\n\n    Args:\n        model_name: Name of model to pull (e.g., 'llama3.2:3b')\n        progress_callback: Optional callback function for progress updates\n\n    Returns:\n        True if model was pulled successfully\n    \"\"\"\n    # Ensure server is running\n    if not start_ollama_server():\n        return False\n\n    ollama_binary = get_ollama_binary()\n    if not ollama_binary:\n        return False\n\n    try:\n        env = get_ollama_env()\n\n        logger.info(f\"Pulling model: {model_name}\")\n\n        # Run pull command with streaming output\n        process = subprocess.Popen(\n            [str(ollama_binary), 'pull', model_name],\n            env=env,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.STDOUT,\n            text=True,\n            bufsize=1\n        )\n\n        # Stream output\n        for line in iter(process.stdout.readline, ''):\n            line = line.strip()\n            if line:\n                logger.debug(f\"Ollama pull: {line}\")\n                if progress_callback:\n                    progress_callback(line)\n\n        process.wait()\n\n        if process.returncode == 0:\n            logger.info(f\"Successfully pulled model: {model_name}\")\n            return True\n        else:\n            logger.error(f\"Failed to pull model: {model_name}\")\n            return False\n\n    except Exception as e:\n        logger.error(f\"Error pulling model: {e}\")\n        return False\n\n\ndef list_models() -> list:\n    \"\"\"\n    List available Ollama models.\n\n    Returns:\n        List of model names, or empty list if failed\n    \"\"\"\n    if not is_ollama_running():\n        if not start_ollama_server():\n            return []\n\n    success, stdout, stderr = run_ollama_command(['list'], timeout=10)\n    if not success:\n        return []\n\n    models = []\n    for line in stdout.strip().split('\\n')[1:]:  # Skip header\n        if line.strip():\n            parts = line.split()\n            if parts:\n                models.append(parts[0])\n\n    return models\n\n\ndef has_model(model_name: str) -> bool:\n    \"\"\"\n    Check if a model is available locally.\n\n    Args:\n        model_name: Name of model to check\n\n    Returns:\n        True if model is available\n    \"\"\"\n    models = list_models()\n    return model_name in models\n"
  },
  {
    "path": "src/summarizer.py",
    "content": "try:\n    import ollama\n    OLLAMA_AVAILABLE = True\nexcept ImportError:\n    ollama = None\n    OLLAMA_AVAILABLE = False\nimport json\nimport logging\nimport subprocess\nimport time\nimport os\nfrom typing import Optional, Dict, Any\nfrom .models import MeetingTranscript, ActionItem, Decision\nfrom . import ollama_manager\n\nlogger = logging.getLogger(__name__)\n\n\nclass OllamaSummarizer:\n    def __init__(self, model_name: Optional[str] = None):\n        \"\"\"\n        Initialize the summarizer with automatic service management.\n        Supports local Ollama, remote Ollama, and cloud API providers.\n\n        Args:\n            model_name: Name of the model to use. If None, loads from config.\n        \"\"\"\n        from .config import get_config\n        config = get_config()\n\n        self.ai_provider = config.get_ai_provider()\n        self.client = None\n        self.cloud_client = None\n        self.anthropic_client = None\n        self.cloud_provider = None\n        self.ollama_process = None\n        self.remote_url = config.get_remote_ollama_url()\n\n        if self.ai_provider == \"cloud\":\n            # Cloud mode: use OpenAI-compatible or Anthropic API\n            cloud_api_key = config.get_cloud_api_key()\n            self.cloud_provider = config.get_cloud_provider()\n            cloud_api_url = config.get_cloud_api_url()\n            self.model_name = model_name or config.get_cloud_model()\n\n            if not cloud_api_key:\n                raise ValueError(\"Cloud API key is not configured. Set it in Settings > AI.\")\n\n            if self.cloud_provider == \"anthropic\":\n                try:\n                    from anthropic import Anthropic\n                except ImportError:\n                    raise ImportError(\"anthropic package is required for Anthropic cloud mode. pip install anthropic\")\n                self.anthropic_client = Anthropic(api_key=cloud_api_key)\n                logger.info(f\"Anthropic provider initialized: model={self.model_name}\")\n            else:\n                try:\n                    from openai import OpenAI\n                except ImportError:\n                    raise ImportError(\"openai package is required for cloud mode. pip install openai\")\n                base_url = cloud_api_url if self.cloud_provider == \"custom\" and cloud_api_url else None\n                self.cloud_client = OpenAI(api_key=cloud_api_key, base_url=base_url)\n                logger.info(f\"Cloud provider initialized: model={self.model_name}\")\n\n        elif self.ai_provider == \"remote\":\n            # Remote mode: connect to user's Ollama on LAN\n            if not OLLAMA_AVAILABLE:\n                raise ImportError(\"Ollama is not installed. Please install ollama-python.\")\n\n            if model_name is None:\n                model_name = config.get_model()\n                logger.info(f\"Using configured model: {model_name}\")\n            self.model_name = model_name\n\n            if not self.remote_url:\n                raise ValueError(\"Remote Ollama URL is not configured. Set it in Settings > AI.\")\n\n            self.client = ollama.Client(host=self.remote_url)\n            logger.info(f\"Remote Ollama initialized: host={self.remote_url}, model={self.model_name}\")\n\n        else:\n            # Local mode: existing behavior\n            if not OLLAMA_AVAILABLE:\n                raise ImportError(\"Ollama is not installed. Please install ollama-python.\")\n\n            if model_name is None:\n                try:\n                    model_name = config.get_model()\n                    logger.info(f\"Using configured model: {model_name}\")\n                except Exception as e:\n                    logger.warning(f\"Failed to load model from config: {e}, using default\")\n                    model_name = \"llama3.2:3b\"\n\n            self.model_name = model_name\n            self._ensure_ollama_ready()\n            self.client = ollama.Client()\n    \n    def _is_ollama_running(self) -> bool:\n        \"\"\"Check if Ollama service is running.\"\"\"\n        return ollama_manager.is_ollama_running()\n    \n    def _find_ollama_path(self) -> Optional[str]:\n        \"\"\"Find the Ollama executable path (bundled or system).\"\"\"\n        ollama_path = ollama_manager.get_ollama_binary()\n        if ollama_path:\n            return str(ollama_path)\n        logger.error(\"Ollama executable not found\")\n        return None\n    \n    def _start_ollama_service(self) -> bool:\n        \"\"\"Start the Ollama service if not running.\"\"\"\n        logger.info(\"Starting Ollama service...\")\n        return ollama_manager.start_ollama_server(wait=True, timeout=30)\n    \n    def _repair_json(self, json_text: str) -> Optional[str]:\n        \"\"\"\n        Attempt to repair common JSON formatting issues.\n        \n        Args:\n            json_text: The malformed JSON string\n            \n        Returns:\n            Repaired JSON string or None if repair fails\n        \"\"\"\n        try:\n            logger.info(\"Attempting JSON repair...\")\n            repaired = json_text\n            \n            # Common repair patterns\n            repairs = [\n                # Fix unquoted strings in arrays (the original issue)\n                (r'(\\[|\\,)\\s*([^\"\\[\\]{},:]+?)\\s*(\\]|\\,)', r'\\1 \"\\2\" \\3'),\n                # Fix trailing commas\n                (r',\\s*}', '}'),\n                (r',\\s*]', ']'),\n                # Fix missing quotes around object keys\n                (r'(\\{|\\,)\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\s*:', r'\\1 \"\\2\":'),\n                # Fix single quotes to double quotes\n                (r\"'([^']*)'\", r'\"\\1\"'),\n            ]\n            \n            for pattern, replacement in repairs:\n                import re\n                old_repaired = repaired\n                repaired = re.sub(pattern, replacement, repaired)\n                if old_repaired != repaired:\n                    logger.info(f\"Applied repair: {pattern}\")\n            \n            # Test if repaired JSON is valid\n            json.loads(repaired)\n            logger.info(\"JSON repair successful\")\n            return repaired\n            \n        except Exception as e:\n            logger.error(f\"JSON repair failed: {e}\")\n            return None\n    \n    def _create_enhanced_fallback(self, malformed_response: str, transcript: str, duration_minutes: int) -> MeetingTranscript:\n        \"\"\"\n        Create an enhanced fallback summary by extracting whatever data we can.\n        \n        Args:\n            malformed_response: The malformed JSON response from Ollama\n            transcript: Original transcript\n            duration_minutes: Meeting duration\n            \n        Returns:\n            MeetingTranscript with extracted data\n        \"\"\"\n        logger.info(\"Creating enhanced fallback summary...\")\n        \n        # Try to extract useful information from malformed response\n        overview = \"Meeting transcript was processed but JSON parsing failed.\"\n        participants = []\n        key_points = []\n        \n        try:\n            # Extract overview if present\n            if '\"overview\"' in malformed_response:\n                import re\n                overview_match = re.search(r'\"overview\":\\s*\"([^\"]*)\"', malformed_response)\n                if overview_match:\n                    overview = overview_match.group(1)\n                    logger.info(\"Extracted overview from malformed response\")\n            \n            # Extract participants if present\n            if '\"participants\"' in malformed_response:\n                # Try to find participant names between quotes\n                import re\n                participants_section = re.search(r'\"participants\":\\s*\\[(.*?)\\]', malformed_response, re.DOTALL)\n                if participants_section:\n                    # Extract quoted strings\n                    quoted_names = re.findall(r'\"([^\"]+)\"', participants_section.group(1))\n                    participants = quoted_names\n                    logger.info(f\"Extracted {len(participants)} participants from malformed response\")\n            \n            # Extract key points if present\n            if '\"key_points\"' in malformed_response:\n                import re\n                key_points_section = re.search(r'\"key_points\":\\s*\\[(.*?)\\]', malformed_response, re.DOTALL)\n                if key_points_section:\n                    # Extract quoted strings\n                    quoted_points = re.findall(r'\"([^\"]+)\"', key_points_section.group(1))\n                    key_points = quoted_points\n                    logger.info(f\"Extracted {len(key_points)} key points from malformed response\")\n            \n        except Exception as e:\n            logger.warning(f\"Failed to extract data from malformed response: {e}\")\n        \n        # Create fallback summary with extracted data\n        fallback_summary = MeetingTranscript(\n            duration=f\"{duration_minutes} minutes\",\n            overview=overview,\n            participants=participants,\n            next_steps=[],  # Create empty action items since parsing failed\n            key_points=[],  # Create empty key points since parsing failed  \n            transcript=transcript\n        )\n        \n        # Add key points\n        for point in key_points:\n            from .models import Decision\n            fallback_summary.key_points.append(Decision(\n                decision=point,\n                assignee='',\n                context='Extracted from partially parsed response'\n            ))\n        \n        logger.info(\"Created enhanced fallback summary with extracted data\")\n        return fallback_summary\n    \n    def _ensure_model_available(self) -> bool:\n        \"\"\"Ensure the required model is downloaded and available (uses HTTP API).\"\"\"\n        try:\n            # Use the ollama Python client (HTTP API) instead of the binary\n            # This avoids SIP/DYLD issues on macOS when running from a packaged app\n            response = ollama.list()\n            models = getattr(response, 'models', []) or []\n            model_names = [getattr(m, 'model', '') for m in models]\n\n            if self.model_name in model_names:\n                logger.info(f\"Model {self.model_name} is already available\")\n                return True\n\n            # Model not found, try to pull it\n            logger.info(f\"Downloading model {self.model_name}...\")\n            try:\n                ollama.pull(self.model_name)\n                logger.info(f\"Successfully downloaded model {self.model_name}\")\n                return True\n            except Exception as e:\n                logger.error(f\"Failed to download model {self.model_name}: {e}\")\n\n            # Try fallback models from supported list\n            fallback_models = [\"llama3.2:3b\", \"gemma3:4b\", \"qwen3.5:9b\", \"deepseek-r1:14b\"]\n            for fallback in fallback_models:\n                if fallback in model_names:\n                    logger.info(f\"Using already-installed fallback model: {fallback}\")\n                    self.model_name = fallback\n                    return True\n\n            for fallback in fallback_models:\n                logger.info(f\"Trying fallback model: {fallback}\")\n                try:\n                    ollama.pull(fallback)\n                    logger.info(f\"Successfully downloaded fallback model {fallback}\")\n                    self.model_name = fallback\n                    return True\n                except Exception:\n                    continue\n\n            return False\n\n        except Exception as e:\n            logger.error(f\"Error ensuring model availability: {e}\")\n            return False\n    \n    def _ensure_ollama_ready(self) -> bool:\n        \"\"\"Ensure Ollama service is running and model is available.\"\"\"\n        logger.info(\"Checking Ollama service...\")\n        \n        # Step 1: Check if Ollama is running\n        if not self._is_ollama_running():\n            if not self._start_ollama_service():\n                raise Exception(\"Failed to start Ollama service\")\n        else:\n            logger.info(\"Ollama service is already running\")\n        \n        # Step 2: Ensure model is available\n        if not self._ensure_model_available():\n            raise Exception(f\"Failed to ensure model {self.model_name} is available\")\n        \n        logger.info(f\"Ollama ready with model {self.model_name}\")\n        return True\n        \n    def _cloud_chat(self, prompt: str, timeout_seconds: int = 300) -> str:\n        \"\"\"\n        Send a chat request via the configured cloud API (OpenAI or Anthropic).\n\n        Args:\n            prompt: The user prompt to send\n            timeout_seconds: Request timeout in seconds\n\n        Returns:\n            The assistant's response text\n        \"\"\"\n        if self.cloud_provider == \"anthropic\":\n            return self._anthropic_chat(prompt, timeout_seconds)\n        return self._openai_chat(prompt, timeout_seconds)\n\n    def _openai_chat(self, prompt: str, timeout_seconds: int = 300) -> str:\n        \"\"\"Send a chat request via the OpenAI-compatible cloud API.\"\"\"\n        max_retries = 3\n        for attempt in range(max_retries):\n            try:\n                if attempt > 0:\n                    logger.info(f\"Cloud API retry attempt {attempt + 1}/{max_retries}\")\n                    time.sleep(5)\n\n                response = self.cloud_client.chat.completions.create(\n                    model=self.model_name,\n                    messages=[{\"role\": \"user\", \"content\": prompt}],\n                    timeout=timeout_seconds,\n                )\n                return response.choices[0].message.content.strip()\n\n            except Exception as e:\n                logger.error(f\"Cloud API attempt {attempt + 1} failed: {e}\")\n                if attempt == max_retries - 1:\n                    raise\n        raise RuntimeError(\"OpenAI chat failed after all retries\")\n\n    def _anthropic_chat(self, prompt: str, timeout_seconds: int = 300) -> str:\n        \"\"\"Send a chat request via the Anthropic Messages API.\"\"\"\n        max_retries = 3\n        for attempt in range(max_retries):\n            try:\n                if attempt > 0:\n                    logger.info(f\"Anthropic API retry attempt {attempt + 1}/{max_retries}\")\n                    time.sleep(5)\n\n                response = self.anthropic_client.messages.create(\n                    model=self.model_name,\n                    max_tokens=4096,\n                    messages=[{\"role\": \"user\", \"content\": prompt}],\n                    timeout=timeout_seconds,\n                )\n                # Anthropic returns content blocks; extract text\n                if not response.content:\n                    raise RuntimeError(\"Anthropic returned empty response\")\n                return response.content[0].text.strip()\n\n            except Exception as e:\n                logger.error(f\"Anthropic API attempt {attempt + 1} failed: {e}\")\n                if attempt == max_retries - 1:\n                    raise\n        raise RuntimeError(\"Anthropic chat failed after all retries\")\n\n    def _create_permissive_prompt(self, transcript: str, language: str = \"en\", notes: str = None) -> str:\n        \"\"\"\n        Create an enhanced prompt with discussion_areas and improved extraction.\n        Uses more examples in schema to permit more detailed summaries.\n        \"\"\"\n        # Build language instruction\n        if language and language not in (\"en\", \"auto\"):\n            from .config import get_config\n            language_name = get_config().get_language_name(language)\n            if language_name != \"Unknown\":\n                language_instruction = f\"\\n\\nCRITICAL: Respond in {language_name}. All text values in the JSON below MUST be written in {language_name}.\"\n            else:\n                language_instruction = \"\"\n        else:\n            language_instruction = \"\"\n\n        # Add speaker label context when diarised transcript is provided\n        diarisation_note = \"\"\n        if \"[You]\" in transcript and \"[Others]\" in transcript:\n            diarisation_note = \"\"\"NOTE: This transcript has speaker labels. [You] is the person who recorded\nthe meeting. [Others] are remote participants heard through system audio.\nAttribute statements to speakers in your summary where relevant.\n\n\"\"\"\n\n        # Add user notes context if provided\n        notes_context = \"\"\n        if notes and notes.strip():\n            notes_context = f\"\"\"USER NOTES (written by the meeting participant during the recording):\n{notes.strip()}\n\nUse these notes as additional context to improve your summary. They may contain\nnames, jargon, or context that helps interpret the transcript more accurately.\n\n\"\"\"\n\n        return f\"\"\"{diarisation_note}{notes_context}You are a helpful meeting assistant. Summarise this meeting transcript into discussion areas, key points and any next steps mentioned. Only base your summary on what was explicitly discussed in the transcript.\n\nIMPORTANT: Do not infer or assume information that wasn't directly mentioned.\n\nInclude a brief overview so someone can quickly understand what happened in the meeting, what areas/topics were discussed, what were the key points, and what are the next steps if any were mentioned.\n\nCRITICAL JSON FORMATTING RULES:\n1. ALL strings must be enclosed in double quotes \"like this\"\n2. Use null (not \"null\") for empty values\n3. NO trailing commas anywhere\n4. NO comments or extra text outside the JSON\n5. ALL array elements must be properly quoted strings\n6. If no discussion areas, key points, or next steps are mentioned, return an empty array [] for that field.\n\nIMPORTANT - VARIABLE NUMBER OF ITEMS:\n- Discussion areas: Include as many as needed to organize the topics (1-2 for short meetings, 4-5 for complex discussions)\n- Key points: Extract as many as were actually discussed (2-3 for short meetings, 6-8 for detailed discussions)\n- Next steps: Include only action items that were clearly mentioned (could be 1, could be 6+)\n- The examples below are illustrative - do not feel obligated to match the exact number shown\n\nCORRECT FORMAT EXAMPLE:\n{{\n  \"key_points\": [\"Budget discussion\", \"Timeline review\"]\n}}\n\nINCORRECT FORMAT (DO NOT DO THIS):\n{{\n  \"key_points\": [\"Budget\", timeline,]\n}}\n\nTRANSCRIPT:\n{transcript}\n{language_instruction}\nReturn ONLY the response in this exact JSON format:\n{{\n  \"overview\": \"Brief overview of what happened in the meeting\",\n  \"discussion_areas\": [\n    {{\n      \"title\": \"First main topic discussed\",\n      \"analysis\": \"Short paragraph about what was discussed in this topic\"\n    }},\n    {{\n      \"title\": \"Second main topic discussed\",\n      \"analysis\": \"Short paragraph about what was discussed in this topic\"\n    }},\n    {{\n      \"title\": \"Third main topic discussed\",\n      \"analysis\": \"Short paragraph about what was discussed in this topic\"\n    }}\n  ],\n  \"key_points\": [\n    \"First important point or topic discussed\",\n    \"Second key point from the meeting\",\n    \"Third key point from the meeting\",\n    \"Fourth key point from the meeting\",\n    \"Fifth key point from the meeting\"\n  ],\n  \"next_steps\": [\n    {{\n      \"description\": \"First next step or action item as explicitly mentioned\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }},\n    {{\n      \"description\": \"Second next step or action item\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }},\n    {{\n      \"description\": \"Third next step or action item\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }},\n    {{\n      \"description\": \"Fourth next step or action item\",\n      \"assignee\": \"Person responsible or null if unclear\",\n      \"deadline\": \"Deadline mentioned or null\"\n    }}\n  ]\n}}\"\"\"\n\n    def summarize_transcript(self, transcript: str, duration_minutes: int, language: str = \"en\", notes: str = None) -> Optional[MeetingTranscript]:\n        \"\"\"\n        Summarize a meeting transcript using Ollama.\n\n        Args:\n            transcript: The meeting transcript text\n            duration_minutes: Duration of the meeting in minutes\n            language: Language code for the summary output\n\n        Returns:\n            MeetingTranscript object or None if summarization failed\n        \"\"\"\n        try:\n            # Handle empty or None transcripts\n            if not transcript or transcript.strip() == \"\" or transcript.lower().strip() == \"none\":\n                logger.warning(\"Empty or None transcript provided, returning placeholder summary\")\n                return MeetingTranscript(\n                    overview=\"No transcript was generated for this recording. This may be due to poor audio quality, silence throughout the recording, or technical issues with the speech recognition system.\",\n                    participants=[],\n                    action_items=[],\n                    decisions=[],\n                    duration_minutes=duration_minutes\n                )\n            \n            prompt = self._create_permissive_prompt(transcript, language, notes=notes)\n            logger.info(f\"Sending transcript to {self.ai_provider} model: {self.model_name}\")\n            logger.info(f\"Transcript length: {len(transcript)} characters\")\n\n            # Calculate dynamic timeout based on transcript length\n            # Base 30 min + 10 min per 10k chars, capped at 2 hours\n            base_timeout = 1800  # 30 minutes\n            extra_timeout = (len(transcript) // 10000) * 600  # 10 min per 10k chars\n            timeout_seconds = min(base_timeout + extra_timeout, 7200)  # Cap at 2 hours\n            logger.info(f\"Using timeout: {timeout_seconds} seconds ({timeout_seconds // 60} minutes)\")\n\n            if self.ai_provider == \"cloud\":\n                response_text = self._cloud_chat(prompt, timeout_seconds)\n            else:\n                # Retry logic for Ollama API calls (local or remote)\n                max_retries = 3\n                ollama_response = None\n                for attempt in range(max_retries):\n                    try:\n                        if attempt > 0:\n                            logger.info(f\"Retry attempt {attempt + 1}/{max_retries}\")\n                            if self.ai_provider == \"remote\":\n                                self.client = ollama.Client(host=self.remote_url)\n                            else:\n                                self._ensure_ollama_ready()\n                                self.client = ollama.Client()\n\n                        ollama_response = self.client.chat(\n                            model=self.model_name,\n                            messages=[\n                                {\n                                    'role': 'user',\n                                    'content': prompt\n                                }\n                            ],\n                        )\n                        break  # Success, exit retry loop\n\n                    except Exception as e:\n                        logger.error(f\"Ollama API attempt {attempt + 1} failed: {e}\")\n                        if attempt == max_retries - 1:\n                            raise\n                        else:\n                            logger.info(\"Waiting 5 seconds before retry...\")\n                            time.sleep(5)\n\n                response_text = ollama_response['message']['content'].strip()\n\n            logger.info(f\"Received response from {self.ai_provider}\")\n            logger.info(f\"Response length: {len(response_text)} characters\")\n            logger.info(f\"Response preview: {response_text[:200]}...\")\n            \n            # Try to parse JSON response with repair functionality\n            try:\n                # Remove any markdown formatting\n                if response_text.startswith('```json'):\n                    response_text = response_text.replace('```json', '').replace('```', '').strip()\n                elif response_text.startswith('```'):\n                    response_text = response_text.replace('```', '').strip()\n                \n                # Handle preamble text like \"Here is the extracted information in JSON format:\"\n                if '{' in response_text and '}' in response_text:\n                    # Find the first { and last } to extract just the JSON\n                    json_start = response_text.find('{')\n                    json_end = response_text.rfind('}') + 1\n                    response_text = response_text[json_start:json_end].strip()\n                \n                # First attempt - try parsing as-is\n                structured_data = json.loads(response_text)\n                logger.info(\"Successfully parsed JSON response\")\n                \n            except json.JSONDecodeError as e:\n                logger.error(f\"Ollama returned invalid JSON: {e}\")\n                logger.error(f\"JSON parse error at position: {e.pos}\")\n                logger.error(f\"Full Ollama response: {response_text}\")\n                logger.info(\"Attempting simple JSON repair for unquoted strings...\")\n                \n                # Simple fix for unquoted strings in arrays (the actual issue we encountered)\n                import re\n                repaired_json = re.sub(r'(\\[|\\,)\\s*([^\"\\[\\]{},:]+?)\\s*(\\]|\\,)', r'\\1 \"\\2\" \\3', response_text)\n                \n                try:\n                    structured_data = json.loads(repaired_json)\n                    logger.info(\"Successfully parsed repaired JSON response\")\n                except json.JSONDecodeError:\n                    logger.error(\"JSON repair failed, creating fallback summary\")\n                    \n                    # Create simple fallback summary with original user-friendly message\n                    fallback_summary = MeetingTranscript(\n                        duration=f\"{duration_minutes} minutes\",\n                        overview=\"Meeting transcript recorded but detailed analysis failed. Content appears to be in a non-English language or format not fully supported.\",\n                        participants=[],\n                        next_steps=[],\n                        key_points=[],\n                        transcript=transcript\n                    )\n                    logger.info(\"Created fallback summary\")\n                    return fallback_summary\n            \n            # Create MeetingTranscript object\n            try:\n                # Parse next steps (formerly key_actions)\n                actions = []\n                for action_data in structured_data.get('next_steps', []):\n                    actions.append(ActionItem(\n                        description=action_data.get('description', ''),\n                        assignee=action_data.get('assignee', '') or '',\n                        deadline=action_data.get('deadline')\n                    ))\n                \n                # Parse key points as decisions (keeping the same data structure for compatibility)\n                decisions = []\n                for point in structured_data.get('key_points', []):\n                    if isinstance(point, str):\n                        # Simple string format\n                        decisions.append(Decision(\n                            decision=point,\n                            assignee='',\n                            context=''\n                        ))\n                    elif isinstance(point, dict):\n                        # Object format (fallback for complex key points)\n                        decisions.append(Decision(\n                            decision=point.get('point', ''),\n                            assignee='',\n                            context=point.get('context', '')\n                        ))\n\n                # Parse discussion areas (new field from permissive prompt)\n                from .models import DiscussionArea\n                discussion_areas = []\n                for area_data in structured_data.get('discussion_areas', []):\n                    if isinstance(area_data, dict):\n                        discussion_areas.append(DiscussionArea(\n                            title=area_data.get('title', ''),\n                            analysis=area_data.get('analysis', '')\n                        ))\n\n                meeting_summary = MeetingTranscript(\n                    duration=f\"{duration_minutes} minutes\",\n                    overview=structured_data.get('overview', ''),\n                    participants=structured_data.get('participants', []),\n                    discussion_areas=discussion_areas,\n                    next_steps=actions,\n                    key_points=decisions,\n                    transcript=transcript\n                )\n                \n                logger.info(\"Successfully created MeetingTranscript object\")\n                return meeting_summary\n                \n            except Exception as e:\n                logger.error(f\"Error creating MeetingTranscript object: {e}\")\n                return None\n                \n        except Exception as e:\n            logger.error(f\"Ollama API call failed: {e}\")\n            logger.error(f\"Model used: {self.model_name}\")\n            logger.error(f\"Transcript length: {len(transcript)} characters\")\n            logger.error(f\"Error type: {type(e).__name__}\")\n            if hasattr(e, 'response'):\n                logger.error(f\"HTTP response: {e.response}\")\n            return None\n    \n    def _create_markdown_prompt(self, transcript: str, language: str = \"en\", notes: str = None) -> str:\n        \"\"\"Create a prompt that asks the LLM to output markdown directly.\"\"\"\n        # Language instruction\n        if language and language not in (\"en\", \"auto\"):\n            from .config import get_config\n            language_name = get_config().get_language_name(language)\n            if language_name != \"Unknown\":\n                language_instruction = f\"\\n\\nCRITICAL: Write the entire output in {language_name}.\"\n            else:\n                language_instruction = \"\"\n        else:\n            language_instruction = \"\"\n\n        # Diarisation context\n        diarisation_note = \"\"\n        if \"[You]\" in transcript and \"[Others]\" in transcript:\n            diarisation_note = \"NOTE: [You] is the recorder, [Others] are remote participants.\\n\\n\"\n\n        # User notes context\n        notes_context = \"\"\n        if notes and notes.strip():\n            notes_context = f\"USER NOTES (written during the meeting):\\n{notes.strip()}\\n\\n\"\n\n        return f\"\"\"{diarisation_note}{notes_context}Summarise this meeting transcript as markdown. Output ONLY the markdown below with no preamble, commentary, or explanation. Start directly with ## Summary.\n\n## Summary\nA 1-3 sentence overview of what was discussed.\n\n## Key Topics\n### [Topic title]\nBrief analysis of what was discussed about this topic.\n\n(Repeat for each major topic)\n\n## Key Points\n- [Key point 1]\n- [Key point 2]\n\n## Action Items\n- [Action item 1]\n- [Action item 2]\n\nOnly include information explicitly discussed. Do not infer or assume.{language_instruction}\n\nTRANSCRIPT:\n{transcript}\"\"\"\n\n    def summarize_transcript_streaming(self, transcript: str, duration_minutes: int = 0, language: str = \"en\", notes: str = None):\n        \"\"\"Generator that yields markdown chunks from the LLM.\n\n        Args:\n            transcript: Meeting transcript text\n            duration_minutes: Duration of the meeting\n            language: Language code for output\n            notes: Optional user notes for context\n\n        Yields:\n            str: Text chunks as they arrive from the LLM\n        \"\"\"\n        prompt = self._create_markdown_prompt(transcript, language, notes)\n        logger.info(f\"Starting streaming summary with {self.ai_provider} model: {self.model_name}\")\n\n        if self.ai_provider == \"cloud\":\n            if self.cloud_provider == \"anthropic\":\n                try:\n                    with self.anthropic_client.messages.stream(\n                        model=self.model_name,\n                        max_tokens=4096,\n                        messages=[{\"role\": \"user\", \"content\": prompt}],\n                    ) as stream:\n                        for text in stream.text_stream:\n                            yield text\n                except Exception as e:\n                    logger.error(f\"Anthropic streaming failed: {e}\")\n                    return\n            else:\n                try:\n                    response = self.cloud_client.chat.completions.create(\n                        model=self.model_name,\n                        messages=[{\"role\": \"user\", \"content\": prompt}],\n                        stream=True,\n                    )\n                    for chunk in response:\n                        if not chunk.choices:\n                            continue\n                        content = chunk.choices[0].delta.content or \"\"\n                        if content:\n                            yield content\n                except Exception as e:\n                    logger.error(f\"OpenAI streaming failed: {e}\")\n                    return\n        else:\n            # Ollama (local or remote)\n            try:\n                if self.ai_provider != \"remote\":\n                    self._ensure_ollama_ready()\n                response = self.client.chat(\n                    model=self.model_name,\n                    messages=[{'role': 'user', 'content': prompt}],\n                    stream=True,\n                )\n                for chunk in response:\n                    content = chunk.get('message', {}).get('content', '')\n                    if content:\n                        yield content\n            except Exception as e:\n                logger.error(f\"Ollama streaming failed: {e}\")\n                return\n\n    def test_connection(self) -> bool:\n        \"\"\"\n        Test connection to Ollama.\n        \n        Returns:\n            True if connection is successful\n        \"\"\"\n        try:\n            models = self.client.list()\n            available_models = [model.model for model in models.models]\n            \n            if self.model_name not in available_models:\n                logger.warning(f\"Model {self.model_name} not found. Available models: {available_models}\")\n                if available_models:\n                    self.model_name = available_models[0]\n                    logger.info(f\"Using available model: {self.model_name}\")\n                else:\n                    logger.error(\"No models available in Ollama\")\n                    return False\n            \n            # Test with a simple prompt\n            test_response = self.client.chat(\n                model=self.model_name,\n                messages=[{'role': 'user', 'content': 'Hello'}]\n            )\n            \n            logger.info(\"Ollama connection test successful\")\n            logger.debug(f\"Test response: {test_response.get('message', {}).get('content', '')[:50]}...\")\n            return True\n            \n        except Exception as e:\n            logger.error(f\"Ollama connection test failed: {e}\")\n            return False\n    \n    def set_model(self, model_name: str) -> bool:\n        \"\"\"\n        Change the Ollama model.\n        \n        Args:\n            model_name: Name of the new model\n            \n        Returns:\n            True if model is available and set successfully\n        \"\"\"\n        try:\n            models = self.client.list()\n            available_models = [model.model for model in models.models]\n            \n            if model_name in available_models:\n                self.model_name = model_name\n                logger.info(f\"Model changed to: {model_name}\")\n                return True\n            else:\n                logger.error(f\"Model {model_name} not available. Available models: {available_models}\")\n                return False\n                \n        except Exception as e:\n            logger.error(f\"Error setting model: {e}\")\n            return False\n    \n    def cleanup(self):\n        \"\"\"Clean up Ollama process if we started it.\"\"\"\n        if self.ollama_process:\n            try:\n                self.ollama_process.terminate()\n                self.ollama_process.wait(timeout=10)\n                logger.info(\"Ollama service process terminated\")\n            except:\n                try:\n                    self.ollama_process.kill()\n                    logger.info(\"Ollama service process killed\")\n                except:\n                    pass\n            self.ollama_process = None\n    \n    def __del__(self):\n        \"\"\"Cleanup when object is destroyed.\"\"\"\n        self.cleanup()\n\n    def generate_title(self, summary: str, transcript: str, language: str = \"en\") -> Optional[str]:\n        \"\"\"\n        Generate a short, descriptive meeting title from the summary and transcript.\n\n        Args:\n            summary: The meeting overview/summary text\n            transcript: The raw transcript text (used as fallback context)\n            language: Language code for the title\n\n        Returns:\n            A short title string, or None if generation failed\n        \"\"\"\n        try:\n            # Use summary if available, otherwise fall back to first part of transcript\n            context = summary if summary else transcript[:2000]\n            if not context or context.strip() == \"\":\n                return None\n\n            # Build language instruction\n            if language and language not in (\"en\", \"auto\"):\n                from .config import get_config\n                language_name = get_config().get_language_name(language)\n                if language_name != \"Unknown\":\n                    lang_instruction = f\" The title MUST be in {language_name}.\"\n                else:\n                    lang_instruction = \"\"\n            else:\n                lang_instruction = \"\"\n\n            prompt = f\"\"\"Generate a short, descriptive title for this meeting based on the summary below.\n\nRULES:\n1. Maximum 6 words\n2. No quotes, no punctuation, no prefixes like \"Meeting:\" or \"Title:\"\n3. Just the title text, nothing else\n4. Capture the main topic or purpose of the meeting{lang_instruction}\n\nSUMMARY:\n{context}\n\nTITLE:\"\"\"\n\n            logger.info(\"Generating meeting title from summary\")\n\n            if self.ai_provider == \"cloud\":\n                response_text = self._cloud_chat(prompt, 30)\n            else:\n                # HTTP-level timeout must account for model cold-start (~10s Metal init)\n                title_client = ollama.Client(\n                    host=self.remote_url if self.ai_provider == \"remote\" else None,\n                    timeout=90\n                )\n                ollama_response = title_client.chat(\n                    model=self.model_name,\n                    messages=[{'role': 'user', 'content': prompt}],\n                )\n                response_text = ollama_response['message']['content'].strip()\n\n            # Clean up the response\n            title = response_text.strip().strip('\"').strip(\"'\").strip()\n            # Remove common prefixes the model might add\n            for prefix in [\"Title:\", \"Meeting:\", \"Meeting Title:\", \"title:\", \"meeting:\"]:\n                if title.lower().startswith(prefix.lower()):\n                    title = title[len(prefix):].strip()\n\n            # Enforce max length (6 words, ~60 chars)\n            words = title.split()\n            if len(words) > 6:\n                title = \" \".join(words[:6])\n\n            # Only return if we got something meaningful\n            if title and len(title) > 2:\n                logger.info(f\"Generated meeting title: {title}\")\n                return title\n\n            return None\n\n        except Exception as e:\n            logger.warning(f\"Failed to generate meeting title: {e}\")\n            return None\n\n    def _build_query_prompt(self, transcript: str, question: str, language: str = \"en\") -> str:\n        if language and language not in (\"en\", \"auto\"):\n            from .config import get_config\n            language_name = get_config().get_language_name(language)\n            query_lang_instruction = f\"\\nRespond in {language_name}.\" if language_name != \"Unknown\" else \"\"\n        else:\n            query_lang_instruction = \"\"\n        return f\"\"\"Answer the following question based on the meeting content below (summary, key topics, and transcript).\nBe concise and direct. If the answer requires inference from what was discussed, that's fine.\nOnly say you don't know if the topic truly wasn't discussed at all.{query_lang_instruction}\n\nQUESTION: {question}\n\n{transcript}\n\nANSWER:\"\"\"\n\n    def query_transcript_streaming(self, transcript: str, question: str, language: str = \"en\"):\n        \"\"\"Generator that yields text chunks from the LLM for a transcript query.\"\"\"\n        if not transcript or transcript.strip() == \"\":\n            yield \"No transcript available to query.\"\n            return\n        if not question or question.strip() == \"\":\n            yield \"Please provide a question.\"\n            return\n\n        prompt = self._build_query_prompt(transcript, question, language)\n\n        try:\n            if self.ai_provider == \"cloud\":\n                if self.cloud_provider == \"anthropic\":\n                    with self.anthropic_client.messages.stream(\n                        model=self.model_name,\n                        max_tokens=2048,\n                        messages=[{\"role\": \"user\", \"content\": prompt}],\n                    ) as stream:\n                        for text in stream.text_stream:\n                            yield text\n                else:\n                    response = self.cloud_client.chat.completions.create(\n                        model=self.model_name,\n                        messages=[{\"role\": \"user\", \"content\": prompt}],\n                        stream=True,\n                    )\n                    for chunk in response:\n                        # Some providers emit chunk variants with empty choices\n                        # (e.g. usage-only chunks); skip those instead of crashing.\n                        if not chunk.choices:\n                            continue\n                        content = chunk.choices[0].delta.content\n                        if content:\n                            yield content\n            else:\n                if self.ai_provider == \"remote\":\n                    self.client = ollama.Client(host=self.remote_url)\n                else:\n                    self._ensure_ollama_ready()\n                    self.client = ollama.Client()\n                stream = self.client.chat(\n                    model=self.model_name,\n                    messages=[{\"role\": \"user\", \"content\": prompt}],\n                    stream=True,\n                )\n                for chunk in stream:\n                    content = chunk['message']['content']\n                    if content:\n                        yield content\n        except Exception as e:\n            logger.error(f\"Streaming query failed: {e}\")\n            yield f\"\\n[Error: {e}]\"\n\n    def query_transcript(self, transcript: str, question: str, language: str = \"en\") -> Optional[str]:\n        \"\"\"\n        Query a transcript with a question using Ollama.\n\n        Args:\n            transcript: The meeting transcript text\n            question: The question to ask about the transcript\n            language: Language code for the response\n\n        Returns:\n            Answer string or None if query failed\n        \"\"\"\n        try:\n            if not transcript or transcript.strip() == \"\":\n                return \"No transcript available to query.\"\n\n            if not question or question.strip() == \"\":\n                return \"Please provide a question.\"\n\n            prompt = self._build_query_prompt(transcript, question, language)\n\n            logger.info(f\"Querying transcript with question: {question[:50]}...\")\n\n            if self.ai_provider == \"cloud\":\n                response_text = self._cloud_chat(prompt, 120)\n            else:\n                # Retry logic for Ollama API calls (local or remote)\n                max_retries = 2\n                for attempt in range(max_retries):\n                    try:\n                        if attempt > 0:\n                            logger.info(f\"Retry attempt {attempt + 1}/{max_retries}\")\n                            if self.ai_provider == \"remote\":\n                                self.client = ollama.Client(host=self.remote_url)\n                            else:\n                                self._ensure_ollama_ready()\n                                self.client = ollama.Client()\n\n                        ollama_response = self.client.chat(\n                            model=self.model_name,\n                            messages=[\n                                {\n                                    'role': 'user',\n                                    'content': prompt\n                                }\n                            ],\n                        )\n                        break\n\n                    except Exception as e:\n                        logger.error(f\"Ollama API attempt {attempt + 1} failed: {e}\")\n                        if attempt == max_retries - 1:\n                            raise\n                        else:\n                            logger.info(\"Waiting 2 seconds before retry...\")\n                            time.sleep(2)\n\n                response_text = ollama_response['message']['content'].strip()\n            logger.info(f\"Query response received: {len(response_text)} characters\")\n\n            return response_text\n\n        except Exception as e:\n            logger.error(f\"Query transcript failed: {e}\")\n            return None\n"
  },
  {
    "path": "src/transcriber.py",
    "content": "\"\"\"\nWhisper transcription module.\n\nSupports two backends:\n1. whisper.cpp (via pywhispercpp) - Lightweight, fast, recommended\n2. openai-whisper (PyTorch) - Original, heavier, fallback\n\nwhisper.cpp is preferred as it's 10x smaller and 2-4x faster.\n\"\"\"\n\nimport logging\nimport os\nimport subprocess\nimport tempfile\nfrom pathlib import Path\nfrom typing import Optional, Tuple\n\nlogger = logging.getLogger(__name__)\n\n# Try whisper.cpp first (preferred - smaller, faster)\ntry:\n    from pywhispercpp.model import Model as WhisperCppModel\n    WHISPER_CPP_AVAILABLE = True\n    logger.info(\"Using whisper.cpp backend (pywhispercpp)\")\nexcept ImportError:\n    WhisperCppModel = None\n    WHISPER_CPP_AVAILABLE = False\n\n# Fall back to openai-whisper if whisper.cpp not available\ntry:\n    import whisper as openai_whisper\n    OPENAI_WHISPER_AVAILABLE = True\nexcept ImportError:\n    openai_whisper = None\n    OPENAI_WHISPER_AVAILABLE = False\n\nWHISPER_AVAILABLE = WHISPER_CPP_AVAILABLE or OPENAI_WHISPER_AVAILABLE\n\n\nclass WhisperTranscriber:\n    \"\"\"\n    Whisper-based audio transcription.\n\n    Automatically uses whisper.cpp if available (faster, smaller),\n    falls back to openai-whisper (PyTorch) if not.\n    \"\"\"\n\n    def __init__(self, model_size: str = \"small\"):\n        \"\"\"\n        Initialize the Whisper transcriber.\n\n        Args:\n            model_size: Whisper model size (tiny, base, small, medium, large)\n        \"\"\"\n        if not WHISPER_AVAILABLE:\n            raise ImportError(\n                \"No Whisper backend available. Install pywhispercpp (recommended) \"\n                \"or openai-whisper: pip install pywhispercpp\"\n            )\n\n        self.model_size = model_size\n        self.model = None\n        self.backend = None\n        self._ensure_ffmpeg_in_path()\n        self._load_model()\n\n    def _ensure_ffmpeg_in_path(self) -> None:\n        \"\"\"\n        Ensure ffmpeg is in PATH for audio processing.\n        Checks bundled ffmpeg first, then system locations.\n        \"\"\"\n        import sys\n\n        # Build list of possible ffmpeg locations\n        possible_ffmpeg_paths = []\n\n        # Check bundled ffmpeg first (PyInstaller bundle)\n        if getattr(sys, 'frozen', False):\n            # Running from PyInstaller bundle\n            # stenoai.spec places ffmpeg at '.' (bundle root, next to executable)\n            exe_dir = Path(sys.executable).parent\n            root_ffmpeg = exe_dir / 'ffmpeg'\n            if root_ffmpeg.exists():\n                possible_ffmpeg_paths.append(str(root_ffmpeg))\n            # Also check _MEIPASS (_internal) in case layout changes\n            if hasattr(sys, '_MEIPASS'):\n                meipass_ffmpeg = Path(sys._MEIPASS) / 'ffmpeg'\n                if meipass_ffmpeg.exists():\n                    possible_ffmpeg_paths.append(str(meipass_ffmpeg))\n            # Also check _internal subdirectory\n            internal_ffmpeg = exe_dir / '_internal' / 'ffmpeg'\n            if internal_ffmpeg.exists():\n                possible_ffmpeg_paths.append(str(internal_ffmpeg))\n        else:\n            # Development mode - check bin directory\n            dev_ffmpeg = Path(__file__).parent.parent / 'bin' / 'ffmpeg'\n            if dev_ffmpeg.exists():\n                possible_ffmpeg_paths.append(str(dev_ffmpeg))\n\n        # Add system locations as fallback\n        possible_ffmpeg_paths.extend([\n            '/opt/homebrew/bin/ffmpeg',  # Homebrew on Apple Silicon\n            '/usr/local/bin/ffmpeg',     # Homebrew on Intel\n            '/usr/bin/ffmpeg',           # System installation\n        ])\n\n        # Check if ffmpeg is already in PATH\n        try:\n            subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5, check=True)\n            logger.info(\"ffmpeg found in PATH\")\n            return\n        except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError):\n            pass\n\n        # Try each possible location\n        ffmpeg_found_path = None\n        for ffmpeg_path in possible_ffmpeg_paths:\n            try:\n                subprocess.run([ffmpeg_path, '-version'], capture_output=True, timeout=5, check=True)\n                ffmpeg_found_path = ffmpeg_path\n                logger.info(f\"Found ffmpeg at: {ffmpeg_path}\")\n                break\n            except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError):\n                continue\n\n        if ffmpeg_found_path:\n            ffmpeg_dir = os.path.dirname(ffmpeg_found_path)\n            current_path = os.environ.get('PATH', '')\n            if ffmpeg_dir not in current_path:\n                os.environ['PATH'] = f\"{ffmpeg_dir}:{current_path}\"\n                logger.info(f\"Added {ffmpeg_dir} to PATH\")\n        else:\n            logger.warning(\"ffmpeg not found - transcription may fail\")\n\n    def _load_model(self) -> None:\n        \"\"\"Load the Whisper model using the best available backend.\"\"\"\n        try:\n            if WHISPER_CPP_AVAILABLE:\n                self._load_whisper_cpp()\n            elif OPENAI_WHISPER_AVAILABLE:\n                self._load_openai_whisper()\n            else:\n                raise ImportError(\"No Whisper backend available\")\n        except Exception as e:\n            logger.error(f\"Error loading Whisper model: {e}\")\n            raise\n\n    def _load_whisper_cpp(self) -> None:\n        \"\"\"Load model using whisper.cpp (pywhispercpp).\"\"\"\n        logger.info(f\"Loading whisper.cpp model: {self.model_size}\")\n\n        # Determine number of threads (use most cores, leave 2 for system)\n        import multiprocessing\n        n_threads = max(1, multiprocessing.cpu_count() - 2)\n\n        # pywhispercpp auto-downloads the model if not present\n        self.model = WhisperCppModel(self.model_size, n_threads=n_threads)\n        self.backend = \"whisper.cpp\"\n        logger.info(f\"whisper.cpp model loaded successfully (threads: {n_threads})\")\n\n    def _load_openai_whisper(self) -> None:\n        \"\"\"Load model using openai-whisper (PyTorch).\"\"\"\n        logger.info(f\"Loading openai-whisper model: {self.model_size}\")\n        self.model = openai_whisper.load_model(self.model_size)\n        self.backend = \"openai-whisper\"\n        logger.info(\"openai-whisper model loaded successfully\")\n\n    def transcribe_audio(self, audio_filepath: Path, language: str = \"en\") -> Optional[dict]:\n        \"\"\"\n        Transcribe audio file to text.\n\n        Args:\n            audio_filepath: Path to the audio file\n            language: Language code (e.g., \"en\", \"de\", \"auto\")\n\n        Returns:\n            Transcribed text or None if transcription failed\n        \"\"\"\n        if not audio_filepath.exists():\n            logger.error(f\"Audio file not found: {audio_filepath}\")\n            return None\n\n        if self.model is None:\n            logger.error(\"Whisper model not loaded\")\n            return None\n\n        try:\n            logger.info(f\"Transcribing audio file: {audio_filepath}\")\n\n            # Check file size\n            file_size = audio_filepath.stat().st_size\n            logger.info(f\"Audio file size: {file_size / 1024:.1f} KB\")\n\n            if file_size < 1000:  # Less than 1KB\n                logger.warning(\"Audio file appears to be too small for transcription\")\n                return {\n                    \"text\": \"Audio file too small or empty\",\n                    \"duration_seconds\": None,\n                    \"detected_language\": None,\n                    \"detected_language_probability\": None,\n                }\n\n            # Use appropriate backend\n            if self.backend == \"whisper.cpp\":\n                result = self._transcribe_whisper_cpp(audio_filepath, language)\n            else:\n                result = self._transcribe_openai_whisper(audio_filepath, language)\n                result[\"duration_seconds\"] = None\n\n            transcript = result.get(\"text\")\n            logger.info(f\"Transcription completed. Length: {len(transcript) if transcript else 0} characters\")\n\n            if not transcript:\n                logger.warning(\"Transcription returned empty text\")\n                result[\"text\"] = \"No speech detected in audio\"\n\n            result.setdefault(\"detected_language\", None)\n            result.setdefault(\"detected_language_probability\", None)\n            return result\n\n        except Exception as e:\n            logger.error(f\"Error during transcription: {e}\")\n            import traceback\n            logger.error(f\"Traceback: {traceback.format_exc()}\")\n            return None\n\n    def _convert_to_16khz(self, audio_filepath: Path) -> tuple:\n        \"\"\"Convert audio to 16kHz mono WAV for whisper.cpp compatibility.\n\n        Returns:\n            (converted_path, duration_seconds) where duration_seconds is the\n            audio length read from the converted WAV header, or None if it\n            could not be determined.\n        \"\"\"\n        import tempfile\n        import wave\n\n        # Create temp file for converted audio\n        temp_dir = tempfile.gettempdir()\n        converted_path = Path(temp_dir) / f\"stenoai_16khz_{audio_filepath.stem}.wav\"\n\n        try:\n            # Use ffmpeg to convert to 16kHz mono WAV\n            result = subprocess.run(\n                [\n                    'ffmpeg', '-y',  # Overwrite output\n                    '-i', str(audio_filepath),\n                    '-ar', '16000',  # 16kHz sample rate\n                    '-ac', '1',      # Mono\n                    '-c:a', 'pcm_s16le',  # 16-bit PCM\n                    str(converted_path)\n                ],\n                capture_output=True,\n                timeout=60\n            )\n\n            if result.returncode == 0 and converted_path.exists():\n                logger.info(f\"Converted audio to 16kHz: {converted_path}\")\n\n                # Read duration from converted WAV header\n                duration_seconds = None\n                try:\n                    with wave.open(str(converted_path), 'rb') as wf:\n                        duration_seconds = wf.getnframes() / wf.getframerate()\n                        logger.info(f\"Audio duration from converted WAV: {duration_seconds:.1f}s\")\n                except Exception as e:\n                    logger.warning(f\"Could not read duration from converted WAV: {e}\")\n\n                return converted_path, duration_seconds\n            else:\n                logger.error(f\"ffmpeg conversion failed: {result.stderr.decode()}\")\n                return audio_filepath, None\n\n        except Exception as e:\n            logger.error(f\"Audio conversion error: {e}\")\n            return audio_filepath, None\n\n    def _transcribe_whisper_cpp(self, audio_filepath: Path, language: str = \"en\") -> dict:\n        \"\"\"Transcribe using whisper.cpp backend.\n\n        Returns:\n            dict with text/duration and optional detected language metadata\n        \"\"\"\n        # whisper.cpp requires 16kHz audio - convert if needed\n        converted_path, duration_seconds = self._convert_to_16khz(audio_filepath)\n        cleanup_converted = converted_path != audio_filepath\n\n        try:\n            resolved_language = language\n            detected_language = None\n            detected_language_probability = None\n\n            if language == \"auto\":\n                try:\n                    detection_result, _ = self.model.auto_detect_language(media=str(converted_path))\n                    if detection_result and len(detection_result) >= 1:\n                        # pywhispercpp returns (language_code, probability) on auto_detect_language.\n                        detected_language = detection_result[0]\n                        resolved_language = detected_language\n                        if len(detection_result) >= 2:\n                            detected_language_probability = float(detection_result[1])\n                    logger.info(\n                        f\"Auto-detected language: {detected_language} \"\n                        f\"(p={detected_language_probability})\"\n                    )\n                except Exception as e:\n                    logger.warning(f\"Failed to auto-detect language; using whisper default detection: {e}\")\n                    # Setting to None omits the language kwarg, letting whisper.cpp\n                    # use its own internal language detection as a fallback.\n                    resolved_language = None\n\n            # pywhispercpp returns a list of segments\n            transcribe_kwargs = {\"media\": str(converted_path)}\n            if resolved_language and resolved_language != \"auto\":\n                transcribe_kwargs[\"language\"] = resolved_language\n            segments = self.model.transcribe(**transcribe_kwargs)\n\n            if not segments:\n                return {\n                    \"text\": None,\n                    \"duration_seconds\": duration_seconds,\n                    \"detected_language\": detected_language,\n                    \"detected_language_probability\": detected_language_probability,\n                }\n\n            # Combine all segment texts\n            transcript = \" \".join(segment.text.strip() for segment in segments)\n            return {\n                \"text\": transcript.strip(),\n                \"duration_seconds\": duration_seconds,\n                \"detected_language\": detected_language,\n                \"detected_language_probability\": detected_language_probability,\n            }\n        finally:\n            # Clean up temp file\n            if cleanup_converted and converted_path.exists():\n                try:\n                    converted_path.unlink()\n                    logger.debug(f\"Cleaned up converted audio: {converted_path}\")\n                except Exception:\n                    pass\n\n    def _transcribe_openai_whisper(self, audio_filepath: Path, language: str = \"en\") -> dict:\n        \"\"\"Transcribe using openai-whisper backend.\"\"\"\n        transcribe_kwargs = {\n            \"audio\": str(audio_filepath),\n            \"verbose\": False,\n            \"fp16\": False,  # Disable FP16 to avoid warnings on CPU\n        }\n        if language and language != \"auto\":\n            transcribe_kwargs[\"language\"] = language\n        result = self.model.transcribe(**transcribe_kwargs)\n\n        if not result or \"text\" not in result:\n            return {\n                \"text\": None,\n                \"detected_language\": None,\n                \"detected_language_probability\": None,\n            }\n\n        return {\n            \"text\": result[\"text\"].strip(),\n            \"detected_language\": result.get(\"language\"),\n            \"detected_language_probability\": None,\n        }\n\n    def _split_stereo_to_channels(self, audio_filepath: Path) -> Tuple[Optional[Path], Optional[Path], Optional[float]]:\n        \"\"\"Detect if audio is stereo and split into separate channel files.\n\n        Returns:\n            (mic_path, system_path, duration_seconds) if stereo,\n            (None, None, None) if mono or detection fails.\n        \"\"\"\n        # Detect channel count via ffprobe\n        try:\n            probe = subprocess.run(\n                ['ffprobe', '-v', 'error', '-select_streams', 'a:0',\n                 '-show_entries', 'stream=channels,duration',\n                 '-of', 'csv=p=0', str(audio_filepath)],\n                capture_output=True, timeout=15, text=True\n            )\n            if probe.returncode != 0:\n                logger.warning(f\"ffprobe failed: {probe.stderr}\")\n                return None, None, None\n\n            parts = probe.stdout.strip().split(',')\n            channels = int(parts[0])\n            # Duration may be 'N/A' for container formats like WebM\n            duration = None\n            if len(parts) > 1 and parts[1] and parts[1].strip() != 'N/A':\n                try:\n                    duration = float(parts[1])\n                except ValueError:\n                    pass\n\n            if channels < 2:\n                logger.info(\"Audio is mono, skipping stereo split\")\n                return None, None, None\n\n            logger.info(f\"Stereo audio detected ({channels} channels), splitting\")\n        except Exception as e:\n            logger.warning(f\"Channel detection failed: {e}\")\n            return None, None, None\n\n        # Split channels into temp files\n        temp_dir = tempfile.gettempdir()\n        mic_path = Path(temp_dir) / f\"stenoai_ch0_{audio_filepath.stem}.wav\"\n        system_path = Path(temp_dir) / f\"stenoai_ch1_{audio_filepath.stem}.wav\"\n\n        try:\n            for ch_idx, out_path in [(0, mic_path), (1, system_path)]:\n                result = subprocess.run(\n                    ['ffmpeg', '-y', '-i', str(audio_filepath),\n                     '-af', f'pan=mono|c0=c{ch_idx}',\n                     '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',\n                     str(out_path)],\n                    capture_output=True, timeout=120\n                )\n                if result.returncode != 0:\n                    logger.error(f\"Channel {ch_idx} extraction failed: {result.stderr.decode()}\")\n                    return None, None, None\n\n            # If ffprobe couldn't get duration from the container (e.g. WebM),\n            # calculate it from the split WAV file\n            if duration is None:\n                try:\n                    import wave\n                    with wave.open(str(mic_path), 'rb') as wf:\n                        duration = wf.getnframes() / wf.getframerate()\n                        logger.info(f\"Duration from split WAV: {duration:.1f}s\")\n                except Exception as e:\n                    logger.warning(f\"Could not get duration from WAV: {e}\")\n\n            logger.info(\"Stereo channels split successfully\")\n            return mic_path, system_path, duration\n        except Exception as e:\n            logger.error(f\"Channel splitting error: {e}\")\n            return None, None, None\n\n    def _check_rms_energy(self, audio_path: Path, threshold: float = 0.005) -> bool:\n        \"\"\"Check if an audio file has enough energy to contain speech.\n\n        Args:\n            audio_path: Path to 16kHz mono WAV file\n            threshold: RMS energy threshold below which the channel is silent\n\n        Returns:\n            True if the audio has speech-level energy\n        \"\"\"\n        try:\n            import wave\n            import struct\n            import math\n\n            with wave.open(str(audio_path), 'rb') as wf:\n                n_frames = wf.getnframes()\n                if n_frames == 0:\n                    return False\n                # Read up to 5 seconds of audio for the check\n                sample_frames = min(n_frames, 16000 * 5)\n                raw = wf.readframes(sample_frames)\n\n            samples = struct.unpack(f'<{sample_frames}h', raw)\n            # Normalise int16 to [-1, 1]\n            float_samples = [s / 32768.0 for s in samples]\n            rms = math.sqrt(sum(s * s for s in float_samples) / len(float_samples))\n            logger.info(f\"RMS energy for {audio_path.name}: {rms:.6f} (threshold {threshold})\")\n            return rms >= threshold\n        except Exception as e:\n            logger.warning(f\"RMS check failed for {audio_path}: {e}\")\n            # If we can't check, assume it has audio\n            return True\n\n    def transcribe_diarised(self, audio_filepath: Path, language: str = \"en\") -> Optional[dict]:\n        \"\"\"Transcribe audio with stereo channel diarisation.\n\n        If the audio is stereo (left=mic, right=system), each channel is\n        transcribed separately and labelled as [You] and [Others].\n\n        Falls back to normal transcription for mono audio.\n\n        Args:\n            audio_filepath: Path to the audio file\n            language: Language code\n\n        Returns:\n            Dict with text, diarised_text, is_diarised, plus standard fields\n        \"\"\"\n        # Try stereo split\n        mic_path, system_path, duration = self._split_stereo_to_channels(audio_filepath)\n\n        if mic_path is None:\n            # Mono audio — use standard transcription\n            result = self.transcribe_audio(audio_filepath, language)\n            if result:\n                result['is_diarised'] = False\n                result['diarised_text'] = None\n            return result\n\n        try:\n            mic_has_audio = self._check_rms_energy(mic_path)\n            system_has_audio = self._check_rms_energy(system_path)\n\n            mic_text = \"\"\n            system_text = \"\"\n            detected_language = None\n            detected_language_probability = None\n\n            if mic_has_audio:\n                logger.info(\"Transcribing mic channel (You)...\")\n                mic_result = self.transcribe_audio(mic_path, language)\n                if mic_result and mic_result.get(\"text\"):\n                    mic_text = mic_result[\"text\"]\n                    # Propagate detected language from the first channel with speech\n                    if not detected_language and mic_result.get(\"detected_language\"):\n                        detected_language = mic_result[\"detected_language\"]\n                        detected_language_probability = mic_result.get(\"detected_language_probability\")\n            else:\n                logger.info(\"Mic channel is silent, skipping\")\n\n            if system_has_audio:\n                logger.info(\"Transcribing system channel (Others)...\")\n                sys_result = self.transcribe_audio(system_path, language)\n                if sys_result and sys_result.get(\"text\"):\n                    system_text = sys_result[\"text\"]\n                    if not detected_language and sys_result.get(\"detected_language\"):\n                        detected_language = sys_result[\"detected_language\"]\n                        detected_language_probability = sys_result.get(\"detected_language_probability\")\n            else:\n                logger.info(\"System channel is silent, skipping\")\n\n            # Build combined plain text and labelled text\n            plain_parts = []\n            labelled_parts = []\n\n            if mic_text:\n                plain_parts.append(mic_text)\n                labelled_parts.append(f\"[You] {mic_text}\")\n            if system_text:\n                plain_parts.append(system_text)\n                labelled_parts.append(f\"[Others] {system_text}\")\n\n            plain_text = \"\\n\\n\".join(plain_parts) if plain_parts else \"No speech detected in audio\"\n            diarised_text = \"\\n\\n\".join(labelled_parts) if labelled_parts else None\n            is_diarised = bool(diarised_text)\n\n            return {\n                \"text\": plain_text,\n                \"diarised_text\": diarised_text,\n                \"is_diarised\": is_diarised,\n                \"duration_seconds\": duration,\n                \"detected_language\": detected_language,\n                \"detected_language_probability\": detected_language_probability,\n            }\n        finally:\n            # Clean up temp channel files\n            for p in (mic_path, system_path):\n                if p and p.exists():\n                    try:\n                        p.unlink()\n                    except Exception:\n                        pass\n\n    def transcribe_with_timestamps(self, audio_filepath: Path) -> Optional[dict]:\n        \"\"\"\n        Transcribe audio file with timestamp information.\n\n        Args:\n            audio_filepath: Path to the audio file\n\n        Returns:\n            Dict with 'text' and 'segments' (list of {text, start, end})\n        \"\"\"\n        if not audio_filepath.exists():\n            logger.error(f\"Audio file not found: {audio_filepath}\")\n            return None\n\n        if self.model is None:\n            logger.error(\"Whisper model not loaded\")\n            return None\n\n        try:\n            logger.info(f\"Transcribing audio file with timestamps: {audio_filepath}\")\n\n            if self.backend == \"whisper.cpp\":\n                segments = self.model.transcribe(str(audio_filepath))\n                result = {\n                    \"text\": \" \".join(s.text.strip() for s in segments),\n                    \"segments\": [\n                        {\"text\": s.text.strip(), \"start\": s.t0 / 100.0, \"end\": s.t1 / 100.0}\n                        for s in segments\n                    ]\n                }\n            else:\n                result = self.model.transcribe(str(audio_filepath), verbose=True)\n\n            logger.info(\"Transcription with timestamps completed\")\n            return result\n\n        except Exception as e:\n            logger.error(f\"Error during transcription: {e}\")\n            return None\n\n    def change_model(self, model_size: str) -> bool:\n        \"\"\"\n        Change the Whisper model size.\n\n        Args:\n            model_size: New model size\n\n        Returns:\n            True if model changed successfully\n        \"\"\"\n        if model_size == self.model_size:\n            logger.info(f\"Already using model: {model_size}\")\n            return True\n\n        try:\n            self.model_size = model_size\n            self._load_model()\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to change model to {model_size}: {e}\")\n            return False\n\n    def get_backend_info(self) -> dict:\n        \"\"\"Get information about the current backend.\"\"\"\n        return {\n            \"backend\": self.backend,\n            \"model_size\": self.model_size,\n            \"whisper_cpp_available\": WHISPER_CPP_AVAILABLE,\n            \"openai_whisper_available\": OPENAI_WHISPER_AVAILABLE,\n        }\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_config.py",
    "content": "import tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom src.config import Config\n\n\nclass ConfigStoragePathTests(unittest.TestCase):\n    def test_set_storage_path_handles_permission_errors(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            self.assertEqual(config.get_storage_path(), \"\")\n\n            with patch(\"pathlib.Path.mkdir\", side_effect=PermissionError(\"no access\")):\n                success = config.set_storage_path(\"/System/Library\")\n\n            self.assertFalse(success)\n            self.assertEqual(config.get_storage_path(), \"\")\n\n    def test_set_storage_path_accepts_none_as_reset(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            success = config.set_storage_path(None)\n            self.assertTrue(success)\n            self.assertEqual(config.get_storage_path(), \"\")\n\n\nclass ConfigLanguageTests(unittest.TestCase):\n    def test_set_language_accepts_supported_dutch_code(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            success = config.set_language(\"nl\")\n            self.assertTrue(success)\n            self.assertEqual(config.get_language(), \"nl\")\n            self.assertEqual(config.get_language_name(\"nl\"), \"Dutch\")\n\n    def test_set_language_accepts_auto_detection_mode(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            success = config.set_language(\"auto\")\n            self.assertTrue(success)\n            self.assertEqual(config.get_language(), \"auto\")\n            self.assertEqual(config.get_language_name(\"auto\"), \"Auto (detect)\")\n\n\nclass ConfigWhisperModelTests(unittest.TestCase):\n    def test_default_whisper_model_is_small(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            self.assertEqual(config.get_whisper_model(), \"small\")\n\n    def test_set_whisper_model_persists_supported_size(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            path = Path(tmp_dir) / \"config.json\"\n            config = Config(config_path=path)\n            self.assertTrue(config.set_whisper_model(\"large-v3-turbo\"))\n            self.assertEqual(config.get_whisper_model(), \"large-v3-turbo\")\n            # Round-trip via a fresh Config instance\n            reloaded = Config(config_path=path)\n            self.assertEqual(reloaded.get_whisper_model(), \"large-v3-turbo\")\n\n    def test_set_whisper_model_rejects_unknown_size(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            self.assertFalse(config.set_whisper_model(\"ultra-mega\"))\n            self.assertEqual(config.get_whisper_model(), \"small\")\n\n    def test_get_whisper_model_falls_back_when_stored_value_invalid(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            # Simulate a hand-edited config with a stale model name\n            config._config[\"whisper_model\"] = \"obsolete-model\"\n            self.assertEqual(config.get_whisper_model(), \"small\")\n\n\nclass ConfigKeepRecordingsTests(unittest.TestCase):\n    def test_default_keep_recordings_is_false(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config = Config(config_path=Path(tmp_dir) / \"config.json\")\n            self.assertFalse(config.get_keep_recordings())\n\n    def test_keep_recordings_round_trip(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            path = Path(tmp_dir) / \"config.json\"\n            config = Config(config_path=path)\n            self.assertTrue(config.set_keep_recordings(True))\n            self.assertTrue(config.get_keep_recordings())\n            reloaded = Config(config_path=path)\n            self.assertTrue(reloaded.get_keep_recordings())\n            self.assertTrue(reloaded.set_keep_recordings(False))\n            self.assertFalse(reloaded.get_keep_recordings())\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_transcriber.py",
    "content": "import unittest\nfrom pathlib import Path\nfrom unittest.mock import Mock\n\nfrom src.transcriber import WhisperTranscriber\n\n\nclass WhisperTranscriberAutoLanguageTests(unittest.TestCase):\n    def _build_transcriber(self, model: Mock) -> WhisperTranscriber:\n        audio_path = Path(\"/tmp/stenoai-test.wav\")\n        transcriber = WhisperTranscriber.__new__(WhisperTranscriber)\n        transcriber.model = model\n        transcriber.backend = \"whisper.cpp\"\n        transcriber._convert_to_16khz = Mock(return_value=(audio_path, 12.3))\n        return transcriber\n\n    def test_auto_mode_uses_detected_language(self):\n        model = Mock()\n        model.auto_detect_language.return_value = ((\"nl\", 0.97), {\"nl\": 0.97})\n        segment = Mock()\n        segment.text = \" Hallo \"\n        model.transcribe.return_value = [segment]\n\n        transcriber = self._build_transcriber(model)\n        result = transcriber._transcribe_whisper_cpp(Path(\"/tmp/stenoai-test.wav\"), language=\"auto\")\n\n        self.assertEqual(result[\"text\"], \"Hallo\")\n        self.assertEqual(result[\"detected_language\"], \"nl\")\n        self.assertEqual(model.transcribe.call_args.kwargs.get(\"language\"), \"nl\")\n\n    def test_auto_mode_falls_back_when_detection_fails(self):\n        model = Mock()\n        model.auto_detect_language.side_effect = RuntimeError(\"detection failed\")\n        segment = Mock()\n        segment.text = \" Hello \"\n        model.transcribe.return_value = [segment]\n\n        transcriber = self._build_transcriber(model)\n        result = transcriber._transcribe_whisper_cpp(Path(\"/tmp/stenoai-test.wav\"), language=\"auto\")\n\n        self.assertEqual(result[\"text\"], \"Hello\")\n        self.assertIsNone(result[\"detected_language\"])\n        self.assertNotIn(\"language\", model.transcribe.call_args.kwargs)\n\n    def test_explicit_language_skips_auto_detection(self):\n        model = Mock()\n        segment = Mock()\n        segment.text = \" Bonjour \"\n        model.transcribe.return_value = [segment]\n\n        transcriber = self._build_transcriber(model)\n        result = transcriber._transcribe_whisper_cpp(\n            Path(\"/tmp/stenoai-test.wav\"),\n            language=\"fr\",\n        )\n\n        self.assertEqual(result[\"text\"], \"Bonjour\")\n        self.assertIsNone(result[\"detected_language\"])\n        model.auto_detect_language.assert_not_called()\n        self.assertEqual(model.transcribe.call_args.kwargs.get(\"language\"), \"fr\")\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "website/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "website/README.md",
    "content": "# React + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCurrently, two official plugins are available:\n\n- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh\n- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh\n\n## Expanding the ESLint configuration\n\nIf you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.\n"
  },
  {
    "path": "website/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reactRefresh from 'eslint-plugin-react-refresh'\nimport { defineConfig, globalIgnores } from 'eslint/config'\n\nexport default defineConfig([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{js,jsx}'],\n    extends: [\n      js.configs.recommended,\n      reactHooks.configs['recommended-latest'],\n      reactRefresh.configs.vite,\n    ],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n      parserOptions: {\n        ecmaVersion: 'latest',\n        ecmaFeatures: { jsx: true },\n        sourceType: 'module',\n      },\n    },\n    rules: {\n      'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],\n    },\n  },\n])\n"
  },
  {
    "path": "website/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/stenoai-logo.svg\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/dragonfly-logo-512.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"description\" content=\"stenoAI is a free, open-source AI meeting notes app that runs 100% locally on your Mac. Private transcription and summarization with no cloud, no accounts, no data leaving your device.\" />\n    <title>stenoAI - private meeting notes for your Mac</title>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600&family=JetBrains+Mono:wght@400;500&display=swap\" />\n    <!-- No-flash dark mode: apply class before React renders -->\n    <script>\n      (function() {\n        var stored = localStorage.getItem('theme');\n        var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n        if (stored === 'dark' || (!stored && prefersDark)) {\n          document.documentElement.classList.add('dark');\n        }\n      })();\n    </script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.jsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "website/package.json",
    "content": "{\n  \"name\": \"website\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"framer-motion\": \"^12.23.12\",\n    \"lucide-react\": \"^0.542.0\",\n    \"posthog-js\": \"^1.372.1\",\n    \"react\": \"^19.1.1\",\n    \"react-dom\": \"^19.1.1\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.33.0\",\n    \"@tailwindcss/postcss\": \"^4.1.12\",\n    \"@types/react\": \"^19.1.10\",\n    \"@types/react-dom\": \"^19.1.7\",\n    \"@vitejs/plugin-react\": \"^5.0.0\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"eslint\": \"^9.33.0\",\n    \"eslint-plugin-react-hooks\": \"^5.2.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.20\",\n    \"globals\": \"^16.3.0\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^4.1.12\",\n    \"vite\": \"^7.1.2\"\n  }\n}\n"
  },
  {
    "path": "website/postcss.config.js",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n    autoprefixer: {},\n  },\n}"
  },
  {
    "path": "website/public/CNAME",
    "content": "stenoai.co\n"
  },
  {
    "path": "website/public/privacy.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Privacy Policy — stenoAI</title>\n  <meta name=\"description\" content=\"stenoAI privacy policy. Learn how we protect your data with local-first processing.\" />\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/stenoai-logo.svg\" />\n  <link rel=\"icon\" type=\"image/png\" href=\"/dragonfly-logo-512.png\" />\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n  <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap\" />\n  <script>\n    (function() {\n      var stored = localStorage.getItem('theme');\n      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n      if (stored === 'dark' || (!stored && prefersDark)) {\n        document.documentElement.classList.add('dark');\n      }\n    })();\n  </script>\n  <style>\n    :root {\n      --page: #FAF9F5;\n      --fg-1: #1B1B19;\n      --fg-2: #6B6B66;\n      --fg-muted: #A8A8A0;\n      --border-subtle: rgba(27,27,25,0.06);\n      --border: rgba(27,27,25,0.10);\n      --surface-hover: #F5F3EC;\n      --font-serif: 'Charter','Bitstream Charter','Sitka Text','Iowan Old Style',Cambria,Georgia,serif;\n      --font-sans: 'Inter',-apple-system,'Segoe UI','Helvetica Neue',sans-serif;\n      --font-mono: 'SF Mono','Menlo','Consolas',monospace;\n    }\n    :root.dark {\n      --page: #1A1A18;\n      --fg-1: #EDEAE0;\n      --fg-2: #9A968A;\n      --fg-muted: #5D5A52;\n      --border-subtle: rgba(237,234,224,0.06);\n      --border: rgba(237,234,224,0.10);\n      --surface-hover: #242420;\n    }\n    *, *::before, *::after { box-sizing: border-box; }\n    html, body {\n      margin: 0; padding: 0;\n      background: var(--page);\n      color: var(--fg-1);\n      font-family: var(--font-sans);\n      font-size: 15px;\n      line-height: 1.6;\n      -webkit-font-smoothing: antialiased;\n    }\n    h1, h2, h3, p, ul { margin: 0; }\n    a { color: var(--fg-1); text-underline-offset: 0.22em; }\n    a:hover { opacity: 0.7; }\n    nav {\n      position: sticky; top: 0; z-index: 40;\n      border-bottom: 1px solid var(--border-subtle);\n      background: var(--page);\n    }\n    nav .inner {\n      max-width: 800px; margin: 0 auto;\n      padding: 16px 24px;\n      display: flex; align-items: center; justify-content: space-between;\n    }\n    .brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--fg-1); }\n    .brand-mark { width: 20px; height: 20px; }\n    .brand-name {\n      font-family: var(--font-serif);\n      font-size: 17px; font-weight: 400;\n      letter-spacing: -0.01em;\n    }\n    .back { font-size: 13px; color: var(--fg-2); text-decoration: none; }\n    .back:hover { color: var(--fg-1); opacity: 1; }\n    main { max-width: 800px; margin: 0 auto; padding: 56px 24px 80px; }\n    h1 {\n      font-family: var(--font-serif);\n      font-size: clamp(28px, 4vw, 40px);\n      font-weight: 400; letter-spacing: -0.02em; line-height: 1.1;\n      color: var(--fg-1); margin-bottom: 8px;\n    }\n    .dateline { font-size: 13px; color: var(--fg-muted); margin-bottom: 48px; }\n    .content { display: flex; flex-direction: column; gap: 32px; }\n    .content section { display: flex; flex-direction: column; gap: 12px; }\n    h2 {\n      font-family: var(--font-sans);\n      font-size: 17px; font-weight: 600;\n      color: var(--fg-1);\n    }\n    h3 {\n      font-family: var(--font-sans);\n      font-size: 15px; font-weight: 500;\n      color: var(--fg-1);\n    }\n    p { color: var(--fg-2); }\n    strong { color: var(--fg-1); font-weight: 600; }\n    ul { color: var(--fg-2); padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }\n    code {\n      font-family: var(--font-mono);\n      font-size: 12px;\n      background: var(--surface-hover);\n      padding: 2px 6px;\n      border-radius: 4px;\n      color: var(--fg-1);\n    }\n    footer {\n      border-top: 1px solid var(--border-subtle);\n      padding: 24px;\n      text-align: center;\n      font-size: 13px;\n      color: var(--fg-muted);\n    }\n  </style>\n</head>\n<body>\n  <nav>\n    <div class=\"inner\">\n      <a href=\"/\" class=\"brand\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" class=\"brand-mark\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n          <path d=\"M28 7 Q29 9.5 30 12.5\" />\n          <path d=\"M36 7 Q35 9.5 34 12.5\" />\n          <circle cx=\"32\" cy=\"15\" r=\"3.8\" />\n          <circle cx=\"30.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n          <circle cx=\"33.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n          <path d=\"M30 19 Q28 19 28 21 L28 50 L32 60 L36 50 L36 21 Q36 19 34 19 Z\" />\n          <line x1=\"28\" y1=\"32\" x2=\"36\" y2=\"32\" />\n          <line x1=\"28\" y1=\"38\" x2=\"36\" y2=\"38\" />\n          <line x1=\"28\" y1=\"44\" x2=\"36\" y2=\"44\" />\n          <line x1=\"28\" y1=\"50\" x2=\"36\" y2=\"50\" />\n          <path d=\"M28 22 C18 15 8 17 4 22 C10 28 20 28 28 27 Z\" />\n          <path d=\"M36 22 C46 15 56 17 60 22 C50 28 44 28 36 27 Z\" />\n          <path d=\"M28 28 C18 30 10 35 6 40 C14 39 22 36 28 33 Z\" />\n          <path d=\"M36 28 C46 30 54 35 58 40 C50 39 42 36 36 33 Z\" />\n        </svg>\n        <span class=\"brand-name\">stenoAI.</span>\n      </a>\n      <a href=\"/\" class=\"back\">&larr; Back to home</a>\n    </div>\n  </nav>\n\n  <main>\n    <h1>Privacy Policy</h1>\n    <p class=\"dateline\">Last updated: March 3, 2026</p>\n\n    <div class=\"content\">\n      <section>\n        <h2>Overview</h2>\n        <p>StenoAI is a privacy-first, open-source meeting notes application that runs entirely on your local device. We are committed to protecting your privacy and being transparent about our data practices.</p>\n      </section>\n\n      <section>\n        <h2>Data that stays on your device</h2>\n        <p>The following data is processed and stored exclusively on your Mac and is never transmitted to any external server:</p>\n        <ul>\n          <li>Audio recordings of your meetings</li>\n          <li>Transcriptions generated by the local Whisper model</li>\n          <li>AI-generated summaries, action items, and key points</li>\n          <li>Session names, folders, and organizational data</li>\n        </ul>\n      </section>\n\n      <section>\n        <h2>Google Calendar integration (optional)</h2>\n        <p>If you choose to connect Google Calendar, StenoAI accesses your calendar data with the <code>calendar.readonly</code> scope. This means:</p>\n        <ul>\n          <li>StenoAI can only <strong>read</strong> your calendar events — it cannot create, modify, or delete events</li>\n          <li>Event data (titles, times, attendees, descriptions) is fetched directly from Google's API to your device</li>\n          <li>Calendar data is held in memory only and is never written to disk or sent to any third-party server</li>\n          <li>OAuth tokens are encrypted using macOS Keychain (via Electron safeStorage) and stored locally</li>\n          <li>You can disconnect Google Calendar at any time from Settings, which revokes access and deletes stored tokens</li>\n        </ul>\n      </section>\n\n      <section>\n        <h2>Google user data: collection, use, and protection</h2>\n        <p>This section describes how StenoAI handles data received from Google APIs, in compliance with the <a href=\"https://developers.google.com/terms/api-services-user-data-policy\">Google API Services User Data Policy</a>.</p>\n\n        <h3>Data accessed</h3>\n        <p>When you connect Google Calendar, StenoAI accesses the following Google user data via the <code>calendar.readonly</code> scope:</p>\n        <ul>\n          <li>Calendar event titles and descriptions</li>\n          <li>Event start and end times</li>\n          <li>Attendee names and email addresses</li>\n          <li>Event metadata (location, recurrence, status)</li>\n        </ul>\n\n        <h3>Data usage</h3>\n        <p>Google Calendar data is used solely to:</p>\n        <ul>\n          <li>Display your upcoming meetings within the StenoAI interface so you can quickly start recording a session</li>\n          <li>Auto-populate meeting names and attendee lists for your transcription sessions</li>\n        </ul>\n        <p>Google user data is never used for advertising, profiling, or any purpose unrelated to the app's core meeting-transcription functionality.</p>\n\n        <h3>Data sharing</h3>\n        <p>StenoAI does <strong>not</strong> share, sell, or transfer Google user data to any third party. Calendar data stays entirely on your device and is never sent to StenoAI servers, analytics services, or any other external service.</p>\n\n        <h3>Data storage and protection</h3>\n        <ul>\n          <li>Calendar event data is held <strong>in memory only</strong> while the app is running. It is never persisted to disk.</li>\n          <li>OAuth refresh and access tokens are encrypted at rest using <strong>macOS Keychain</strong> via Electron's safeStorage API, which provides OS-level encryption tied to your user account.</li>\n          <li>All communication with Google APIs occurs over HTTPS/TLS.</li>\n        </ul>\n\n        <h3>Data retention and deletion</h3>\n        <ul>\n          <li>Calendar event data is not retained — it exists only in application memory and is discarded when the app is closed or when you navigate away from the calendar view.</li>\n          <li>OAuth tokens are retained locally until you disconnect Google Calendar.</li>\n          <li><strong>To delete all Google data:</strong> Open StenoAI Settings and click \"Disconnect Google Calendar.\" This immediately revokes the OAuth token with Google and deletes all stored credentials from your device.</li>\n          <li>Uninstalling StenoAI also removes all locally stored tokens and data.</li>\n          <li>You can also revoke access at any time from your <a href=\"https://myaccount.google.com/permissions\">Google Account permissions page</a>.</li>\n        </ul>\n      </section>\n\n      <section>\n        <h2>Analytics (optional)</h2>\n        <p>StenoAI includes optional, privacy-safe analytics via PostHog to help improve the product. This can be disabled in Settings. When enabled, we collect:</p>\n        <ul>\n          <li>Anonymous usage events (e.g., \"recording started\", \"summary generated\")</li>\n          <li>Duration buckets (e.g., \"5-15 minutes\") — never exact durations</li>\n          <li>App version and platform information</li>\n        </ul>\n        <p>We never collect or transmit: audio content, transcript text, summary content, file paths, meeting names, or any personally identifiable information.</p>\n      </section>\n\n      <section>\n        <h2>Third-party services</h2>\n        <p>StenoAI connects to the internet only for:</p>\n        <ul>\n          <li>Google Calendar API (if connected by you)</li>\n          <li>PostHog analytics (enabled by default — can be disabled in Settings)</li>\n          <li>Checking for app updates via GitHub</li>\n        </ul>\n      </section>\n\n      <section>\n        <h2>Contact</h2>\n        <p>For privacy-related questions, open an issue on <a href=\"https://github.com/ruzin/stenoai\">GitHub</a> or reach out on <a href=\"https://discord.gg/DZ6vcQnxxu\">Discord</a>.</p>\n      </section>\n    </div>\n  </main>\n\n  <footer>&copy; 2026 stenoAI</footer>\n</body>\n</html>\n"
  },
  {
    "path": "website/public/terms.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Terms of Service — stenoAI</title>\n  <meta name=\"description\" content=\"stenoAI terms of service.\" />\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/stenoai-logo.svg\" />\n  <link rel=\"icon\" type=\"image/png\" href=\"/dragonfly-logo-512.png\" />\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n  <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap\" />\n  <script>\n    (function() {\n      var stored = localStorage.getItem('theme');\n      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n      if (stored === 'dark' || (!stored && prefersDark)) {\n        document.documentElement.classList.add('dark');\n      }\n    })();\n  </script>\n  <style>\n    :root {\n      --page: #FAF9F5;\n      --fg-1: #1B1B19;\n      --fg-2: #6B6B66;\n      --fg-muted: #A8A8A0;\n      --border-subtle: rgba(27,27,25,0.06);\n      --border: rgba(27,27,25,0.10);\n      --surface-hover: #F5F3EC;\n      --font-serif: 'Charter','Bitstream Charter','Sitka Text','Iowan Old Style',Cambria,Georgia,serif;\n      --font-sans: 'Inter',-apple-system,'Segoe UI','Helvetica Neue',sans-serif;\n      --font-mono: 'SF Mono','Menlo','Consolas',monospace;\n    }\n    :root.dark {\n      --page: #1A1A18;\n      --fg-1: #EDEAE0;\n      --fg-2: #9A968A;\n      --fg-muted: #5D5A52;\n      --border-subtle: rgba(237,234,224,0.06);\n      --border: rgba(237,234,224,0.10);\n      --surface-hover: #242420;\n    }\n    *, *::before, *::after { box-sizing: border-box; }\n    html, body {\n      margin: 0; padding: 0;\n      background: var(--page);\n      color: var(--fg-1);\n      font-family: var(--font-sans);\n      font-size: 15px;\n      line-height: 1.6;\n      -webkit-font-smoothing: antialiased;\n    }\n    h1, h2, h3, p, ul { margin: 0; }\n    a { color: var(--fg-1); text-underline-offset: 0.22em; }\n    a:hover { opacity: 0.7; }\n    nav {\n      position: sticky; top: 0; z-index: 40;\n      border-bottom: 1px solid var(--border-subtle);\n      background: var(--page);\n    }\n    nav .inner {\n      max-width: 800px; margin: 0 auto;\n      padding: 16px 24px;\n      display: flex; align-items: center; justify-content: space-between;\n    }\n    .brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--fg-1); }\n    .brand-mark { width: 20px; height: 20px; }\n    .brand-name {\n      font-family: var(--font-serif);\n      font-size: 17px; font-weight: 400;\n      letter-spacing: -0.01em;\n    }\n    .back { font-size: 13px; color: var(--fg-2); text-decoration: none; }\n    .back:hover { color: var(--fg-1); opacity: 1; }\n    main { max-width: 800px; margin: 0 auto; padding: 56px 24px 80px; }\n    h1 {\n      font-family: var(--font-serif);\n      font-size: clamp(28px, 4vw, 40px);\n      font-weight: 400; letter-spacing: -0.02em; line-height: 1.1;\n      color: var(--fg-1); margin-bottom: 8px;\n    }\n    .dateline { font-size: 13px; color: var(--fg-muted); margin-bottom: 48px; }\n    .content { display: flex; flex-direction: column; gap: 32px; }\n    .content section { display: flex; flex-direction: column; gap: 12px; }\n    h2 {\n      font-family: var(--font-sans);\n      font-size: 17px; font-weight: 600;\n      color: var(--fg-1);\n    }\n    p { color: var(--fg-2); }\n    strong { color: var(--fg-1); font-weight: 600; }\n    ul { color: var(--fg-2); padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }\n    code {\n      font-family: var(--font-mono);\n      font-size: 12px;\n      background: var(--surface-hover);\n      padding: 2px 6px;\n      border-radius: 4px;\n      color: var(--fg-1);\n    }\n    footer {\n      border-top: 1px solid var(--border-subtle);\n      padding: 24px;\n      text-align: center;\n      font-size: 13px;\n      color: var(--fg-muted);\n    }\n  </style>\n</head>\n<body>\n  <nav>\n    <div class=\"inner\">\n      <a href=\"/\" class=\"brand\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" class=\"brand-mark\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n          <path d=\"M28 7 Q29 9.5 30 12.5\" />\n          <path d=\"M36 7 Q35 9.5 34 12.5\" />\n          <circle cx=\"32\" cy=\"15\" r=\"3.8\" />\n          <circle cx=\"30.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n          <circle cx=\"33.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n          <path d=\"M30 19 Q28 19 28 21 L28 50 L32 60 L36 50 L36 21 Q36 19 34 19 Z\" />\n          <line x1=\"28\" y1=\"32\" x2=\"36\" y2=\"32\" />\n          <line x1=\"28\" y1=\"38\" x2=\"36\" y2=\"38\" />\n          <line x1=\"28\" y1=\"44\" x2=\"36\" y2=\"44\" />\n          <line x1=\"28\" y1=\"50\" x2=\"36\" y2=\"50\" />\n          <path d=\"M28 22 C18 15 8 17 4 22 C10 28 20 28 28 27 Z\" />\n          <path d=\"M36 22 C46 15 56 17 60 22 C50 28 44 28 36 27 Z\" />\n          <path d=\"M28 28 C18 30 10 35 6 40 C14 39 22 36 28 33 Z\" />\n          <path d=\"M36 28 C46 30 54 35 58 40 C50 39 42 36 36 33 Z\" />\n        </svg>\n        <span class=\"brand-name\">stenoAI.</span>\n      </a>\n      <a href=\"/\" class=\"back\">&larr; Back to home</a>\n    </div>\n  </nav>\n\n  <main>\n    <h1>Terms of Service</h1>\n    <p class=\"dateline\">Last updated: February 15, 2026</p>\n\n    <div class=\"content\">\n      <section>\n        <h2>Acceptance of terms</h2>\n        <p>By downloading and using StenoAI, you agree to these terms. StenoAI is open-source software provided under the terms of its <a href=\"https://github.com/ruzin/stenoai/blob/main/LICENSE\">license</a>.</p>\n      </section>\n\n      <section>\n        <h2>Use of the software</h2>\n        <p>StenoAI is provided free of charge for personal and professional use. You are responsible for:</p>\n        <ul>\n          <li>Ensuring you have consent to record meetings in your jurisdiction</li>\n          <li>Complying with applicable laws regarding audio recording and data privacy</li>\n          <li>Maintaining the security of your device and local data</li>\n        </ul>\n      </section>\n\n      <section>\n        <h2>Disclaimer of warranties</h2>\n        <p>StenoAI is provided \"as is\" without warranty of any kind. Transcription and summarization accuracy depend on audio quality, hardware, and model selection. We do not guarantee the accuracy, completeness, or reliability of AI-generated content.</p>\n      </section>\n\n      <section>\n        <h2>Limitation of liability</h2>\n        <p>To the fullest extent permitted by law, the StenoAI project and its contributors shall not be liable for any indirect, incidental, special, or consequential damages arising from the use of the software.</p>\n      </section>\n\n      <section>\n        <h2>Changes to these terms</h2>\n        <p>We may update these terms from time to time. Changes will be reflected on this page with an updated date. Continued use of StenoAI after changes constitutes acceptance of the revised terms.</p>\n      </section>\n    </div>\n  </main>\n\n  <footer>&copy; 2026 stenoAI</footer>\n</body>\n</html>\n"
  },
  {
    "path": "website/src/App.jsx",
    "content": "import { Nav } from \"./sections/Nav\";\nimport { Hero } from \"./sections/Hero\";\nimport { TrustStrip } from \"./sections/TrustStrip\";\nimport { HowItWorks } from \"./sections/HowItWorks\";\nimport { Features } from \"./sections/Features\";\nimport { Models } from \"./sections/Models\";\nimport { Industries } from \"./sections/Industries\";\nimport { FAQ } from \"./sections/FAQ\";\nimport { CTAFooter } from \"./sections/CTAFooter\";\nimport { Footer } from \"./sections/Footer\";\n\nexport default function App() {\n  return (\n    <>\n      <Nav />\n      <Hero />\n      <TrustStrip />\n      <HowItWorks />\n      <Features />\n      <Models />\n      <Industries />\n      <FAQ />\n      <CTAFooter />\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "website/src/analytics.js",
    "content": "import posthog from 'posthog-js'\n\nexport function trackDownload(location, arch) {\n  posthog.capture('download_clicked', { location, arch })\n}\n\nexport function trackGitHub(location) {\n  posthog.capture('github_clicked', { location })\n}\n"
  },
  {
    "path": "website/src/components/Brand.jsx",
    "content": "export function StenoMark({ size = 22, className = \"\" }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 64 64\"\n      width={size}\n      height={size}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={className}\n    >\n      <path d=\"M28 7 Q29 9.5 30 12.5\" />\n      <path d=\"M36 7 Q35 9.5 34 12.5\" />\n      <circle cx=\"32\" cy=\"15\" r=\"3.8\" />\n      <circle cx=\"30.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n      <circle cx=\"33.5\" cy=\"15\" r=\"0.7\" fill=\"currentColor\" stroke=\"none\" />\n      <path d=\"M30 19 Q28 19 28 21 L28 50 L32 60 L36 50 L36 21 Q36 19 34 19 Z\" />\n      <line x1=\"28\" y1=\"32\" x2=\"36\" y2=\"32\" />\n      <line x1=\"28\" y1=\"38\" x2=\"36\" y2=\"38\" />\n      <line x1=\"28\" y1=\"44\" x2=\"36\" y2=\"44\" />\n      <line x1=\"28\" y1=\"50\" x2=\"36\" y2=\"50\" />\n      <path d=\"M28 22 C18 15 8 17 4 22 C10 28 20 28 28 27 Z\" />\n      <path d=\"M36 22 C46 15 56 17 60 22 C54 28 44 28 36 27 Z\" />\n      <path d=\"M28 28 C18 30 10 35 6 40 C14 39 22 36 28 33 Z\" />\n      <path d=\"M36 28 C46 30 54 35 58 40 C50 39 42 36 36 33 Z\" />\n    </svg>\n  );\n}\n\nexport function Wordmark({ size = 19 }) {\n  return (\n    <span\n      style={{\n        fontFamily: \"var(--font-serif)\",\n        fontSize: size,\n        fontWeight: 400,\n        letterSpacing: \"-0.01em\",\n        color: \"var(--fg-1)\",\n        lineHeight: 1,\n      }}\n    >\n      stenoAI.\n    </span>\n  );\n}\n"
  },
  {
    "path": "website/src/components/ThemeToggle.jsx",
    "content": "import { useState } from \"react\";\nimport { Sun, Moon } from \"lucide-react\";\n\nexport function ThemeToggle() {\n  const [dark, setDark] = useState(() => {\n    if (typeof window === \"undefined\") return false;\n    const stored = localStorage.getItem(\"theme\");\n    const prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n    return stored ? stored === \"dark\" : prefersDark;\n  });\n\n  function toggle() {\n    const next = !dark;\n    setDark(next);\n    document.documentElement.classList.toggle(\"dark\", next);\n    localStorage.setItem(\"theme\", next ? \"dark\" : \"light\");\n  }\n\n  return (\n    <button\n      onClick={toggle}\n      aria-label=\"Toggle theme\"\n      style={{ width: 40, height: 40 }}\n      className=\"inline-flex items-center justify-center rounded-[6px] border-0 bg-transparent text-fg-2 cursor-pointer transition-colors hover:bg-surface-hover hover:text-fg-1\"\n    >\n      {dark ? <Sun size={15} /> : <Moon size={15} />}\n    </button>\n  );\n}\n"
  },
  {
    "path": "website/src/index.css",
    "content": "@import \"tailwindcss\";\n\n/* ── Dark mode variant (class-based) ── */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* ═══════════════════════════════════════════════════\n   Raw tokens - light mode (warm paper + ink)\n   ═══════════════════════════════════════════════════ */\n:root {\n  --paper-0: #FAF9F5;\n  --paper-1: #F5F3EC;\n  --paper-2: #EFEBE1;\n  --paper-3: #E5DFD1;\n\n  --ink-900: #1B1B19;\n  --ink-700: #3D3D39;\n  --ink-500: #6B6B66;\n  --ink-300: #A8A8A0;\n  --ink-100: #D6D4CB;\n\n  --red-600: #B84A3A;\n  --green-600: #4F7A5B;\n\n  /* Semantic - light */\n  --page:                var(--paper-0);\n  --surface:             var(--paper-0);\n  --surface-raised:      #FFFFFF;\n  --surface-sunken:      var(--paper-1);\n  --surface-hover:       var(--paper-1);\n  --surface-active:      var(--paper-2);\n  --surface-translucent: rgba(250, 249, 245, 0.82);\n\n  --fg-1:      var(--ink-900);\n  --fg-2:      var(--ink-500);\n  --fg-muted:  var(--ink-300);\n  --fg-inverse: var(--paper-0);\n\n  --border-subtle: rgba(27, 27, 25, 0.06);\n  --border:        rgba(27, 27, 25, 0.10);\n  --border-strong: rgba(27, 27, 25, 0.22);\n\n  --primary:       var(--ink-900);\n  --primary-hover: var(--ink-700);\n  --primary-fg:    var(--paper-0);\n  --focus-ring:    rgba(27, 27, 25, 0.35);\n\n  --recording: var(--red-600);\n\n  --shadow-sm: 0 1px 2px rgba(27, 27, 25, 0.05);\n  --shadow-md: 0 8px 24px -8px rgba(27, 27, 25, 0.14), 0 2px 4px -2px rgba(27, 27, 25, 0.06);\n  --shadow-lg: 0 24px 48px -16px rgba(27, 27, 25, 0.22), 0 4px 8px -4px rgba(27, 27, 25, 0.08);\n\n  --font-serif: 'Charter', 'Bitstream Charter', 'Sitka Text', 'Iowan Old Style', Cambria, Georgia, serif;\n  --font-sans:  'Inter', -apple-system, 'Segoe UI', 'Helvetica Neue', sans-serif;\n  --font-mono:  'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;\n\n  --ease: cubic-bezier(0.2, 0, 0, 1);\n  --dur-fast: 120ms;\n  --dur:      200ms;\n  --dur-slow: 320ms;\n}\n\n/* ═══════════════════════════════════════════════════\n   Semantic tokens - dark mode\n   ═══════════════════════════════════════════════════ */\n.dark {\n  --page:                #1A1A18;\n  --surface:             #1A1A18;\n  --surface-raised:      #24241F;\n  --surface-sunken:      #14140F;\n  --surface-hover:       #242420;\n  --surface-active:      #2E2E28;\n  --surface-translucent: rgba(26, 26, 24, 0.78);\n\n  --fg-1:      #EDEAE0;\n  --fg-2:      #9A968A;\n  --fg-muted:  #5D5A52;\n  --fg-inverse: var(--ink-900);\n\n  --border-subtle: rgba(237, 234, 224, 0.06);\n  --border:        rgba(237, 234, 224, 0.10);\n  --border-strong: rgba(237, 234, 224, 0.20);\n\n  --primary:       #EDEAE0;\n  --primary-hover: #FFFFFF;\n  --primary-fg:    #1A1A18;\n  --focus-ring:    rgba(237, 234, 224, 0.45);\n\n  --recording: #D17563;\n\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);\n  --shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.55), 0 2px 4px -2px rgba(0, 0, 0, 0.3);\n  --shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.6), 0 4px 8px -4px rgba(0, 0, 0, 0.3);\n}\n\n/* ═══════════════════════════════════════════════════\n   Tailwind v4 theme - exposes CSS vars as utilities\n   ═══════════════════════════════════════════════════ */\n@theme {\n  /* Raw color scale */\n  --color-paper-0: #FAF9F5;\n  --color-paper-1: #F5F3EC;\n  --color-paper-2: #EFEBE1;\n  --color-paper-3: #E5DFD1;\n  --color-ink-900: #1B1B19;\n  --color-ink-700: #3D3D39;\n  --color-ink-500: #6B6B66;\n  --color-ink-300: #A8A8A0;\n  --color-ink-100: #D6D4CB;\n\n  /* Semantic utilities - resolve to CSS vars so dark mode works automatically */\n  --color-page:           var(--page);\n  --color-surface:        var(--surface);\n  --color-surface-raised: var(--surface-raised);\n  --color-surface-sunken: var(--surface-sunken);\n  --color-surface-hover:  var(--surface-hover);\n  --color-fg-1:           var(--fg-1);\n  --color-fg-2:           var(--fg-2);\n  --color-fg-muted:       var(--fg-muted);\n  --color-fg-inverse:     var(--fg-inverse);\n  --color-primary:        var(--primary);\n  --color-primary-hover:  var(--primary-hover);\n  --color-primary-fg:     var(--primary-fg);\n  --color-border:         var(--border);\n  --color-border-subtle:  var(--border-subtle);\n  --color-border-strong:  var(--border-strong);\n  --color-recording:      var(--recording);\n\n  /* Font families */\n  --font-serif: var(--font-serif);\n  --font-sans:  var(--font-sans);\n  --font-mono:  var(--font-mono);\n\n  /* Shadows */\n  --shadow-sm: var(--shadow-sm);\n  --shadow-md: var(--shadow-md);\n  --shadow-lg: var(--shadow-lg);\n\n  /* Border radius */\n  --radius-xs: 4px;\n  --radius-sm: 6px;\n  --radius:    8px;\n  --radius-md: 10px;\n  --radius-lg: 14px;\n  --radius-xl: 20px;\n}\n\n/* ═══════════════════════════════════════════════════\n   Base styles - in @layer base so Tailwind utilities override them\n   ═══════════════════════════════════════════════════ */\n*, *::before, *::after { box-sizing: border-box; }\n\n@layer base {\n  html, body {\n    margin: 0;\n    padding: 0;\n    background: var(--page);\n    color: var(--fg-1);\n    font-family: var(--font-sans);\n    font-size: 15px;\n    line-height: 1.55;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n\n  h1, h2, h3, h4, h5, h6, p { margin: 0; }\n\n  a {\n    color: inherit;\n    text-decoration: underline;\n    text-decoration-color: var(--border-strong);\n    text-decoration-thickness: 1px;\n    text-underline-offset: 0.22em;\n    transition: text-decoration-color var(--dur-fast) var(--ease);\n  }\n  a:hover { text-decoration-color: var(--fg-1); }\n\n  :focus-visible {\n    outline: 2px solid var(--focus-ring);\n    outline-offset: 2px;\n    border-radius: 4px;\n  }\n}\n\n/* ═══════════════════════════════════════════════════\n   @utility - reusable component shorthands\n   ═══════════════════════════════════════════════════ */\n@utility sect {\n  padding-top: 112px;\n  padding-bottom: 112px;\n}\n\n@utility btn-base {\n  font-family: var(--font-sans);\n  font-size: 14px;\n  font-weight: 500;\n  padding: 10px 18px;\n  border-radius: var(--radius);\n  border: 0;\n  cursor: pointer;\n  text-decoration: none;\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);\n}\n\n@utility btn-primary {\n  background-color: var(--primary);\n  color: var(--primary-fg);\n}\n\n@utility btn-ghost {\n  background-color: transparent;\n  color: var(--fg-1);\n}\n\n@utility btn-sm {\n  padding: 7px 12px;\n  font-size: 13px;\n}\n\n/* Hover states via plain CSS (not @utility which doesn't support pseudo-selectors) */\n.btn-primary:hover { background-color: var(--primary-hover); text-decoration: none; }\n.btn-ghost:hover   { background-color: var(--surface-hover); text-decoration: none; }\n\n/* CTA section - inverted button colors on the dark ink block */\n.cta-wrap .btn-primary {\n  background-color: var(--primary-fg);\n  color: var(--primary);\n}\n.cta-wrap .btn-primary:hover {\n  background-color: var(--primary-fg);\n  filter: brightness(0.94);\n}\n.cta-wrap .btn-ghost {\n  color: var(--primary-fg);\n  box-shadow: inset 0 0 0 1px rgba(250, 249, 245, 0.25);\n}\n.cta-wrap .btn-ghost:hover {\n  background: rgba(250, 249, 245, 0.08);\n  color: var(--primary-fg);\n}\n\n/* Hero recording pulse */\n@keyframes recording-pulse {\n  0%   { box-shadow: 0 0 0 0 rgba(184, 74, 58, 0.45); }\n  70%  { box-shadow: 0 0 0 6px rgba(184, 74, 58, 0); }\n  100% { box-shadow: 0 0 0 0 rgba(184, 74, 58, 0); }\n}\n.rec-dot {\n  width: 7px; height: 7px; border-radius: 50%;\n  background: var(--recording);\n  animation: recording-pulse 1.6s cubic-bezier(0.2,0,0,1) infinite;\n}\n\n/* ═══════════════════════════════════════════════════\n   Container - responsive padding\n   ═══════════════════════════════════════════════════ */\n@layer utilities {\n  .container-site {\n    max-width: 1120px;\n    margin-left: auto;\n    margin-right: auto;\n    padding-left: 16px;\n    padding-right: 16px;\n  }\n  @media (min-width: 640px) {\n    .container-site { padding-left: 20px; padding-right: 20px; }\n  }\n  @media (min-width: 768px) {\n    .container-site { padding-left: 28px; padding-right: 28px; }\n  }\n}\n\n/* ═══════════════════════════════════════════════════\n   Responsive - section padding\n   ═══════════════════════════════════════════════════ */\n@media (max-width: 640px) {\n  .sect { padding-top: 72px; padding-bottom: 72px; }\n}\n@media (min-width: 641px) and (max-width: 900px) {\n  .sect { padding-top: 80px; padding-bottom: 80px; }\n}\n"
  },
  {
    "path": "website/src/main.jsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport posthog from 'posthog-js'\nimport './index.css'\nimport App from './App.jsx'\n\nif (import.meta.env.PROD) {\n  posthog.init('phc_U2cnTyIyKGNSVaK18FyBMltd8nmN7uHxhhm21fAHwqb', {\n    api_host: 'https://us.i.posthog.com',\n    person_profiles: 'identified_only',\n    capture_pageview: true,\n  })\n}\n\ncreateRoot(document.getElementById('root')).render(\n  <StrictMode>\n    <App />\n  </StrictMode>,\n)\n"
  },
  {
    "path": "website/src/sections/CTAFooter.jsx",
    "content": "import { Download } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\nimport { trackDownload } from \"../analytics\";\n\nconst DOWNLOAD_ARM = \"https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-arm64.dmg\";\nconst DOWNLOAD_X64 = \"https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-x64.dmg\";\n\nexport function CTAFooter() {\n  return (\n    <section id=\"download\" className=\"pb-[120px] pt-[80px]\">\n      <div className=\"container-site\">\n        <Motion.div\n          initial={{ opacity: 0, y: 8 }}\n          whileInView={{ opacity: 1, y: 0 }}\n          viewport={{ once: true }}\n          transition={{ duration: 0.5 }}\n          className=\"cta-wrap rounded-[14px] md:rounded-[20px] px-6 py-14 sm:px-10 sm:py-16 md:px-16 md:py-24 text-center\"\n          style={{ backgroundColor: \"var(--primary)\", color: \"var(--primary-fg)\" }}\n        >\n          <div style={{ maxWidth: 520, margin: \"0 auto\" }}>\n            <h2\n              className=\"mb-[14px]\"\n              style={{\n                fontFamily: \"var(--font-serif)\",\n                fontWeight: 400,\n                fontSize: \"clamp(34px, 4.6vw, 52px)\",\n                lineHeight: 1.08,\n                letterSpacing: \"-0.02em\",\n                color: \"var(--primary-fg)\",\n              }}\n            >\n              Start keeping private notes.\n            </h2>\n            <p className=\"text-lg mb-8\" style={{ color: \"var(--primary-fg)\", opacity: 0.7 }}>\n              Free. Open source. No account needed.\n            </p>\n\n            <div className=\"flex gap-[10px] justify-center flex-wrap\">\n              <a\n                href={DOWNLOAD_ARM}\n                onClick={() => trackDownload('cta_footer', 'arm64')}\n                className=\"btn-base btn-primary no-underline\"\n              >\n                <Download size={15} aria-hidden=\"true\" /> Apple Silicon\n              </a>\n              <a\n                href={DOWNLOAD_X64}\n                onClick={() => trackDownload('cta_footer', 'x64')}\n                className=\"btn-base btn-ghost no-underline\"\n              >\n                Intel Macs\n              </a>\n            </div>\n\n            <p className=\"mt-5 text-[13px]\" style={{ color: \"var(--primary-fg)\", opacity: 0.5 }}>\n              macOS 13 or later · Requires ~4 GB for default model\n            </p>\n          </div>\n        </Motion.div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/FAQ.jsx",
    "content": "import { useState } from \"react\";\nimport { Plus, Minus } from \"lucide-react\";\nimport { AnimatePresence, motion as Motion } from \"framer-motion\";\n\nconst faqs = [\n  { q: \"What's included for free?\", a: \"Unlimited local transcription and summarization. No account, no tier, no upsell. stenoAI is open source. You can build and run it yourself if you prefer.\" },\n  { q: \"Which AI models can I use?\", a: \"Five open-weight models: Llama 3.2 3B, Gemma 3 4B, Qwen 3.5 9B, DeepSeek-R1 14B, and GPT-OSS 20B. All run locally on your Mac.\" },\n  { q: \"Is my data really private?\", a: \"Yes. stenoAI makes no network requests after install. The source is open, so you can verify it yourself, or have your security team do so.\" },\n  { q: \"How accurate is the transcription?\", a: \"stenoAI uses Whisper. Results depend on audio clarity. Quiet rooms and good microphones produce the best outcomes. Whisper performs well across 99 languages.\" },\n  { q: \"What Mac do I need?\", a: \"macOS only, on Apple Silicon or Intel. Apple Silicon is recommended for speed. The app runs comfortably on an M1 MacBook Air.\" },\n  { q: \"Does it work with remote meetings?\", a: \"Yes. stenoAI captures system audio and microphone simultaneously. Both sides of a Zoom, Teams, or Meet call are transcribed without any bot joining the meeting.\" },\n];\n\nexport function FAQ() {\n  const [open, setOpen] = useState(null);\n\n  return (\n    <section id=\"faq\" className=\"sect\">\n      <div className=\"container-site\" style={{ maxWidth: 820 }}>\n        <div className=\"mb-10\">\n          <h2\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontWeight: 400,\n              fontSize: \"clamp(34px, 4.6vw, 52px)\",\n              lineHeight: 1.08,\n              letterSpacing: \"-0.02em\",\n              color: \"var(--fg-1)\",\n            }}\n          >\n            Questions\n          </h2>\n        </div>\n\n        <div className=\"flex flex-col\">\n          {faqs.map((f, i) => (\n            <div\n              key={i}\n              style={{ borderTop: \"1px solid var(--border-subtle)\", borderBottom: i === faqs.length - 1 ? \"1px solid var(--border-subtle)\" : \"none\" }}\n            >\n              <button\n                onClick={() => setOpen(open === i ? null : i)}\n                className=\"w-full bg-transparent border-0 py-6 flex justify-between items-center gap-5 cursor-pointer text-left text-fg-1 text-base md:text-[17px]\"\n                style={{ fontFamily: \"var(--font-sans)\", fontWeight: 500 }}\n              >\n                <span>{f.q}</span>\n                <span className=\"text-fg-2 flex-shrink-0\">\n                  {open === i ? <Minus size={16} aria-hidden=\"true\" /> : <Plus size={16} aria-hidden=\"true\" />}\n                </span>\n              </button>\n\n              <AnimatePresence>\n                {open === i && (\n                  <Motion.div\n                    initial={{ height: 0, opacity: 0 }}\n                    animate={{ height: \"auto\", opacity: 1 }}\n                    exit={{ height: 0, opacity: 0 }}\n                    transition={{ duration: 0.2 }}\n                    className=\"overflow-hidden\"\n                  >\n                    <p\n                      className=\"text-fg-2 text-[15px] leading-[1.6] pb-6\"\n                      style={{ maxWidth: \"62ch\" }}\n                    >\n                      {f.a}\n                    </p>\n                  </Motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/Features.jsx",
    "content": "import { Cpu, NotebookPen, MessageSquare, ShieldOff, Layers, HardDrive } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\n\nconst feats = [\n  { icon: <Cpu size={18} aria-hidden=\"true\" />, title: \"Local transcription\", body: \"Whisper.cpp runs on Apple Silicon and Intel. Fast on a laptop, private by architecture.\" },\n  { icon: <NotebookPen size={18} aria-hidden=\"true\" />, title: \"AI notepad\", body: \"Jot notes during a recording. They're merged with the transcript to shape the AI summary.\" },\n  { icon: <MessageSquare size={18} aria-hidden=\"true\" />, title: \"Ask your meetings\", body: \"Chat with a model that has full context of the transcript. Answers come with citations to the source.\" },\n  { icon: <ShieldOff size={18} aria-hidden=\"true\" />, title: \"No data leaves\", body: \"Zero network requests after install. Verified by inspectable, open-source code you can audit yourself.\" },\n  { icon: <Layers size={18} aria-hidden=\"true\" />, title: \"99 languages\", body: \"Whisper auto-detects the language spoken. Works equally well across multilingual meetings.\" },\n  { icon: <HardDrive size={18} aria-hidden=\"true\" />, title: \"Runs offline\", body: \"No internet required after the initial model download. Works on planes, in hospitals, anywhere.\" },\n];\n\nexport function Features() {\n  return (\n    <section id=\"features\" className=\"sect\" style={{ background: \"var(--surface-sunken)\" }}>\n      <div className=\"container-site\">\n        <div className=\"mb-[48px] md:mb-[72px]\" style={{ maxWidth: 640 }}>\n          <h2\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontWeight: 400,\n              fontSize: \"clamp(34px, 4.6vw, 52px)\",\n              lineHeight: 1.08,\n              letterSpacing: \"-0.02em\",\n              color: \"var(--fg-1)\",\n              margin: \"0 0 18px\",\n            }}\n          >\n            Built for focus. Engineered for privacy.\n          </h2>\n          <p className=\"text-fg-2 text-lg leading-[1.55]\" style={{ maxWidth: \"56ch\" }}>\n            Every capability is designed around one principle: your audio never leaves your device.\n          </p>\n        </div>\n\n        <div className=\"grid sm:grid-cols-2 lg:grid-cols-3 gap-8 sm:gap-10 sm:gap-x-12\">\n          {feats.map((f, i) => (\n            <Motion.div\n              key={f.title}\n              initial={{ opacity: 0, y: 8 }}\n              whileInView={{ opacity: 1, y: 0 }}\n              viewport={{ once: true }}\n              transition={{ duration: 0.4, delay: i * 0.05 }}\n            >\n              <div className=\"text-fg-2 mb-[14px]\">{f.icon}</div>\n              <h3 className=\"text-fg-1 mb-2\" style={{ fontWeight: 500, fontSize: 18 }}>{f.title}</h3>\n              <p className=\"text-fg-2 text-[15px] leading-[1.6]\" style={{ maxWidth: \"42ch\" }}>{f.body}</p>\n            </Motion.div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/Footer.jsx",
    "content": "import { StenoMark, Wordmark } from \"../components/Brand\";\nimport { trackGitHub } from \"../analytics\";\n\nconst GITHUB_URL = \"https://github.com/ruzin/stenoai\";\n\nexport function Footer() {\n  return (\n    <footer style={{ borderTop: \"1px solid var(--border-subtle)\", padding: \"56px 0 40px\" }}>\n      <div className=\"container-site\">\n        <div className=\"flex flex-col md:flex-row justify-between items-start md:items-center gap-6 flex-wrap\">\n          <div className=\"flex items-center gap-[10px] text-fg-1\">\n            <StenoMark size={18} />\n            <Wordmark size={16} />\n          </div>\n          <div className=\"flex gap-4 md:gap-6\">\n            <a href={GITHUB_URL} target=\"_blank\" rel=\"noopener noreferrer\" onClick={() => trackGitHub('footer')} className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">GitHub</a>\n            <a href=\"/privacy.html\" className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">Privacy</a>\n            <a href=\"/terms.html\" className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">Terms</a>\n          </div>\n          <div className=\"text-fg-2 text-[13px]\">© 2026 stenoAI</div>\n        </div>\n        <p className=\"mt-14 text-fg-muted text-[13px] leading-[1.55]\" style={{ maxWidth: \"70ch\" }}>\n          Independent open-source project for private meeting notes. Not affiliated with any similarly named company.\n          Product names (Whisper, Llama, Gemma, Qwen, DeepSeek) are trademarks of their respective owners.\n        </p>\n      </div>\n    </footer>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/Hero.jsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { Download, ArrowRight, ShieldCheck, Lock, Cpu } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\nimport { trackDownload } from \"../analytics\";\n\nconst DOWNLOAD_ARM = \"https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-arm64.dmg\";\n\nfunction fmt(s) {\n  const h = String(Math.floor(s / 3600)).padStart(2, \"0\");\n  const m = String(Math.floor((s % 3600) / 60)).padStart(2, \"0\");\n  const sec = String(s % 60).padStart(2, \"0\");\n  return `${h}:${m}:${sec}`;\n}\n\nexport function Hero() {\n  const [seconds, setSeconds] = useState(862);\n\n  useEffect(() => {\n    const t = setInterval(() => setSeconds((s) => s + 1), 1000);\n    return () => clearInterval(t);\n  }, []);\n\n  return (\n    <section className=\"pt-[40px] pb-[56px] md:pt-[56px] md:pb-[80px]\">\n      <div className=\"container-site grid md:grid-cols-[1.1fr_1fr] gap-10 md:gap-16 items-center\">\n\n        {/* Copy */}\n        <div>\n          <Motion.h1\n            initial={{ opacity: 0, y: 8 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.5 }}\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontWeight: 400,\n              fontSize: \"clamp(44px, 6.2vw, 72px)\",\n              lineHeight: 1.02,\n              letterSpacing: \"-0.025em\",\n              color: \"var(--fg-1)\",\n              maxWidth: \"14ch\",\n            }}\n          >\n            AI that runs privately on your Mac.\n          </Motion.h1>\n\n          <Motion.p\n            initial={{ opacity: 0, y: 8 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.5, delay: 0.1 }}\n            className=\"text-fg-2 text-lg leading-[1.55] mt-7 mb-9\"\n            style={{ maxWidth: \"44ch\" }}\n          >\n            stenoAI records, transcribes, and summarizes every confidential interaction on-device.\n            No cloud, no usage limits and no bots joining your calls.\n          </Motion.p>\n\n          <Motion.div\n            initial={{ opacity: 0, y: 8 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.5, delay: 0.15 }}\n            className=\"flex gap-[10px] flex-wrap\"\n          >\n            <a href=\"#download\" onClick={() => trackDownload('hero', 'unknown')} className=\"btn-base btn-primary inline-flex items-center gap-2 no-underline hover:no-underline\">\n              <Download size={15} aria-hidden=\"true\" /> Download for Mac\n            </a>\n            <a href=\"#how\" className=\"btn-base btn-ghost inline-flex items-center gap-2 no-underline hover:no-underline\">\n              See how it works <ArrowRight size={15} aria-hidden=\"true\" />\n            </a>\n          </Motion.div>\n\n          <Motion.div\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={{ duration: 0.5, delay: 0.25 }}\n            className=\"flex gap-5 flex-wrap mt-7\"\n          >\n            <span className=\"inline-flex items-center gap-1.5 text-fg-2 text-[13px]\">\n              <ShieldCheck size={13} aria-hidden=\"true\" /> No network requests after install\n            </span>\n            <span className=\"inline-flex items-center gap-1.5 text-fg-2 text-[13px]\">\n              <Lock size={13} aria-hidden=\"true\" /> Open source, verify it yourself\n            </span>\n          </Motion.div>\n        </div>\n\n        {/* Mock macOS window */}\n        <Motion.div\n          initial={{ opacity: 0, y: 8 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ duration: 0.6, delay: 0.1 }}\n          className=\"relative max-w-full overflow-x-auto\"\n        >\n          <div\n            className=\"rounded-[14px] overflow-hidden\"\n            style={{ background: \"var(--surface-raised)\", boxShadow: \"var(--shadow-lg)\" }}\n          >\n            {/* Title bar */}\n            <div\n              className=\"flex gap-[6px] items-center px-3 md:px-[14px] py-[10px]\"\n              style={{ background: \"var(--surface-sunken)\", borderBottom: \"1px solid var(--border-subtle)\" }}\n            >\n              <span className=\"w-[10px] h-[10px] rounded-full bg-[#FF5F57] block\" />\n              <span className=\"w-[10px] h-[10px] rounded-full bg-[#FEBC2E] block\" />\n              <span className=\"w-[10px] h-[10px] rounded-full bg-[#28C840] block\" />\n              <div className=\"ml-auto inline-flex items-center gap-[7px] text-fg-2 text-[12px] tabular-nums px-[10px] py-1 rounded-[6px]\">\n                <span className=\"rec-dot\" />\n                <span style={{ fontFamily: \"var(--font-mono)\" }}>Recording · {fmt(seconds)}</span>\n              </div>\n            </div>\n\n            {/* Content */}\n            <div className=\"px-4 sm:px-6 md:px-8 pt-7 pb-8\">\n              <div\n                className=\"mb-1.5\"\n                style={{\n                  fontFamily: \"var(--font-serif)\",\n                  fontWeight: 400,\n                  fontSize: 26,\n                  letterSpacing: \"-0.015em\",\n                  color: \"var(--fg-1)\",\n                }}\n              >\n                Q1 budget sync\n              </div>\n              <div className=\"text-fg-2 text-[13px] mb-5\">Feb 15, 2026 · 42 min</div>\n              <p className=\"text-sm leading-[1.6] text-fg-1 mb-6\">\n                The team reviewed Q1 variance. Engineering is 8% under plan; marketing is 3% over, driven by a paid pilot. Decision: reallocate $40k through March 31.\n              </p>\n\n              <div className=\"text-sm font-medium text-fg-1 mb-2.5\">Key points</div>\n              <ul className=\"list-none p-0 m-0 flex flex-col gap-2 mb-5\">\n                {[\n                  \"Engineering underspend: two hires slipped to March.\",\n                  \"Paid pilot ahead of signups, over budget.\",\n                  \"Reallocate $40k; revisit at next sync.\",\n                ].map((item) => (\n                  <li key={item} className=\"flex gap-[10px] text-[13.5px] leading-[1.55] text-fg-1\">\n                    <span className=\"w-[3px] h-[3px] rounded-full bg-fg-2 flex-shrink-0 mt-[9px]\" />\n                    {item}\n                  </li>\n                ))}\n              </ul>\n\n              <div className=\"text-sm font-medium text-fg-1 mb-2.5\">Action items</div>\n              <ul className=\"list-none p-0 m-0 flex flex-col gap-2\">\n                {[\n                  \"Marcus to file reallocation request by Friday.\",\n                  \"Priya to draft updated Q1 forecast.\",\n                ].map((item) => (\n                  <li key={item} className=\"flex gap-[10px] text-[13.5px] leading-[1.55] text-fg-1\">\n                    <span className=\"w-[3px] h-[3px] rounded-full bg-fg-2 flex-shrink-0 mt-[9px]\" />\n                    {item}\n                  </li>\n                ))}\n              </ul>\n            </div>\n          </div>\n\n          <div className=\"mt-3 flex items-center gap-1.5 text-fg-muted text-[12px]\">\n            <Cpu size={12} aria-hidden=\"true\" />\n            Summarized locally with{\" \"}\n            <code style={{ fontFamily: \"var(--font-mono)\", fontSize: 11 }}>gemma3:4b</code>\n          </div>\n        </Motion.div>\n\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/HowItWorks.jsx",
    "content": "import { Mic, FileText, Sparkles } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\n\nconst steps = [\n  {\n    n: \"01\",\n    icon: <Mic size={18} aria-hidden=\"true\" />,\n    title: \"Record\",\n    body: \"Capture microphone, system audio, or both. Even with headphones, both sides of a virtual meeting are recorded without any bot joining the call.\",\n  },\n  {\n    n: \"02\",\n    icon: <FileText size={18} aria-hidden=\"true\" />,\n    title: \"Transcribe\",\n    body: \"Whisper.cpp converts audio to text entirely on your Mac. Ninety-nine languages, auto-detected. Apple Silicon runs it fast.\",\n  },\n  {\n    n: \"03\",\n    icon: <Sparkles size={18} aria-hidden=\"true\" />,\n    title: \"Summarize\",\n    body: \"A local language model extracts the summary, key topics, and action items. Nothing is uploaded. Ever.\",\n  },\n];\n\nexport function HowItWorks() {\n  return (\n    <section id=\"how\" className=\"sect\">\n      <div className=\"container-site\">\n        <div className=\"mb-[48px] md:mb-[72px]\" style={{ maxWidth: 640 }}>\n          <h2\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontWeight: 400,\n              fontSize: \"clamp(34px, 4.6vw, 52px)\",\n              lineHeight: 1.08,\n              letterSpacing: \"-0.02em\",\n              color: \"var(--fg-1)\",\n              margin: \"0 0 18px\",\n            }}\n          >\n            Three steps. Zero cloud.\n          </h2>\n          <p className=\"text-fg-2 text-lg leading-[1.55]\" style={{ maxWidth: \"56ch\" }}>\n            From raw audio to structured notes, every step happens on your machine, including the language model.\n          </p>\n        </div>\n\n        <div className=\"grid md:grid-cols-3 gap-8 md:gap-12\">\n          {steps.map((s, i) => (\n            <Motion.div\n              key={s.n}\n              initial={{ opacity: 0, y: 8 }}\n              whileInView={{ opacity: 1, y: 0 }}\n              viewport={{ once: true }}\n              transition={{ duration: 0.4, delay: i * 0.1 }}\n            >\n              <div className=\"flex items-center gap-3 mb-5\">\n                <div\n                  className=\"text-[13px] text-fg-2 tabular-nums\"\n                  style={{ fontVariantNumeric: \"tabular-nums\" }}\n                >\n                  {s.n}\n                </div>\n                <div className=\"text-fg-2\">{s.icon}</div>\n              </div>\n              <h3\n                className=\"text-fg-1 mb-2.5\"\n                style={{ fontWeight: 500, fontSize: 20, letterSpacing: \"-0.01em\" }}\n              >\n                {s.title}\n              </h3>\n              <p className=\"text-fg-2 text-[15px] leading-[1.6]\" style={{ maxWidth: \"34ch\" }}>\n                {s.body}\n              </p>\n            </Motion.div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/Industries.jsx",
    "content": "import { motion as Motion } from \"framer-motion\";\n\nconst inds = [\n  { title: \"Government\", body: \"Sensitive briefings, policy discussions, and internal reviews stay within your perimeter. No cloud dependencies, no third-party processors, no records leaving the device.\" },\n  { title: \"Defense\", body: \"Operational planning and classified discussions run entirely on-device. Works offline, on air-gapped networks, with nothing transiting an external server.\" },\n  { title: \"Legal\", body: \"Client calls and case discussions remain privileged. Enforced by architecture, not policy or a privacy checkbox.\" },\n  { title: \"Finance\", body: \"Earnings prep, board meetings, deal discussions. None of it touches a third-party server.\" },\n];\n\nexport function Industries() {\n  return (\n    <section id=\"industries\" className=\"sect\" style={{ background: \"var(--surface-sunken)\" }}>\n      <div className=\"container-site grid md:grid-cols-[1fr_1.1fr] gap-10 md:gap-20 items-start\">\n\n        <Motion.div\n          initial={{ opacity: 0, y: 8 }}\n          whileInView={{ opacity: 1, y: 0 }}\n          viewport={{ once: true }}\n          transition={{ duration: 0.5 }}\n          style={{ maxWidth: 400 }}\n        >\n          <h2\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontWeight: 400,\n              fontSize: \"clamp(34px, 4.6vw, 52px)\",\n              lineHeight: 1.08,\n              letterSpacing: \"-0.02em\",\n              color: \"var(--fg-1)\",\n              margin: \"0 0 20px\",\n            }}\n          >\n            When privacy isn't optional.\n          </h2>\n          <p className=\"text-fg-2 text-lg leading-[1.55]\">\n            If your meetings contain confidential data, you can't send audio to a third party.\n            stenoAI is built for people who understand this.\n          </p>\n          <blockquote\n            className=\"mt-10 text-fg-2\"\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontSize: \"clamp(18px, 3vw, 22px)\",\n              fontStyle: \"italic\",\n              fontWeight: 400,\n              lineHeight: 1.4,\n              borderLeft: \"2px solid var(--border-strong)\",\n              paddingLeft: 20,\n              margin: \"40px 0 0\",\n            }}\n          >\n            \"Your data never leaves your Mac.\"\n          </blockquote>\n        </Motion.div>\n\n        <div className=\"flex flex-col\">\n          {inds.map((ind, i) => (\n            <Motion.div\n              key={ind.title}\n              initial={{ opacity: 0, y: 8 }}\n              whileInView={{ opacity: 1, y: 0 }}\n              viewport={{ once: true }}\n              transition={{ duration: 0.4, delay: i * 0.08 }}\n              className=\"py-6\"\n              style={{\n                borderTop: \"1px solid var(--border-subtle)\",\n                borderBottom: i === inds.length - 1 ? \"1px solid var(--border-subtle)\" : \"none\",\n              }}\n            >\n              <h3 className=\"text-fg-1 mb-1.5\" style={{ fontWeight: 500, fontSize: 18 }}>{ind.title}</h3>\n              <p className=\"text-fg-2 text-[15px] leading-[1.55]\" style={{ maxWidth: \"56ch\" }}>{ind.body}</p>\n            </Motion.div>\n          ))}\n        </div>\n\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/Models.jsx",
    "content": "import { useState } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\n\nconst models = [\n  { id: \"llama3.2:3b\",     label: \"Llama 3.2\",   detail: \"3B · Fast\" },\n  { id: \"gemma3:4b\",       label: \"Gemma 3\",      detail: \"4B · Balanced\" },\n  { id: \"qwen3.5:9b\",      label: \"Qwen 3.5\",     detail: \"9B · Smart\" },\n  { id: \"deepseek-r1:14b\", label: \"DeepSeek R1\",  detail: \"14B · Reasoning\" },\n  { id: \"gpt-oss:20b\",     label: \"GPT-OSS\",      detail: \"20B · Capable\" },\n];\n\nexport function Models() {\n  const [active, setActive] = useState(\"llama3.2:3b\");\n\n  return (\n    <section id=\"models\" className=\"sect\">\n      <div className=\"container-site grid md:grid-cols-2 gap-10 md:gap-20 items-center\">\n\n        <Motion.div\n          initial={{ opacity: 0, y: 8 }}\n          whileInView={{ opacity: 1, y: 0 }}\n          viewport={{ once: true }}\n          transition={{ duration: 0.5 }}\n        >\n          <h2\n            style={{\n              fontFamily: \"var(--font-serif)\",\n              fontWeight: 400,\n              fontSize: \"clamp(34px, 4.6vw, 52px)\",\n              lineHeight: 1.08,\n              letterSpacing: \"-0.02em\",\n              color: \"var(--fg-1)\",\n              margin: \"0 0 18px\",\n            }}\n          >\n            Your model, your choice.\n          </h2>\n          <p className=\"text-fg-2 text-lg leading-[1.55]\" style={{ maxWidth: \"44ch\" }}>\n            Five open-weight models included. Switch instantly without restarting.\n            All run on your Mac.\n          </p>\n        </Motion.div>\n\n        <Motion.div\n          initial={{ opacity: 0, y: 8 }}\n          whileInView={{ opacity: 1, y: 0 }}\n          viewport={{ once: true }}\n          transition={{ duration: 0.5, delay: 0.1 }}\n          className=\"flex flex-col gap-0.5\"\n        >\n          {models.map((m) => (\n            <button\n              key={m.id}\n              onClick={() => setActive(m.id)}\n              className=\"flex items-center gap-3 px-4 py-[14px] rounded-[8px] border-0 bg-transparent cursor-pointer w-full text-left transition-colors text-fg-1\"\n              style={{\n                background: active === m.id ? \"var(--surface-hover)\" : \"transparent\",\n                transition: \"background var(--dur-fast) var(--ease)\",\n              }}\n            >\n              <div className=\"flex flex-col gap-0.5 flex-1\">\n                <span className=\"text-[15px] font-medium text-fg-1\">{m.label}</span>\n                <code\n                  className=\"text-fg-2\"\n                  style={{ fontFamily: \"var(--font-mono)\", fontSize: 12 }}\n                >\n                  {m.id}\n                </code>\n              </div>\n              <span className=\"text-fg-2 text-[13px] whitespace-nowrap\">{m.detail}</span>\n              {active === m.id && <Check size={14} className=\"text-fg-1\" aria-hidden=\"true\" />}\n            </button>\n          ))}\n        </Motion.div>\n\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/Nav.jsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { Github, Download } from \"lucide-react\";\nimport { StenoMark, Wordmark } from \"../components/Brand\";\nimport { ThemeToggle } from \"../components/ThemeToggle\";\nimport { trackDownload, trackGitHub } from \"../analytics\";\n\nconst GITHUB_URL = \"https://github.com/ruzin/stenoai\";\n\nexport function Nav() {\n  const [scrolled, setScrolled] = useState(false);\n\n  useEffect(() => {\n    const fn = () => setScrolled(window.scrollY > 12);\n    window.addEventListener(\"scroll\", fn, { passive: true });\n    return () => window.removeEventListener(\"scroll\", fn);\n  }, []);\n\n  return (\n    <nav\n      className=\"sticky top-0 z-40 transition-shadow\"\n      style={{\n        background: \"var(--surface-translucent)\",\n        backdropFilter: \"blur(16px)\",\n        borderBottom: scrolled ? \"1px solid var(--border-subtle)\" : \"1px solid transparent\",\n        boxShadow: scrolled ? \"var(--shadow-sm)\" : \"none\",\n      }}\n    >\n      <div className=\"container-site flex items-center justify-between py-[18px]\">\n        <a href=\"#\" className=\"flex items-center gap-[10px] text-fg-1 no-underline hover:no-underline\">\n          <StenoMark size={22} />\n          <Wordmark size={18} />\n        </a>\n\n        <div className=\"hidden md:flex gap-7 items-center\">\n          <a href=\"#how\" className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">How it works</a>\n          <a href=\"#features\" className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">Features</a>\n          <a href=\"#industries\" className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">Who it's for</a>\n          <a href=\"#faq\" className=\"text-fg-2 text-sm no-underline hover:text-fg-1 transition-colors\">FAQ</a>\n        </div>\n\n        <div className=\"flex gap-1 items-center\">\n          <ThemeToggle />\n          <a\n            href={GITHUB_URL}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            onClick={() => trackGitHub('nav')}\n            className=\"hidden md:inline-flex btn-base btn-ghost btn-sm items-center gap-2 no-underline hover:no-underline\"\n          >\n            <Github size={14} aria-hidden=\"true\" /> GitHub\n          </a>\n          <a\n            href=\"#download\"\n            onClick={() => trackDownload('nav', 'unknown')}\n            className=\"btn-base btn-primary btn-sm inline-flex items-center gap-2 no-underline hover:no-underline\"\n          >\n            Download\n          </a>\n        </div>\n      </div>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "website/src/sections/TrustStrip.jsx",
    "content": "const logos = [\n  { name: \"AWS\", src: \"/logos/aws.svg\" },\n  { name: \"HashiCorp\", src: \"/logos/hashicorp.svg\" },\n  { name: \"Tesco\", src: \"/logos/tesco.svg\", h: \"h-10 sm:h-12\" },\n  { name: \"Deliveroo\", src: \"/logos/deliveroo.svg\" },\n  { name: \"Rutgers\", src: \"/logos/rutgers.svg\" },\n  { name: \"European Union\", src: \"/logos/european-union.svg\" },\n];\n\nexport function TrustStrip() {\n  return (\n    <section className=\"py-6 md:py-8\">\n      <div className=\"container-site\">\n        <div className=\"text-center text-fg-2 text-xs tracking-[0.06em] uppercase mb-7\">\n          Trusted by users at\n        </div>\n        <div className=\"flex flex-wrap justify-center items-center gap-x-12 sm:gap-x-20 gap-y-6\">\n          {logos.map((l) => (\n            <img\n              key={l.name}\n              src={l.src}\n              alt={l.name}\n              className={`${l.h || \"h-6 sm:h-7\"} w-auto dark:invert`}\n              style={{ opacity: 0.5 }}\n            />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "website/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{js,ts,jsx,tsx}\",\n  ],\n  theme: {\n    extend: {},\n  },\n  plugins: [],\n}"
  },
  {
    "path": "website/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [react()],\n  base: '/',\n  build: {\n    outDir: 'dist',\n    assetsDir: 'assets'\n  }\n})\n"
  }
]