Full Code of ruzin/stenoai for AI

main 1c831cba0c08 cached
147 files
1.1 MB
316.9k tokens
767 symbols
1 requests
Download .txt
Showing preview only (1,227K chars total). Download the full file or copy to clipboard to get everything.
Repository: ruzin/stenoai
Branch: main
Commit: 1c831cba0c08
Files: 147
Total size: 1.1 MB

Directory structure:
gitextract_90ku06c9/

├── .clabot
├── .github/
│   ├── FUNDING.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── build-release.yml
│       └── deploy-website.yml
├── .gitignore
├── CLA.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── announcements.json
├── app/
│   ├── build/
│   │   ├── entitlements.mac.plist
│   │   ├── icon-dragonfly.icns
│   │   └── icon.icns
│   ├── electron-builder.ci.yml
│   ├── main.js
│   ├── package-lock.json
│   ├── package.json
│   ├── preload.js
│   ├── renderer/
│   │   ├── .eslintrc.cjs
│   │   ├── .prettierrc.json
│   │   ├── components.json
│   │   ├── index.html
│   │   ├── postcss.config.cjs
│   │   ├── src/
│   │   │   ├── App.tsx
│   │   │   ├── components/
│   │   │   │   ├── AppShell.tsx
│   │   │   │   ├── AskBar.tsx
│   │   │   │   ├── AudioWave.tsx
│   │   │   │   ├── BottomDockSlot.tsx
│   │   │   │   ├── ChatHistoryRow.tsx
│   │   │   │   ├── FolderScopePicker.tsx
│   │   │   │   ├── IconPicker.tsx
│   │   │   │   ├── LiveDock.tsx
│   │   │   │   ├── MainToolbar.tsx
│   │   │   │   ├── MeetingsShell.tsx
│   │   │   │   ├── QuitDialog.tsx
│   │   │   │   ├── Sidebar.tsx
│   │   │   │   ├── TranscriptPanel.tsx
│   │   │   │   ├── home/
│   │   │   │   │   ├── PreviousRow.tsx
│   │   │   │   │   └── UpcomingCard.tsx
│   │   │   │   └── ui/
│   │   │   │       ├── app-icon.tsx
│   │   │   │       ├── button.tsx
│   │   │   │       ├── card.tsx
│   │   │   │       ├── chip.tsx
│   │   │   │       ├── confirm-dialog.tsx
│   │   │   │       ├── dialog.tsx
│   │   │   │       ├── input.tsx
│   │   │   │       ├── kbd.tsx
│   │   │   │       ├── popover.tsx
│   │   │   │       ├── row.tsx
│   │   │   │       ├── select.tsx
│   │   │   │       ├── switch.tsx
│   │   │   │       ├── tabs.tsx
│   │   │   │       ├── tooltip.tsx
│   │   │   │       └── typography.tsx
│   │   │   ├── globals.css
│   │   │   ├── hooks/
│   │   │   │   ├── index.ts
│   │   │   │   ├── liveDraftStore.ts
│   │   │   │   ├── meetingKeys.ts
│   │   │   │   ├── useAi.ts
│   │   │   │   ├── useAiPrompts.ts
│   │   │   │   ├── useAudioLevel.ts
│   │   │   │   ├── useCalendarEvents.ts
│   │   │   │   ├── useChatSessions.ts
│   │   │   │   ├── useFolders.ts
│   │   │   │   ├── useLiveMeeting.ts
│   │   │   │   ├── useMeetings.ts
│   │   │   │   ├── useModels.ts
│   │   │   │   ├── useRecording.ts
│   │   │   │   ├── useSettings.ts
│   │   │   │   ├── useSetup.ts
│   │   │   │   ├── useStreamingQuery.ts
│   │   │   │   └── useTheme.ts
│   │   │   ├── lib/
│   │   │   │   ├── askBarContext.tsx
│   │   │   │   ├── chat.ts
│   │   │   │   ├── chatPresets.tsx
│   │   │   │   ├── debugLogs.ts
│   │   │   │   ├── ipc.ts
│   │   │   │   ├── markdown.tsx
│   │   │   │   ├── meetingDetailState.ts
│   │   │   │   ├── meetingsListContext.tsx
│   │   │   │   ├── queryClient.ts
│   │   │   │   ├── result.ts
│   │   │   │   ├── router.ts
│   │   │   │   └── utils.ts
│   │   │   ├── main.tsx
│   │   │   └── routes/
│   │   │       ├── Chat.tsx
│   │   │       ├── ChatConversation.tsx
│   │   │       ├── FolderDetail.tsx
│   │   │       ├── Home.tsx
│   │   │       ├── MeetingDetail.tsx
│   │   │       ├── Processing.tsx
│   │   │       ├── Recording.tsx
│   │   │       ├── Sandbox.tsx
│   │   │       ├── Settings.tsx
│   │   │       └── Setup.tsx
│   │   ├── tailwind.config.cjs
│   │   └── tsconfig.json
│   └── vite.config.ts
├── prompt_tests/
│   ├── PROMPT_TESTING.md
│   └── test_prompts.py
├── requirements.txt
├── scripts/
│   ├── build-backend.sh
│   ├── download-ollama.sh
│   ├── test_dmg_fresh_install.sh
│   └── test_first_time_setup.sh
├── setup.py
├── simple_recorder.py
├── src/
│   ├── __init__.py
│   ├── audio_recorder.py
│   ├── config.py
│   ├── folders.py
│   ├── models.py
│   ├── ollama_manager.py
│   ├── summarizer.py
│   └── transcriber.py
├── tests/
│   ├── __init__.py
│   ├── test_config.py
│   └── test_transcriber.py
└── website/
    ├── .gitignore
    ├── README.md
    ├── eslint.config.js
    ├── index.html
    ├── package.json
    ├── postcss.config.js
    ├── public/
    │   ├── CNAME
    │   ├── privacy.html
    │   └── terms.html
    ├── src/
    │   ├── App.jsx
    │   ├── analytics.js
    │   ├── components/
    │   │   ├── Brand.jsx
    │   │   └── ThemeToggle.jsx
    │   ├── index.css
    │   ├── main.jsx
    │   └── sections/
    │       ├── CTAFooter.jsx
    │       ├── FAQ.jsx
    │       ├── Features.jsx
    │       ├── Footer.jsx
    │       ├── Hero.jsx
    │       ├── HowItWorks.jsx
    │       ├── Industries.jsx
    │       ├── Models.jsx
    │       ├── Nav.jsx
    │       └── TrustStrip.jsx
    ├── tailwind.config.js
    └── vite.config.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .clabot
================================================
{
  "contributors": [],
  "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.",
  "label": "cla-signed",
  "recheckComment": "I have read the CLA Document and I hereby sign the CLA"
}


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: ruzin



================================================
FILE: .github/pull_request_template.md
================================================
## Description

Brief description of what this PR does and why it's needed.

## Type of Change

- [ ] Bug fix
- [ ] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Testing

- [ ] Tested locally with `npm start`
- [ ] Verified CLI functionality works
- [ ] Tested on macOS
- [ ] No breaking changes to existing functionality

## Additional Notes

Any additional context about this change.

================================================
FILE: .github/workflows/build-release.yml
================================================
name: Build and Release DMG

on:
  push:
    tags:
      - 'v*.*.*'
  workflow_dispatch:
    inputs:
      version:
        description: 'Version number (e.g., 1.0.0)'
        required: true
        default: '1.0.0'

permissions:
  contents: write

jobs:
  build-macos:
    strategy:
      matrix:
        include:
          - arch: x64
            build_cmd: build:intel
            runner: macos-15-intel
          - arch: arm64
            build_cmd: build:arm64
            runner: macos-14
    runs-on: ${{ matrix.runner }}

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Setup Python
      uses: actions/setup-python@v5
      with:
        python-version: '3.11'

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '22'
        cache: 'npm'
        cache-dependency-path: app/package-lock.json

    - name: Get version from package.json
      id: package_version
      working-directory: app
      run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT

    - name: Install Python dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install pyinstaller

    - name: Download bundled binaries (Ollama, ffmpeg)
      run: |
        chmod +x scripts/download-ollama.sh
        ./scripts/download-ollama.sh

    - name: Strip ad-hoc code signatures from dylibs (fixes install_name_tool on Intel)
      if: matrix.arch == 'x64'
      run: |
        # pywhispercpp ships pre-signed dylibs that install_name_tool can't modify
        # Strip signatures so PyInstaller can rewrite library paths
        find "$(python -c 'import site; print(site.getsitepackages()[0])')/pywhispercpp" \
          -name "*.dylib" -exec codesign --remove-signature {} \; 2>/dev/null || true
        echo "Stripped ad-hoc signatures from pywhispercpp dylibs"

    - name: Build Python backend with PyInstaller
      run: |
        pyinstaller stenoai.spec --noconfirm
        # Verify build and architecture
        ls -la dist/stenoai/
        file dist/stenoai/stenoai
        echo "Expected arch: ${{ matrix.arch }}"
        echo "Backend built successfully"

    - name: Install Electron dependencies
      working-directory: app
      run: npm ci

    - name: Import code signing certificate
      uses: apple-actions/import-codesign-certs@v2
      with:
        p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
        p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}

    - name: Build and notarize DMG for ${{ matrix.arch }}
      working-directory: app
      run: npm run ${{ matrix.build_cmd }}
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        APPLE_ID: ${{ secrets.APPLE_ID }}
        APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
        APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

    - name: Upload build artifacts
      uses: actions/upload-artifact@v4
      with:
        name: build-${{ matrix.arch }}
        path: |
          app/dist/*.dmg
          app/dist/*.zip
          app/dist/*.yml
          app/dist/*.yaml
        retention-days: 1

  release:
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    needs: build-macos
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
        fetch-tags: true

    - name: Get version from package.json
      id: package_version
      working-directory: app
      run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT

    - name: Extract annotated tag message
      id: tag_body
      if: startsWith(github.ref, 'refs/tags/v')
      run: |
        # Pull the body of the annotated tag (strips the PGP signature if any).
        # Falls back to the commit subject if the tag is lightweight.
        MSG=$(git for-each-ref --format='%(contents:body)' "refs/tags/${{ github.ref_name }}")
        if [ -z "$MSG" ]; then
          MSG=$(git for-each-ref --format='%(contents:subject)' "refs/tags/${{ github.ref_name }}")
        fi
        {
          echo 'text<<STENO_RELEASE_EOF'
          echo "$MSG"
          echo 'STENO_RELEASE_EOF'
        } >> "$GITHUB_OUTPUT"

    - name: Download all build artifacts
      uses: actions/download-artifact@v4
      with:
        path: artifacts

    - name: Prepare release files
      run: |
        mkdir -p release
        find artifacts -name "*.dmg" -exec cp {} release/ \;
        find artifacts -name "*.zip" -exec cp {} release/ \;
        find artifacts -name "*.yml" -o -name "*.yaml" | xargs -I {} cp {} release/ 2>/dev/null || true
        ls -la release/
        # Create website-friendly aliases for DMGs
        version="${{ github.event.inputs.version || steps.package_version.outputs.version }}"
        cd release
        for dmg in *.dmg; do
          if [[ "$dmg" == *"-x64.dmg" ]]; then
            cp "$dmg" "stenoAI-macos-x64.dmg"
          elif [[ "$dmg" == *"-arm64.dmg" ]]; then
            cp "$dmg" "stenoAI-macos-arm64.dmg"
          fi
        done
        ls -la

    - name: Create Release with Assets
      uses: softprops/action-gh-release@v1
      with:
        name: StenoAI ${{ github.event.inputs.version || steps.package_version.outputs.version }}
        body: |
          ## StenoAI v${{ github.event.inputs.version || steps.package_version.outputs.version }}

          ${{ steps.tag_body.outputs.text }}

          ### Downloads

          - **Apple Silicon (M1-M5)**: `stenoAI-macos-arm64.dmg`
          - **Intel Macs**: `stenoAI-macos-x64.dmg`

          ### First Time Setup
          1. Download the appropriate DMG for your Mac
          2. Install by dragging StenoAI to Applications
          3. Launch app - setup wizard runs automatically
          4. Grant microphone permissions when prompted
          5. Start recording meetings!

          ### Requirements
          - macOS 10.14 or later
          - Internet connection for initial setup
          - Microphone access permissions
        files: |
          release/*.dmg
          release/*.zip
          release/*.yml
          release/*.yaml
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


================================================
FILE: .github/workflows/deploy-website.yml
================================================
name: Deploy Website to GitHub Pages

on:
  push:
    branches: [ main ]
    paths: [ 'website/**' ]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout
      uses: actions/checkout@v4
      
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
        cache-dependency-path: website/package-lock.json
        
    - name: Install dependencies
      working-directory: website
      run: npm ci
      
    - name: Build website
      working-directory: website
      run: npm run build
      
    - name: Setup Pages
      uses: actions/configure-pages@v4
      
    - name: Upload artifact
      uses: actions/upload-pages-artifact@v3
      with:
        path: website/dist
        
    - name: Deploy to GitHub Pages
      id: deployment
      uses: actions/deploy-pages@v4

================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
!app/build/
app/build/*
!app/build/entitlements.mac.plist
!app/build/icon.icns
!app/build/icon-dragonfly.icns
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
!app/renderer/src/lib/

# Superpowers skill scratch — agent working notes, not project content.
docs/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# pipenv
Pipfile.lock

# PEP 582
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# StenoAI specific
recordings/*.wav
transcripts/*.txt
output/*.json
recorder_state*.json
audio_buffer.npy

# Node.js
node_modules/
app/node_modules/
e2e/node_modules

# Playwright
e2e/test-results/
e2e/playwright-report/

# IDE
.vscode/
.idea/
.cursor/
.codex/
.opencode/

# AI tool configs (user/session-specific)
.agents/
.claude/
.github/skills/

# OS
.DS_Store
Thumbs.db

# Config with credentials
config/
*.env
*_config.json
config.json
!app/package*.json

# Local documentation and review notes
CODE_REVIEW.md
SESSION_LOG.md
FEATURES.md

# Prompt testing outputs
prompt_tests/outputs/

# Bundled Ollama binary (downloaded during build)
bin/


================================================
FILE: CLA.md
================================================
# Contributor License Agreement

Thank you for your interest in contributing to StenoAI (the "Project").

This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project maintainer.

## 1. Definitions

"You" (or "Your") means the copyright owner or legal entity authorized by the copyright owner that is making this Agreement.

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

"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).

## 2. Grant of Copyright License

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

- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works
- Sublicense the above rights to third parties
- **Relicense Your Contributions under different terms**, including but not limited to commercial licenses

This grant includes the right to distribute Your Contributions under licenses different from the Project's current license (MIT), including proprietary commercial licenses.

## 3. Grant of Patent License

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

## 4. You Retain Ownership

You retain all right, title, and interest in and to Your Contributions. This Agreement does not transfer ownership; it only grants licenses as described above.

## 5. Your Representations

You represent that:

- You are legally entitled to grant the above licenses
- Each of Your Contributions is Your original creation (or You have rights to submit it)
- Your Contribution submissions include complete details of any third-party licenses or restrictions
- You will notify the Project if any of the above representations becomes inaccurate

## 6. No Obligation

The Project maintainer is under no obligation to:

- Accept or include Your Contribution
- Distribute Your Contribution in any particular version
- Provide support or maintenance for Your Contribution
- Release the Project or Your Contribution under any particular license

## 7. Support and Disclaimers

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

---

**By submitting a Contribution, You accept and agree to the terms and conditions of this Agreement for Your present and future Contributions.**

This Agreement is effective upon Your first Contribution to the Project.


================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Do not use excessive emojis anywhere.

## Architecture

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

- **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`.
- **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.
- **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`.
- **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.
- **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.
- **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`.
- **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`.

## Development Commands

### Backend (Python)
- Build the bundled backend: `source venv/bin/activate && pyinstaller stenoai.spec --noconfirm`
- Inspect CLI surface: `dist/stenoai/stenoai --help`
- Most relevant CLI commands for debugging: `status`, `setup-check`, `list_failed`, `reprocess path/to/summary.json`, `query transcript.txt`, `pipeline filename.wav`
- Lint: `ruff check .`
- Run all tests: `python -m unittest discover tests`
- Run a single test: `python -m unittest tests.test_config.ConfigStoragePathTests.test_set_storage_path_handles_permission_errors`

### Desktop App (Electron)
- Start app (dev): `cd app && npm start`
- Build DMG (local, for testing): `cd app && npm run build`

For setup from a clean checkout, see `CONTRIBUTING.md` and `README.md`.

## Production Readiness
This app ships as a signed DMG to real users. Before considering any change complete:
- **Packaged app test**: Dev mode (`npm start`) is not sufficient. Always rebuild the DMG (`npm run build`) and test the installed app from `/Applications`.
- **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.
- **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.
- **No bare `exit()` in Python code**. PyInstaller bundles don't have `exit` as a builtin. Always use `sys.exit()`.

## Brand Colors
StenoAI logo gradient (used in website logo SVG and app header):
- Indigo: `#6366f1`
- Sky blue: `#0ea5e9`
- Cyan: `#06b6d4`
- CSS: `linear-gradient(135deg, #6366f1, #0ea5e9, #06b6d4)`

App UI accent: `--accent-primary: #818cf8` (lighter indigo, used for focus states, active tabs, toggles)

## Git Workflow
- Always create a branch for changes unless explicitly told otherwise
- Never commit directly to `main`
- Before creating a PR, run a self-review of the full branch diff (`git diff main...HEAD`):
  - Review backend code for security issues, error handling gaps, edge cases, and best practices
  - Review frontend code for layout bugs, CSS consistency, accessibility, and polish
  - Use the frontend-design skill for UI-related changes
  - Categorize findings by severity (critical/medium/low) and fix critical issues before merging

## Git Commit Guidelines
- Do NOT include "Generated with Claude Code" attribution in commit messages
- Do NOT include "Co-Authored-By: Claude <noreply@anthropic.com>" in commit messages
- Keep commit messages concise and focused on what changed
- Use conventional commit format when appropriate (feat:, fix:, docs:, etc.)

## Release Process
Releases are automated via `.github/workflows/build-release.yml`. Never create releases manually.

1. Bump version in `app/package.json` (on the branch, before merging)
2. After PR is merged to `main`, create an **annotated tag** on main with the release notes in the tag message:
   ```
   git tag -a v0.2.5 -m "Release notes here..."
   git push origin v0.2.5
   ```
3. The tag push triggers the workflow which:
   - Builds signed + notarized DMGs for both arm64 and x64
   - Creates a GitHub Release with the tag message as the "What's New" section
   - Uploads both DMGs as release assets
4. The tag message becomes the release notes body — write it as markdown with a summary of changes
5. Do NOT build DMGs locally for releases, do NOT use `gh release create` manually

## README "What's New" Section
The 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):
1. Check recently merged PRs: `gh pr list --state merged --limit 10`
2. For each notable PR, add a row to the table with the merge date and a one-sentence summary
3. Keep "Coming soon" items for features that are planned but not yet shipped
4. Remove entries older than ~2 months to keep the section fresh
5. Most recent entries go at the top of the table

## Session Logging
When the user says "log session" or similar (e.g., "update session log", "document this session"):
1. Update SESSION_LOG.md in the root directory with the current session details
2. Include: date/time, summary of work, key decisions, files modified, issues resolved, next steps
3. REPLACE or CONDENSE previous session entries to keep the file concise (max 2-3 most recent sessions)
4. Keep only relevant context for the next Claude session - remove outdated or completed work details
5. Format with clear headers and organized sections


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to StenoAI

Thank you for your interest in contributing to StenoAI! This guide will help you get started.

## Getting Started

### Prerequisites

- macOS (required for development and testing)
- Python 3.8+
- Node.js 18+
- Git

### Local Development Setup

1. **Fork and clone the repository**
   ```bash
   git clone https://github.com/your-username/stenoai.git
   cd stenoai
   ```

2. **Set up Python environment**
   ```bash
   python3 -m venv venv
   source venv/bin/activate
   pip install -r requirements.txt
   pip install -e .
   ```

3. **Install system dependencies**
   ```bash
   # Install Ollama
   brew install ollama
   ollama serve &
   ollama pull llama3.2:3b
   
   # Install ffmpeg
   brew install ffmpeg
   ```

4. **Set up Electron app**
   ```bash
   cd app
   npm install
   npm start
   ```

5. **Test the setup**
   ```bash
   # Test CLI
   python simple_recorder.py --help
   
   # Test app launch
   cd app && npm start
   ```

## Development Workflow

### Making Changes

1. **Create a feature branch**
   ```bash
   git checkout -b feature/your-feature-name
   ```

2. **Make your changes**
   - Follow existing code style and patterns
   - Test your changes locally
   - Update documentation if needed

3. **Test your changes**
   ```bash
   # Test Python code
   python simple_recorder.py --help
   python -c "import src.audio_recorder, src.transcriber, src.summarizer"
   
   # Test Electron app
   cd app && npm start
   ```

4. **Commit and push**
   ```bash
   git add .
   git commit -m "Add your descriptive commit message"
   git push origin feature/your-feature-name
   ```

5. **Create a Pull Request**
   - Use the PR template to describe your changes
   - Focus on clear description and testing details
   - Be responsive to review feedback

### Code Style

**Python:**
- Follow PEP 8 guidelines
- Use type hints where appropriate
- Write docstrings for functions and classes
- Use `ruff` for linting: `ruff check .`

**JavaScript:**
- Use semicolons
- Use const/let instead of var
- Follow existing patterns in the codebase

### Testing

Before submitting a PR, please ensure:

- [ ] CLI functionality works: `python simple_recorder.py --help`
- [ ] Electron app starts: `cd app && npm start`
- [ ] No breaking changes to existing functionality

## Versioning

This project uses manual semantic versioning:

- Maintainers handle version bumps and releases
- Contributors focus on code quality, not versioning
- Releases are created manually using `npm version` commands

## Types of Contributions

### Bug Reports

When filing a bug report, please include:
- macOS version
- Steps to reproduce
- Expected vs actual behavior
- Error messages or logs
- Screenshots if applicable

### Feature Requests

For feature requests, please:
- Describe the problem you're trying to solve
- Explain your proposed solution
- Consider if this fits the project's scope and vision

### Code Contributions

We welcome contributions for:
- Bug fixes
- Performance improvements
- New features (please discuss in an issue first)
- Documentation improvements
- Test coverage improvements

### Documentation

Help improve our documentation:
- Fix typos or unclear instructions
- Add examples or clarifications
- Update outdated information

## Project Structure

```
stenoai/
├── app/                  # Electron desktop app
│   ├── main.js          # Main process
│   ├── preload.js       # Context-isolated IPC bridge
│   ├── renderer/        # React + Vite renderer (TypeScript)
│   └── package.json     # App dependencies
├── src/                  # Python backend
│   ├── audio_recorder.py    # Audio recording
│   ├── transcriber.py       # Whisper integration
│   ├── summarizer.py        # Ollama/LLM processing
│   └── models.py            # Data models
├── simple_recorder.py    # CLI interface
├── requirements.txt      # Python dependencies
└── CLAUDE.md            # Development instructions
```

## Getting Help

- Check existing [issues](https://github.com/ruzin/stenoai/issues)
- Create a new issue for bugs or feature requests
- Join discussions in the repository

## Contributor License Agreement

By contributing to StenoAI, you agree to our [Contributor License Agreement (CLA)](CLA.md).

**What this means:**
- You retain ownership of your contributions
- You grant us broad, irrevocable rights to use, modify, and relicense your contributions
- This allows us to offer commercial licenses while keeping the project free for personal use

**How it works:**
- When you submit your first pull request, CLA Assistant will prompt you to sign
- Simply comment "I have read the CLA Document and I hereby sign the CLA" on the PR
- This is a one-time process - future contributions are automatically covered

The project is licensed under the MIT License. See [LICENSE](LICENSE) for details.

## Recognition

Contributors will be recognized in our releases and README. Thank you for helping make StenoAI better!

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Skrape Limited

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<div align="center">
  <img src="website/public/dragonfly-logo-512.png" alt="StenoAI Logo" width="120" height="120">

  # StenoAI

  *Your private stenographer*
</div>

<p align="center">
  <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>
  <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>
  <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>
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
  <img src="https://img.shields.io/badge/Platform-macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS">
  <a href="#sponsors"><img src="https://img.shields.io/badge/Sponsors-%E2%9D%A4-EA4AAA?style=for-the-badge" alt="Sponsors"></a>
</p>

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

<p align="center"><sub>Trusted by users at <b>AWS</b>, <b>Deliveroo</b>, <b>Tesco</b> & <b>HashiCorp</b>.</sub></p>

<div align="center">
  <img src="website/public/readme.png" alt="StenoAI Interface" width="800">

  <br>

  [![Twitter Follow](https://img.shields.io/twitter/follow/ruzin?style=social)](https://x.com/ruzin_saleem)
</div>

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

## Sponsors

### Recall.ai - API for desktop recording

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

## 📢 What's New
- **2026-04-19** 🔄 In-app auto-updates — Updates download in the background and install on next quit; no more manual DMG downloads
- **2026-04-19** 💬 Inline ask bar — Query your meetings from a floating bar at the bottom of every note
- **2026-04-19** 📂 Ask against saved markdown — The ask bar now reads your saved `.md` notes directly (summary, topics, and full transcript)
- **2026-04-19** 📝 Diarised markdown export — Saved transcripts include `[You]` / `[Others]` speaker labels
- **2026-03-25** ✍️ In-app note-taking — Jot notes during a recording and they're folded into the AI summary
- **2026-03-23** 🗣️ Speaker diarisation — [You] vs [Others] labels for system audio recordings
- **2026-03-23** 🌍 Auto-detect language — 99 languages supported out of the box
- **2026-03-04** 🏷️ Auto-generated meeting titles — AI creates short titles from your transcripts

## Features

- **Privacy-first** — 100% on-device; your recordings, transcripts, and summaries never leave your Mac
- **In-app note-taking** — Jot notes while you record; they're folded straight into the AI summary
- **Ask your meetings** — Natural-language Q&A across any saved note, including summary, key topics, and full transcript
- **System audio capture** — Record both sides of virtual meetings, headphones on, no extra setup
- **Speaker diarisation** — `[You]` vs `[Others]` labels on system-audio recordings
- **Multi-language** — Auto-detect and transcribe in 99 languages
- **Markdown notes** — Summaries and transcripts saved as clean Markdown you can edit, search, or sync
- **Remote Ollama server** — Offload summarisation to a beefier Mac or workstation on your network
- **Bring your own cloud model** — Optional OpenAI, Anthropic, or custom API endpoint for users who prefer a hosted LLM
- **Under the hood** — Local transcription via whisper.cpp, summarisation via bundled Ollama (5 models to choose from)

## macOS Shortcuts (Optional)

<details>
<summary>Expand setup and calendar automation guide</summary>

StenoAI supports Apple Shortcuts via deep links using the `stenoai://` URL scheme.

- Start recording: `stenoai://record/start?name=Daily%20Standup`
- Stop recording: `stenoai://record/stop`

### How to set it up

1. Open the **Shortcuts** app on macOS.
2. Create a new shortcut (for example: "Start StenoAI Recording").
3. Add the **Open URLs** action.
4. Use one of the URLs above.
5. (Optional) Add a keyboard shortcut from the shortcut settings.

### Calendar event naming (optional)

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

Example:

`stenoai://record/start?name=Weekly%20Product%20Sync`

### Calendar event start automation (via Rules bridge)

macOS Shortcuts **cannot natively trigger** exactly at Calendar event start.  
To run this automatically on event timing, a third-party automation app is required.

This addon uses:

- **Apple Shortcuts**: builds the `stenoai://record/start?...` action.
- **Rules – Calendar Automation**: watches Calendar events and triggers the shortcut.

#### Architecture overview

1. Rules App monitors upcoming Calendar events.
2. Rules checks the event note/body for a marker keyword (for example `stenoai`).
3. If matched, Rules runs a Shortcut.
4. The Shortcut gets the next event title and opens:
   - `stenoai://record/start?name={calendar_event_title}`
5. StenoAI receives the URL and starts recording with that name.

#### Step-by-step setup

1. Install **Rules – Calendar Automation** on macOS.
2. Create a Shortcut in Apple Shortcuts (example name: `StenoAI Start From Calendar Event`).
3. In that Shortcut, add actions in this order:
   - `Find Calendar Events` (limit to `1`, sorted by start date ascending, upcoming only)
   - Extract the event title from the found event
   - `URL Encode` the title
   - `Open URLs` with:
     - `stenoai://record/start?name=<encoded title>`
4. Open Rules and create a calendar-trigger rule:
   - Source: your target calendar(s)
   - Trigger window: event start (or preferred offset)
   - Condition: event note contains `stenoai`
   - Action: run Shortcut `StenoAI Start From Calendar`
5. In your Calendar event notes, add the word `stenoai` for meetings that should auto-start recording.
6. Test with a near-future event:
   - create event with `stenoai` in notes,
   - wait for trigger,
   - confirm StenoAI starts and uses the event title as session name.

#### Notes

- Without Rules (or another automation bridge), this cannot be fully event-driven from Calendar start time.
- Keep using regular manual shortcuts (`Open URLs`) for non-automated scenarios.

Have questions or suggestions? [Join our Discord](https://discord.gg/DZ6vcQnxxu) to chat with the community.
</details>

## Models & Performance

**Transcription Models** (Whisper):
- `small`: Default model - good accuracy and speed on Apple Silicon **(default)**
- `base`: Faster but lower accuracy for basic meetings
- `medium`: High accuracy for important meetings (slower)

**Summarization Models** (Ollama):
- `llama3.2:3b` (2GB): Fast and lightweight for quick meetings **(default)**
- `gemma3:4b` (2.5GB): Lightweight and efficient
- `qwen3.5:9b` (6.6GB): Excellent at structured output and action items
- `deepseek-r1:14b` (9.0GB): Strong reasoning and analysis capabilities
- `gpt-oss:20b` (14GB): OpenAI open-weight model with reasoning capabilities

## Future Roadmap

### Enhanced Features
- Live transcription during recording
- NVIDIA Parakeet as a transcription engine option
- Editing notes after processing
- Windows version

## Installation

Download the latest release for your Mac (**requires macOS 14 Sonoma or later**):

- [Apple Silicon (M1-M5)](https://github.com/ruzin/stenoai/releases/latest/download/stenoAI-macos-arm64.dmg)
- [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.

### Installing on macOS

1. **Download and open the DMG file**
2. **Drag the app to Applications**
3. **When you first launch the app**, macOS may show a security warning
4. **To fix this warning:**
   - Go to **System Settings > Privacy & Security** and click **"Open Anyway"**

   **Alternatively:**
   - Right-click StenoAI in Applications and select **"Open"**
   - Or run in Terminal: `xattr -cr /Applications/StenoAI.app`
5. **The app will work normally on subsequent launches**

You can run it locally as well (see below) if you don't want to install a DMG.

## Local Development/Use Locally

### Prerequisites
- Python 3.9+
- Node.js 18+

### Setup
```bash
git clone https://github.com/ruzin/stenoai.git
cd stenoai

# Backend setup
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

# Download bundled binaries (Ollama, ffmpeg)
./scripts/download-ollama.sh

# Build the Python backend
pip install pyinstaller
pyinstaller stenoai.spec --noconfirm

# Frontend
cd app
npm install
npm start
```

Note: Ollama and ffmpeg are bundled - no system installation needed. The setup wizard in the app will download the required AI models automatically.

### Build
```bash
cd app
npm run build
```

## Project Structure

```
stenoai/
├── app/                  # Electron desktop app
├── src/                  # Python backend
├── website/              # Marketing site
├── recordings/           # Audio files
├── transcripts/          # Text output
└── output/              # Summaries
```

## Troubleshooting

### Debug Logs

**Setup wizard debug console:** during first-time setup, expand the debug console panel to see real-time logs of model downloads and service startup.

**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):
```bash
/Applications/StenoAI.app/Contents/MacOS/StenoAI
```

**System Console:**
```bash
# View recent StenoAI-related logs
log show --last 10m --predicate 'process CONTAINS "StenoAI" OR eventMessage CONTAINS "ollama"' --info

# Monitor live logs
log stream --predicate 'eventMessage CONTAINS "ollama" OR process CONTAINS "StenoAI"' --level info
```

### Common Issues

- **Update didn't install**: Auto-updates are applied on next quit. Quit via the **StenoAI → Quit** menu (not just closing the window), then reopen.
- **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.
- **`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.
- **Recording stops early**: Check microphone permissions, Screen Recording permission (if using system audio), and available disk space.
- **"Processing failed"**: Usually an Ollama service or model issue — check the terminal logs.
- **Empty transcripts**: Whisper couldn't detect speech — verify audio input levels.
- **Slow processing**: Normal for longer recordings; Ollama is CPU-intensive, especially on older Intel Macs.

### Logs Location
- **User Data**: `~/Library/Application Support/stenoai/`
- **Recordings**: `~/Library/Application Support/stenoai/recordings/`
- **Transcripts**: `~/Library/Application Support/stenoai/transcripts/`
- **Summaries**: `~/Library/Application Support/stenoai/output/`

## License

This project is licensed under the [MIT License](LICENSE).


================================================
FILE: announcements.json
================================================
{
  "announcements": []
}


================================================
FILE: app/build/entitlements.mac.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<!-- Audio recording permission -->
	<key>com.apple.security.device.audio-input</key>
	<true/>

	<!-- Network access for API calls (Ollama, etc.) -->
	<key>com.apple.security.network.client</key>
	<true/>

	<!-- File access for recordings/transcripts -->
	<key>com.apple.security.files.user-selected.read-write</key>
	<true/>

	<!-- Application Support folder access -->
	<key>com.apple.security.files.bookmarks.app-scope</key>
	<true/>

	<!-- Allow JIT compilation for Python runtime -->
	<key>com.apple.security.cs.allow-jit</key>
	<true/>

	<!-- Allow unsigned executable memory (for Python/Whisper/Ollama) -->
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>

	<!-- Disable library validation (for Python dependencies) -->
	<key>com.apple.security.cs.disable-library-validation</key>
	<true/>

	<!-- Allow DYLD environment variables (for bundled Ollama dylibs) -->
	<key>com.apple.security.cs.allow-dyld-environment-variables</key>
	<true/>
</dict>
</plist>


================================================
FILE: app/electron-builder.ci.yml
================================================
# electron-builder config for CI dry-runs (pack:unsigned).
#
# Inherits the base "build" config from package.json, but strips:
#   - extraResources (../dist/stenoai is not built in CI — no PyInstaller)
#   - afterSign hook (no notarization in unsigned packs)
#   - DMG signing (no cert in CI)
#
# The resulting .app is NOT runnable: it has no Python backend, is unsigned,
# and is not notarized. This config exists solely to prove the Vite output +
# preload.js + main.js package correctly on every PR, before release time.
# Release builds continue to use the package.json config unchanged.

extraResources:
  - from: assets
    to: assets
    filter:
      - trayIcon*.png

dmg:
  sign: false

mac:
  identity: null
  notarize: false


================================================
FILE: app/main.js
================================================
const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, safeStorage, Tray, Menu, nativeImage, Notification } = require('electron');

// Prevent EPIPE crashes when stdout/stderr pipe is broken (e.g. launching terminal closed)
process.stdout?.on('error', () => {});
process.stderr?.on('error', () => {});
const path = require('path');
const { spawn, exec } = require('child_process');
const fs = require('fs');
const https = require('https');
const http = require('http');
const os = require('os');
const { URL, URLSearchParams } = require('url');
const crypto = require('crypto');
const { PostHog } = require('posthog-node');
const { initMain } = require('electron-audio-loopback');
const { autoUpdater } = require('electron-updater');

// E2E test-harness hooks. Set via env vars; production sees none of these.
//   STENOAI_USER_DATA_DIR — per-test temp userData dir (must be set before app.whenReady)
//   STENOAI_E2E=1         — skip tray, auto-updater, PostHog telemetry
//   STENOAI_E2E_MOCK_IPC=1 — install deterministic mock IPC handlers
if (process.env.STENOAI_USER_DATA_DIR) {
  app.setPath('userData', process.env.STENOAI_USER_DATA_DIR);
}
const IS_E2E = process.env.STENOAI_E2E === '1';
const IS_E2E_MOCK_IPC = process.env.STENOAI_E2E_MOCK_IPC === '1';
if (IS_E2E_MOCK_IPC) {
  require('./e2e-mock-ipc').install({ ipcMain, BrowserWindow });
}

// Initialize electron-audio-loopback before app is ready
initMain();

let mainWindow;
let pythonProcess;
let tray = null;
let isQuitting = false;
// true once the window has been shown for the first time (React mounted).
// Prevents activate/focus handlers from showing the window before it's ready.
let windowReadyToShow = false;
let shortcutQueue = [];
let pendingShortcutUrls = [];
let rendererShortcutReady = false;
let launchedByShortcut = false;

const SHORTCUT_PROTOCOL = 'stenoai';
const SHORTCUT_HOST = 'record';
const SHORTCUT_SESSION_NAME_MAX_LENGTH = 120;
const gotSingleInstanceLock = app.requestSingleInstanceLock();

function extractShortcutUrlFromArgv(argv = []) {
  return argv.find(arg => typeof arg === 'string' && arg.startsWith(`${SHORTCUT_PROTOCOL}://`));
}

function sanitizeShortcutUrlForLogs(incomingUrl) {
  try {
    const parsed = new URL(incomingUrl);
    return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`;
  } catch (error) {
    return '[invalid-shortcut-url]';
  }
}

function sanitizeShortcutSessionName(rawValue) {
  if (typeof rawValue !== 'string') {
    return null;
  }

  // Keep user-visible names readable while stripping unsupported characters.
  // Preserve Unicode letters (including diacritics) and common punctuation.
  const sanitized = rawValue
    .replace(/[^\p{L}\p{M}\p{N}_\s.,()@&'!+#-]/gu, ' ')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, SHORTCUT_SESSION_NAME_MAX_LENGTH);

  return sanitized || null;
}

function registerShortcutProtocolClient() {
  if (process.platform !== 'darwin') {
    return false;
  }

  // In development (electron .), macOS protocol registration needs executable + app args.
  if (!app.isPackaged) {
    return app.setAsDefaultProtocolClient(
      SHORTCUT_PROTOCOL,
      process.execPath,
      [path.resolve(process.argv[1])]
    );
  }

  return app.setAsDefaultProtocolClient(SHORTCUT_PROTOCOL);
}

// Backend executable path - always use bundled stenoai
function getBackendPath() {
  if (app.isPackaged) {
    // Production: bundled in app resources
    return path.join(process.resourcesPath, 'stenoai', 'stenoai');
  } else {
    // Development: use local build
    return path.join(__dirname, '..', 'dist', 'stenoai', 'stenoai');
  }
}

function getBackendCwd() {
  if (app.isPackaged) {
    return path.join(process.resourcesPath, 'stenoai');
  } else {
    return path.join(__dirname, '..', 'dist', 'stenoai');
  }
}

function parseShortcutUrl(incomingUrl) {
  try {
    const parsed = new URL(incomingUrl);
    if (parsed.protocol !== `${SHORTCUT_PROTOCOL}:`) {
      return { type: 'invalid', reason: 'invalid-protocol' };
    }

    if (parsed.hostname !== SHORTCUT_HOST) {
      return { type: 'invalid', reason: 'invalid-host' };
    }

    const cleanPath = (parsed.pathname || '').replace(/\/+$/, '');
    if (cleanPath === '/start') {
      const sessionName = sanitizeShortcutSessionName(parsed.searchParams.get('name') || '');
      return {
        type: 'start',
        sessionName
      };
    }

    if (cleanPath === '/stop') {
      return { type: 'stop' };
    }

    return { type: 'invalid', reason: 'invalid-path' };
  } catch (error) {
    return { type: 'invalid', reason: 'parse-error' };
  }
}

function ensureMainWindow() {
  if (!app.isReady()) {
    sendDebugLog('Shortcut action received before app ready; deferring window creation');
    return false;
  }

  if (!mainWindow || mainWindow.isDestroyed()) {
    createWindow();
  }

  return true;
}

function dispatchShortcutAction(action) {
  if (!mainWindow || mainWindow.isDestroyed()) {
    return false;
  }

  if (action.type === 'start') {
    mainWindow.webContents.send('shortcut-start-recording', {
      sessionName: action.sessionName || null
    });
    launchedByShortcut = false;
    return true;
  }

  if (action.type === 'stop') {
    mainWindow.webContents.send('shortcut-stop-recording');
    launchedByShortcut = false;
    return true;
  }

  return false;
}

function flushShortcutQueue() {
  if (!rendererShortcutReady || !mainWindow || mainWindow.isDestroyed()) {
    return;
  }

  while (shortcutQueue.length > 0) {
    const nextAction = shortcutQueue.shift();
    const dispatched = dispatchShortcutAction(nextAction);
    if (!dispatched) {
      shortcutQueue.unshift(nextAction);
      break;
    }
  }
}

function enqueueShortcutAction(action) {
  if (shortcutQueue.length >= 5) {
    sendDebugLog('Shortcut queue overflow, dropping oldest action');
    shortcutQueue.shift();
  }
  shortcutQueue.push(action);
  flushShortcutQueue();
}

async function shouldShowShortcutNotifications() {
  try {
    const settings = await handleGetNotifications();
    if (!settings.success) {
      return true;
    }
    return settings.notifications_enabled !== false;
  } catch (error) {
    return true;
  }
}

async function showShortcutNotification(body) {
  if (process.platform !== 'darwin') {
    return;
  }

  try {
    const enabled = await shouldShowShortcutNotifications();
    if (!enabled || !Notification.isSupported()) {
      return;
    }

    new Notification({
      title: 'StenoAI Shortcuts',
      body
    }).show();
  } catch (error) {
    console.error('Failed to show shortcut notification:', error.message);
  }
}

const BACKEND_STATUS_RETRY_ATTEMPTS = 3;
const BACKEND_STATUS_RETRY_DELAY_MS = 250;

function wait(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function isBackendRecording() {
  for (let attempt = 1; attempt <= BACKEND_STATUS_RETRY_ATTEMPTS; attempt += 1) {
    try {
      const status = await handleGetStatus();
      if (status.success) {
        return status.status.includes('STATUS: RECORDING');
      }
    } catch (error) {
      if (attempt === BACKEND_STATUS_RETRY_ATTEMPTS) {
        console.error('Error checking recording status for shortcut action:', error.message);
      }
    }

    if (attempt < BACKEND_STATUS_RETRY_ATTEMPTS) {
      await wait(BACKEND_STATUS_RETRY_DELAY_MS);
    }
  }

  console.warn('Backend status unavailable after retries; assuming not recording for shortcut action');
  return false;
}

async function handleShortcutUrl(incomingUrl) {
  const parsedAction = parseShortcutUrl(incomingUrl);
  const safeShortcutUrl = sanitizeShortcutUrlForLogs(incomingUrl);

  if (parsedAction.type === 'invalid') {
    sendDebugLog(`Ignored invalid shortcut URL (${parsedAction.reason}): ${safeShortcutUrl}`);
    await showShortcutNotification('Invalid shortcut URL');
    launchedByShortcut = false;
    return;
  }

  const backendRecording = await isBackendRecording();
  const recording = backendRecording || systemAudioRecordingActive;

  if (parsedAction.type === 'start') {
    if (recording) {
      await showShortcutNotification('Recording already in progress');
      launchedByShortcut = false;
      return;
    }

    if (!ensureMainWindow()) {
      launchedByShortcut = true;
      pendingShortcutUrls.push(incomingUrl);
      return;
    }
    enqueueShortcutAction(parsedAction);
    await showShortcutNotification('Start recording requested');
    return;
  }

  if (!recording) {
    await showShortcutNotification('Recording already stopped');
    launchedByShortcut = false;
    return;
  }

  if (!ensureMainWindow()) {
    launchedByShortcut = true;
    pendingShortcutUrls.push(incomingUrl);
    return;
  }
  enqueueShortcutAction(parsedAction);
  await showShortcutNotification('Stop recording requested');
}

// Telemetry state
let posthogClient = null;
let telemetryEnabled = false;
let anonymousId = null;

const POSTHOG_API_KEY = 'phc_U2cnTyIyKGNSVaK18FyBMltd8nmN7uHxhhm21fAHwqb';
const POSTHOG_HOST = 'https://us.i.posthog.com';

// Google Calendar OAuth2 configuration
const GOOGLE_CLIENT_ID = '281073275073-20da4u5t9luk2366vd5ai0a2r55d5pf5.apps.googleusercontent.com';
const GOOGLE_CLIENT_SECRET = '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';
const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar.readonly';
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';

// Outlook Calendar OAuth2 configuration (PKCE public client — no client secret)
const OUTLOOK_CLIENT_ID = '53a8ba1f-3a2e-4fc9-afb1-b9b8ff13de19';
const OUTLOOK_SCOPES = 'Calendars.Read offline_access';
const OUTLOOK_AUTH_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize';
const OUTLOOK_TOKEN_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';

/**
 * Return a privacy-safe duration bucket string.
 */
function durationBucket(seconds) {
  if (seconds < 60) return '<1m';
  if (seconds < 300) return '1-5m';
  if (seconds < 900) return '5-15m';
  if (seconds < 1800) return '15-30m';
  if (seconds < 3600) return '30-60m';
  return '60m+';
}

/**
 * Initialize PostHog telemetry by reading config from Python backend.
 */
async function initTelemetry() {
  if (IS_E2E) {
    telemetryEnabled = false;
    return;
  }
  try {
    const result = await new Promise((resolve, reject) => {
      const proc = spawn(getBackendPath(), ['get-telemetry'], {
        cwd: getBackendCwd()
      });
      let stdout = '';
      proc.stdout.on('data', (data) => { stdout += data.toString(); });
      proc.on('close', (code) => {
        if (code === 0) resolve(stdout);
        else reject(new Error(`get-telemetry exited with code ${code}`));
      });
      proc.on('error', reject);
    });

    const config = JSON.parse(result.trim());
    telemetryEnabled = config.telemetry_enabled;
    anonymousId = config.anonymous_id;

    if (telemetryEnabled) {
      posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST });
      // Identify user for DAU tracking
      posthogClient.identify({
        distinctId: anonymousId,
        properties: {
          platform: process.platform,
          arch: process.arch
        }
      });
      console.log('Telemetry initialized (anonymous analytics enabled)');
    } else {
      console.log('Telemetry disabled by user preference');
    }
  } catch (error) {
    console.error('Failed to initialize telemetry:', error.message);
    telemetryEnabled = false;
  }
}

/**
 * Track an analytics event. Silent fail -- never throws.
 */
function trackEvent(eventName, properties = {}) {
  try {
    if (!telemetryEnabled || !posthogClient || !anonymousId) return;

    const packagePath = path.join(__dirname, 'package.json');
    const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));

    posthogClient.capture({
      distinctId: anonymousId,
      event: eventName,
      properties: {
        app_version: packageContent.version,
        platform: process.platform,
        arch: process.arch,
        ...properties
      }
    });
  } catch (error) {
    // Silent fail -- telemetry must never break the app
  }
}

/**
 * Flush and shut down the PostHog client.
 */
async function shutdownTelemetry() {
  try {
    if (posthogClient) {
      await posthogClient.shutdown();
      posthogClient = null;
      console.log('Telemetry shut down');
    }
  } catch (error) {
    // Silent fail
  }
}

/**
 * Get the list of allowed base directories, including any custom storage path.
 */
let _cachedCustomStoragePath = null;
function getAllowedBaseDirs() {
  const projectRoot = path.join(__dirname, '..');
  const dirs = [
    projectRoot,
    path.join(os.homedir(), 'Library', 'Application Support', 'stenoai')
  ];
  if (_cachedCustomStoragePath) {
    dirs.push(_cachedCustomStoragePath);
  }
  return dirs;
}

/**
 * Validate that a file path is within allowed directories (security)
 * Prevents path traversal attacks by ensuring files are only accessed
 * within the app's designated data directories
 */
function validateSafeFilePath(filepath, allowedBaseDirs) {
  if (!filepath) return false;

  try {
    // Resolve to absolute path and normalize
    const resolvedPath = path.resolve(filepath);

    // Ensure it's within one of the allowed base directories
    for (const baseDir of allowedBaseDirs) {
      const resolvedBase = path.resolve(baseDir);
      if (resolvedPath.startsWith(resolvedBase + path.sep) || resolvedPath === resolvedBase) {
        return true;
      }
    }

    return false;
  } catch (error) {
    console.error('Error validating file path:', error);
    return false;
  }
}

function createWindow(options = {}) {
  rendererShortcutReady = false;

  const windowOpts = {
    width: 1200,
    height: 800,
    minWidth: 1000,
    minHeight: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: false,
      preload: path.join(__dirname, 'preload.js'),
      scrollBounce: true,
    },
    titleBarStyle: 'hiddenInset',
    show: false,
    backgroundColor: '#FAF9F5',
    // React UI renders the macOS traffic lights inside the sidebar's top
    // band rather than floating above a fixed titlebar.
    trafficLightPosition: { x: 18, y: 18 },
  };
  if (options.bounds && typeof options.bounds.x === 'number') {
    Object.assign(windowOpts, options.bounds);
  }

  mainWindow = new BrowserWindow(windowOpts);

  const rendererDist = path.join(__dirname, 'renderer', 'dist', 'index.html');
  const hash = process.env.STENOAI_RENDERER_HASH;
  if (hash) {
    mainWindow.loadFile(rendererDist, { hash });
  } else {
    mainWindow.loadFile(rendererDist);
  }

  windowReadyToShow = false;

  const showWhenReady = () => {
    windowReadyToShow = true;
    if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
      mainWindow.show();
    }
  };

  mainWindow.once('ready-to-show', () => {
    if (launchedByShortcut) {
      return;
    }
    // Wait until React signals it has mounted. Fall back to showing after
    // 4s in case the signal never arrives.
    const fallback = setTimeout(showWhenReady, 4000);
    ipcMain.once('renderer-ready-to-show', () => {
      clearTimeout(fallback);
      showWhenReady();
    });
  });



  // On macOS, hide to tray instead of destroying (like Slack, Spotify)
  mainWindow.on('close', (event) => {
    if (process.platform === 'darwin' && !isQuitting) {
      event.preventDefault();
      mainWindow.hide();
    }
  });

  mainWindow.on('closed', () => {
    mainWindow = null;
    rendererShortcutReady = false;
    if (pythonProcess) {
      pythonProcess.kill();
    }
  });
}

function getTrayIconPath(recording) {
  const iconName = recording ? 'trayIconRecordingTemplate' : 'trayIconTemplate';
  if (app.isPackaged) {
    return path.join(process.resourcesPath, 'assets', `${iconName}.png`);
  }
  return path.join(__dirname, 'assets', `${iconName}.png`);
}

function createTray() {
  const icon = nativeImage.createFromPath(getTrayIconPath(false));
  icon.setTemplateImage(true);
  tray = new Tray(icon);
  tray.setToolTip('StenoAI');

  updateTrayMenu();
}

function updateTrayIcon(recording) {
  if (!tray) return;
  const icon = nativeImage.createFromPath(getTrayIconPath(recording));
  icon.setTemplateImage(true);
  tray.setImage(icon);
  tray.setToolTip(recording ? 'StenoAI - Recording' : 'StenoAI');
  updateTrayMenu();
}

function showAndFocusWindow() {
  if (mainWindow) {
    mainWindow.show();
    mainWindow.focus();
  }
}

function updateTrayMenu() {
  if (!tray) return;
  const isRecording = currentRecordingProcess !== null || systemAudioRecordingActive;

  const appVersion = require('./package.json').version;

  const contextMenu = Menu.buildFromTemplate([
    {
      label: 'Open StenoAI',
      click: showAndFocusWindow
    },
    {
      label: isRecording ? 'Stop Recording' : 'Start Recording',
      click: () => {
        if (mainWindow) {
          mainWindow.webContents.send(isRecording ? 'tray-stop-recording' : 'tray-start-recording');
        }
      }
    },
    {
      label: 'Settings',
      click: () => {
        showAndFocusWindow();
        if (mainWindow) {
          mainWindow.webContents.send('tray-open-settings');
        }
      }
    },
    {
      label: 'Hide StenoAI',
      click: () => {
        if (mainWindow) mainWindow.hide();
      }
    },
    { type: 'separator' },
    {
      label: `StenoAI v${appVersion}`,
      enabled: false
    },
    {
      label: 'Report a Bug',
      click: () => {
        shell.openExternal('https://discord.gg/DZ6vcQnxxu');
      }
    },
    { type: 'separator' },
    {
      label: 'Quit StenoAI',
      click: () => {
        app.quit();
      }
    }
  ]);

  tray.setContextMenu(contextMenu);
}

if (!gotSingleInstanceLock) {
  app.quit();
} else {
  app.on('second-instance', (event, argv) => {
    const shortcutUrl = extractShortcutUrlFromArgv(argv);
    if (shortcutUrl) {
      if (app.isReady()) {
        handleShortcutUrl(shortcutUrl).catch(err => {
          sendDebugLog(`Error handling shortcut URL: ${err.message}`);
        });
      } else {
        launchedByShortcut = true;
        pendingShortcutUrls.push(shortcutUrl);
      }
    }

    if (mainWindow && !mainWindow.isDestroyed()) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.show();
      mainWindow.focus();
    }
  });

  // Sends the custom in-app quit dialog to the renderer and waits for a response.
  // Falls back to true (allow quit) if the window is unavailable. A 5s timeout
  // guards against a wedged React tree — on timeout we resolve false to
  // preserve any active recording rather than killing it silently.
  async function showCustomQuitDialog(type, jobCount) {
    if (!mainWindow || mainWindow.isDestroyed()) return true;
    mainWindow.show();
    mainWindow.focus();
    mainWindow.webContents.send('show-quit-dialog', { type, jobCount });
    return new Promise((resolve) => {
      const handler = (_event, data) => {
        clearTimeout(timer);
        resolve(data && data.confirmed === true);
      };
      const timer = setTimeout(() => {
        ipcMain.removeListener('quit-dialog-response', handler);
        resolve(false);
      }, 5000);
      ipcMain.once('quit-dialog-response', handler);
    });
  }

  app.on('before-quit', async (event) => {
    if (isQuitting) return;

    // Use synchronous flag -- systemAudioRecordingActive is updated via IPC on each state change
    if (currentRecordingProcess || systemAudioRecordingActive) {
      event.preventDefault();
      const confirmed = await showCustomQuitDialog('recording');
      if (confirmed) {
        if (currentRecordingProcess) {
          currentRecordingProcess.kill('SIGTERM');
          currentRecordingProcess = null;
          currentRecordingSessionName = null;
        }
        if (systemAudioRecordingActive && mainWindow && !mainWindow.isDestroyed()) {
          try {
            await mainWindow.webContents.executeJavaScript('stopSystemAudioRecording("quit")');
          } catch (e) {
            // Best effort -- file is saved even if processing doesn't start
          }
        }
        systemAudioRecordingActive = false;
        updateTrayIcon(false);
        isQuitting = true;
        app.quit();
      }
    } else if (isProcessing || processingQueue.length > 0) {
      event.preventDefault();
      const jobCount = processingQueue.length + (isProcessing ? 1 : 0);
      const confirmed = await showCustomQuitDialog('processing', jobCount);
      if (confirmed) {
        isQuitting = true;
        app.quit();
      }
    } else {
      isQuitting = true;
    }
  });

  app.on('open-url', (event, incomingUrl) => {
    if (process.platform !== 'darwin') {
      return;
    }

    event.preventDefault();
    sendDebugLog(`Received shortcut URL via open-url: ${sanitizeShortcutUrlForLogs(incomingUrl)}`);

    if (!app.isReady()) {
      launchedByShortcut = true;
      pendingShortcutUrls.push(incomingUrl);
      return;
    }

    handleShortcutUrl(incomingUrl).catch(err => {
      sendDebugLog(`Error handling shortcut URL: ${err.message}`);
    });
  });

  app.whenReady().then(async () => {
    // Set application menu with Help > Learn More
    const appMenu = Menu.buildFromTemplate([
      { role: 'appMenu' },
      { role: 'fileMenu' },
      { role: 'editMenu' },
      { role: 'viewMenu' },
      { role: 'windowMenu' },
      {
        role: 'help',
        submenu: [
          {
            label: 'Learn More',
            click: () => {
              shell.openExternal('https://github.com/ruzin/stenoai');
            }
          },
          {
            label: 'Report a Bug',
            click: () => {
              shell.openExternal('https://discord.gg/DZ6vcQnxxu');
            }
          }
        ]
      }
    ]);
    Menu.setApplicationMenu(appMenu);

    createWindow();
    if (!IS_E2E) createTray();
    setupAutoUpdater();
    const protocolRegistered = registerShortcutProtocolClient();
    sendDebugLog(`Protocol handler registration (${SHORTCUT_PROTOCOL}): ${protocolRegistered}`);

    // Load hide-dock-icon preference and apply
    if (process.platform === 'darwin' && app.dock) {
      try {
        const dockResult = await new Promise((resolve, reject) => {
          const proc = spawn(getBackendPath(), ['get-dock-icon'], {
            cwd: getBackendCwd()
          });
          let stdout = '';
          proc.stdout.on('data', (data) => { stdout += data.toString(); });
          proc.on('close', (code) => {
            if (code === 0) resolve(stdout);
            else reject(new Error(`get-dock-icon exited with code ${code}`));
          });
          proc.on('error', reject);
        });

        const dockConfig = JSON.parse(dockResult.trim());
        if (dockConfig.hide_dock_icon) {
          app.dock.hide();
          console.log('Dock icon hidden (menu bar only mode)');
        }
      } catch (e) {
        console.error('Failed to load dock icon preference:', e.message);
      }
    }

    // Initialize telemetry and track app open
    await initTelemetry();
    trackEvent('app_opened');

    // Load custom storage path for file validation
    try {
      const spResult = await runPythonScript('simple_recorder.py', ['get-storage-path'], true);
      const spData = JSON.parse(spResult.trim());
      if (spData.storage_path) {
        _cachedCustomStoragePath = spData.storage_path;
        console.log('Custom storage path loaded:', _cachedCustomStoragePath);
      }
    } catch (e) {
      // Non-fatal - custom path just won't be cached
    }

    // Register global hotkey for toggle recording (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows/Linux)
    const hotkeyModifier = process.platform === 'darwin' ? 'Command+Shift+R' : 'Ctrl+Shift+R';
    const registered = globalShortcut.register(hotkeyModifier, () => {
      console.log('Global hotkey triggered: toggle recording');
      if (mainWindow) {
        mainWindow.webContents.send('toggle-recording-hotkey');
      }
    });

    if (registered) {
      console.log(`Global hotkey registered: ${hotkeyModifier}`);
    } else {
      console.error(`Failed to register global hotkey: ${hotkeyModifier}`);
    }

    if (pendingShortcutUrls.length > 0) {
      const urlsToProcess = [...pendingShortcutUrls];
      pendingShortcutUrls = [];

      for (const shortcutUrl of urlsToProcess) {
        await handleShortcutUrl(shortcutUrl);
      }
    }
  });

  // Fallback for launch contexts where deep-link may arrive via argv instead of open-url.
  if (process.platform === 'darwin') {
    const argvShortcutUrl = extractShortcutUrlFromArgv(process.argv);
    if (argvShortcutUrl) {
      pendingShortcutUrls.push(argvShortcutUrl);
      launchedByShortcut = true;
    }
  }

  app.on('will-quit', async () => {
    globalShortcut.unregisterAll();
    if (tray) {
      tray.destroy();
      tray = null;
    }
    // Kill Ollama on quit. The process may have been started by Electron or
    // the Python backend — both write the PID to ollama.pid in _internal/.
    const pidFile = path.join(getBackendCwd(), '_internal', 'ollama.pid');
    try {
      const pid = parseInt(require('fs').readFileSync(pidFile, 'utf8').trim(), 10);
      if (pid) {
        process.kill(pid, 'SIGTERM');
        // Give it a moment to shut down, then force-kill if still alive
        setTimeout(() => {
          try { process.kill(pid, 'SIGKILL'); } catch (_) {}
        }, 1000);
      }
      require('fs').unlinkSync(pidFile);
    } catch (_) {}
    // Also kill if Electron spawned it directly
    if (ollamaPid) {
      try { process.kill(ollamaPid, 'SIGTERM'); } catch (_) {}
      ollamaPid = null;
    }
    await shutdownTelemetry();
  });

  app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
      app.quit();
    }
  });

  app.on('activate', () => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      // Only show if the window has finished its initial load.
      // On first launch, windowReadyToShow is false until React mounts.
      if (windowReadyToShow) {
        mainWindow.show();
        mainWindow.focus();
      }
      launchedByShortcut = false;
    } else {
      launchedByShortcut = false;
      createWindow();
    }
  });
}

// Focus window handler (used by notification click to bring app to foreground)
ipcMain.on('focus-window', () => {
    if (mainWindow) {
        if (mainWindow.isMinimized()) mainWindow.restore();
        mainWindow.show();
        mainWindow.focus();
    }
});

ipcMain.on('shortcut-renderer-ready', () => {
  rendererShortcutReady = true;
  flushShortcutQueue();
});

// Microphone permission handlers
ipcMain.handle('check-microphone-permission', async () => {
  try {
    const status = systemPreferences.getMediaAccessStatus('microphone');
    console.log('Microphone permission status:', status);
    return { success: true, status };
  } catch (error) {
    console.error('Error checking microphone permission:', error);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('request-microphone-permission', async () => {
  try {
    console.log('Requesting microphone permission...');
    const granted = await systemPreferences.askForMediaAccess('microphone');
    console.log('Microphone permission granted:', granted);
    return { success: true, granted };
  } catch (error) {
    console.error('Error requesting microphone permission:', error);
    return { success: false, error: error.message };
  }
});

// Debug functionality handled by side panel now

// Backend communication - always uses bundled stenoai executable
function runPythonScript(script, args = [], silent = false, extraEnv = {}) {
  return new Promise((resolve, reject) => {
    const backendPath = getBackendPath();

    // Log the command being executed (unless silent)
    console.log('Running:', `${backendPath} ${args.join(' ')}`);
    if (!silent) {
      sendDebugLog(`$ stenoai ${args.join(' ')}`);
    }

    const process = spawn(backendPath, args, {
      cwd: getBackendCwd(),
      env: Object.keys(extraEnv).length > 0 ? { ...require('process').env, ...extraEnv } : undefined
    });

    let stdout = '';
    let stderr = '';

    process.stdout.on('data', (data) => {
      const output = data.toString();
      stdout += output;
      console.log('Python stdout:', output);
      // Stream stdout to debug panel in real-time (unless silent)
      if (!silent) {
        output.split('\n').forEach(line => {
          if (line.trim()) sendDebugLog(line.trim());
        });
      }
    });

    process.stderr.on('data', (data) => {
      const output = data.toString();
      stderr += output;
      console.log('Python stderr:', output);
      // Stream stderr to debug panel in real-time (unless silent)
      if (!silent) {
        output.split('\n').forEach(line => {
          if (line.trim()) sendDebugLog('STDERR: ' + line.trim());
        });
      }
    });

    process.on('close', (code) => {
      if (!silent) {
        sendDebugLog(`Command completed with exit code: ${code}`);
      }
      if (code === 0) {
        resolve(stdout);
      } else {
        reject(new Error(`Python script failed with code ${code}: ${stderr}`));
      }
    });
    
    process.on('error', (error) => {
      sendDebugLog(`Command error: ${error.message}`);
      reject(error);
    });
  });
}

async function getBackendStatusInternal(silent = true) {
  const result = await runPythonScript('simple_recorder.py', ['status'], silent);
  return { success: true, status: result };
}

async function handleGetStatus() {
  try {
    return await getBackendStatusInternal(true); // Silent mode
  } catch (error) {
    return { success: false, error: error.message };
  }
}

async function handleGetNotifications() {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-notifications']);
    const jsonData = JSON.parse(result);

    return {
      success: true,
      ...jsonData
    };
  } catch (error) {
    sendDebugLog(`Error getting notification settings: ${error.message}`);
    return { success: false, error: error.message };
  }
}

// IPC Handlers - Separate start/stop with better error handling
ipcMain.handle('start-recording', async (event, sessionName) => {
  try {
    sendDebugLog(`Starting recording session: ${sessionName || 'Meeting'}`);
    sendDebugLog('$ python simple_recorder.py start');

    // Start recording (removed clear-state to prevent race conditions)
    const result = await runPythonScript('simple_recorder.py', ['start', sessionName || 'Meeting']);

    if (result.includes('SUCCESS')) {
      sendDebugLog('Recording started successfully');
      trackEvent('recording_started');
      return { success: true, message: result };
    } else {
      sendDebugLog(`Recording failed: ${result}`);
      return { success: false, error: result };
    }
  } catch (error) {
    console.error('Start recording error:', error.message);
    sendDebugLog(`Recording error: ${error.message}`);
    trackEvent('error_occurred', { error_type: 'start_recording' });
    return { success: false, error: error.message };
  }
});

ipcMain.handle('stop-recording', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['stop']);

    if (result.includes('SUCCESS') || result.includes('Recording saved')) {
      trackEvent('recording_stopped');
      return { success: true, message: result };
    } else {
      return { success: false, error: result };
    }
  } catch (error) {
    console.error('Stop recording error:', error.message);
    trackEvent('error_occurred', { error_type: 'stop_recording' });
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-status', handleGetStatus);

ipcMain.handle('process-recording', async (event, audioFile, sessionName) => {
  try {
    const cloudKey = loadCloudApiKey();
    const env = cloudKey ? { STENOAI_CLOUD_API_KEY: cloudKey } : {};
    const result = await runPythonScript('simple_recorder.py', ['process', audioFile, '--name', sessionName], false, env);
    trackEvent('transcription_completed', { success: true });
    trackEvent('summarization_completed', { success: true });
    return { success: true, result: result };
  } catch (error) {
    trackEvent('error_occurred', { error_type: 'process_recording' });
    return { success: false, error: error.message };
  }
});

ipcMain.handle('test-system', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['test']);
    return { success: true, result: result };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('select-audio-file', async () => {
  const result = await dialog.showOpenDialog(mainWindow, {
    properties: ['openFile'],
    filters: [
      { name: 'Audio Files', extensions: ['wav', 'mp3', 'm4a', 'aac', 'webm'] }
    ]
  });
  
  if (!result.canceled && result.filePaths.length > 0) {
    return { success: true, filePath: result.filePaths[0] };
  }
  
  return { success: false, error: 'No file selected' };
});

ipcMain.handle('list-meetings', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['list-meetings'], true); // Silent mode
    return { success: true, meetings: JSON.parse(result) };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('clear-state', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['clear-state']);
    return { success: true, message: result };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('reprocess-meeting', async (event, summaryFile, regenerateTitle, sessionName) => {
  try {
    const args = ['reprocess', summaryFile];
    if (regenerateTitle) args.push('--regenerate-title');

    sendDebugLog(`🔄 Reprocessing meeting: ${summaryFile}`);
    sendDebugLog(`$ stenoai ${args.join(' ')}`);

    const cloudKey = loadCloudApiKey();
    const reprocessEnv = cloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: cloudKey } : undefined;

    await new Promise((resolve, reject) => {
      const proc = spawn(getBackendPath(), args, {
        cwd: getBackendCwd(),
        env: reprocessEnv
      });

      let stderrBuf = '';

      const procTimeout = setTimeout(() => {
        console.error('reprocess timed out after 30 minutes, killing');
        proc.kill();
      }, 30 * 60 * 1000);

      proc.on('error', (err) => {
        clearTimeout(procTimeout);
        reject(new Error(`reprocess spawn error: ${err.message}`));
      });

      proc.stdout.on('data', (data) => {
        const text = data.toString();
        text.split('\n').forEach(line => {
          if (line.startsWith('CHUNK:')) {
            try {
              const encoded = line.slice(6);
              const chunk = Buffer.from(encoded, 'base64').toString('utf-8');
              if (mainWindow && !mainWindow.isDestroyed()) {
                mainWindow.webContents.send('summary-chunk', { chunk, sessionName });
              }
            } catch (e) { console.log('CHUNK decode error:', e.message); }
          } else if (line.startsWith('TITLE:')) {
            const title = line.slice(6);
            if (mainWindow && !mainWindow.isDestroyed()) {
              mainWindow.webContents.send('summary-title', { title, sessionName });
            }
          } else if (line === 'STREAM_COMPLETE') {
            if (mainWindow && !mainWindow.isDestroyed()) {
              mainWindow.webContents.send('summary-complete', { success: true, sessionName });
            }
          } else if (line.trim()) {
            sendDebugLog(line.trim());
          }
        });
      });

      proc.stderr.on('data', (data) => {
        const msg = data.toString().trim();
        if (msg) {
          stderrBuf += msg + '\n';
          sendDebugLog(`STDERR: ${msg}`);
        }
      });

      proc.on('close', (code) => {
        clearTimeout(procTimeout);
        if (code === 0) {
          console.log(`✅ Completed reprocessing: ${sessionName}`);
          if (mainWindow && !mainWindow.isDestroyed()) {
            mainWindow.webContents.send('processing-complete', {
              success: true,
              sessionName,
              message: 'Reprocessing completed successfully'
            });
          }
          resolve();
        } else {
          reject(new Error(`reprocess exited with code ${code}: ${stderrBuf.slice(-500)}`));
        }
      });
    });

    sendDebugLog('✅ Meeting reprocessed successfully');
    return { success: true };
  } catch (error) {
    sendDebugLog(`❌ Reprocessing failed: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('regen-meeting-title', async (event, summaryFile, sessionName) => {
  try {
    const cloudKey = loadCloudApiKey();
    const regenEnv = cloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: cloudKey } : undefined;

    await new Promise((resolve, reject) => {
      const proc = spawn(getBackendPath(), ['regen-title', summaryFile], {
        cwd: getBackendCwd(),
        env: regenEnv,
      });

      let stderrBuf = '';
      const procTimeout = setTimeout(() => { proc.kill(); }, 2 * 60 * 1000);

      proc.on('error', (err) => { clearTimeout(procTimeout); reject(new Error(err.message)); });

      proc.stdout.on('data', (data) => {
        data.toString().split('\n').forEach((line) => {
          if (line.startsWith('TITLE:')) {
            const title = line.slice(6);
            if (mainWindow && !mainWindow.isDestroyed()) {
              mainWindow.webContents.send('summary-title', { title, sessionName });
            }
          }
        });
      });

      proc.stderr.on('data', (data) => { stderrBuf += data.toString(); });

      proc.on('close', (code) => {
        clearTimeout(procTimeout);
        if (code === 0) resolve();
        else reject(new Error(`regen-title exited with code ${code}: ${stderrBuf.slice(-300)}`));
      });
    });

    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('query-transcript', async (event, summaryFile, question) => {
  try {
    sendDebugLog(`🤖 Querying transcript: ${question.substring(0, 50)}...`);

    // Run the query command (pass cloud key for cloud provider)
    const cloudKey = loadCloudApiKey();
    const env = cloudKey ? { STENOAI_CLOUD_API_KEY: cloudKey } : {};
    const result = await runPythonScript('simple_recorder.py', ['query', summaryFile, '-q', question], false, env);

    // Parse the JSON response
    try {
      const jsonResponse = JSON.parse(result.trim());
      if (jsonResponse.success) {
        sendDebugLog('✅ Query answered successfully');
        trackEvent('ai_query_used', { success: true });
        return { success: true, answer: jsonResponse.answer };
      } else {
        sendDebugLog(`❌ Query failed: ${jsonResponse.error}`);
        trackEvent('ai_query_used', { success: false });
        return { success: false, error: jsonResponse.error };
      }
    } catch (parseError) {
      // If parsing fails, check if the result contains any JSON
      const jsonMatch = result.match(/\{[\s\S]*\}/);
      if (jsonMatch) {
        const jsonResponse = JSON.parse(jsonMatch[0]);
        if (jsonResponse.success) {
          trackEvent('ai_query_used', { success: true });
          return { success: true, answer: jsonResponse.answer };
        } else {
          trackEvent('ai_query_used', { success: false });
          return { success: false, error: jsonResponse.error };
        }
      }
      sendDebugLog(`❌ Failed to parse query response: ${parseError.message}`);
      trackEvent('ai_query_used', { success: false });
      return { success: false, error: 'Failed to parse AI response' };
    }
  } catch (error) {
    sendDebugLog(`❌ Query failed: ${error.message}`);
    trackEvent('error_occurred', { error_type: 'query_transcript' });
    return { success: false, error: error.message };
  }
});

const activeQueryProcs = new Map();

ipcMain.on('query-cancel', (_event, queryId) => {
  const proc = activeQueryProcs.get(queryId);
  if (proc) {
    console.log(`[QUERY] Cancelling queryId=${queryId}`);
    proc.kill();
    activeQueryProcs.delete(queryId);
  }
});

ipcMain.on('query-transcript-stream', (event, queryId, summaryFile, question) => {
  console.log(`[QUERY] IPC received: question="${question.substring(0, 50)}" file="${summaryFile}"`);
  sendDebugLog(`🤖 Streaming query: ${question.substring(0, 50)}...`);
  const cloudKey = loadCloudApiKey();
  const env = cloudKey ? { ...process.env, STENOAI_CLOUD_API_KEY: cloudKey } : process.env;

  let proc;
  try {
    const backendPath = getBackendPath();
    proc = require('child_process').spawn(backendPath, ['query-streaming', summaryFile, '-q', question], {
      env,
      cwd: getBackendCwd(),
    });
  } catch (err) {
    event.sender.send('query-done', { queryId, success: false, error: err.message });
    return;
  }

  activeQueryProcs.set(queryId, proc);
  // Kill the spawned proc if the renderer sender goes away before the query
  // finishes. Keep a reference so we can remove the listener on normal close
  // (otherwise repeated queries on a long-lived sender leak one-time listeners).
  const onSenderDestroyed = () => {
    if (activeQueryProcs.has(queryId)) {
      proc.kill();
      activeQueryProcs.delete(queryId);
    }
  };
  event.sender.once('destroyed', onSenderDestroyed);
  let buf = '';
  let chunkCount = 0;
  proc.stdout.on('data', (data) => {
    buf += data.toString();
    const lines = buf.split('\n');
    buf = lines.pop();
    for (const line of lines) {
      if (line.startsWith('CHAT_CHUNK:') || line.startsWith('CHUNK:')) {
        const prefixLen = line.startsWith('CHAT_CHUNK:') ? 11 : 6;
        try {
          const chunk = Buffer.from(line.slice(prefixLen), 'base64').toString('utf-8');
          chunkCount++;
          if (chunkCount === 1) console.log(`[QUERY] First chunk received (queryId=${queryId})`);
          if (!event.sender.isDestroyed()) event.sender.send('query-chunk', { queryId, chunk });
          else {
            console.log(`[QUERY] Sender destroyed, killing process queryId=${queryId}`);
            proc.kill();
            activeQueryProcs.delete(queryId);
          }
        } catch (e) { console.log(`[QUERY] Chunk decode error: ${e.message}`); }
      } else if (line === 'CHAT_STREAM_COMPLETE' || line === 'STREAM_COMPLETE') {
        console.log(`[QUERY] STREAM_COMPLETE received, ${chunkCount} chunks sent`);
        if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true });
        else console.log(`[QUERY] Sender destroyed at STREAM_COMPLETE`);
      } else if (line.startsWith('CHAT_STREAM_ERROR:') || line.startsWith('STREAM_ERROR:')) {
        const errMsg = line.startsWith('CHAT_STREAM_ERROR:') ? line.slice(18) : line.slice(13);
        console.log(`[QUERY] STREAM_ERROR: ${errMsg}`);
        if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: false, error: errMsg });
      }
    }
  });

  proc.stderr.on('data', (data) => {
    const msg = data.toString().trim();
    if (msg) console.log(`[QUERY stderr] ${msg.substring(0, 200)}`);
  });

  proc.on('close', (code) => {
    activeQueryProcs.delete(queryId);
    if (!event.sender.isDestroyed()) {
      event.sender.removeListener('destroyed', onSenderDestroyed);
    }
    console.log(`[QUERY] Process closed, code=${code}, chunks=${chunkCount}, bufRemainder=${buf.length > 0 ? JSON.stringify(buf.substring(0, 100)) : 'empty'}`);
    if (buf.trim() === 'CHAT_STREAM_COMPLETE' || buf.trim() === 'STREAM_COMPLETE') {
      console.log(`[QUERY] STREAM_COMPLETE was in buf remainder — sending done now`);
      if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true });
    } else if (code !== 0 && code !== null && !event.sender.isDestroyed()) {
      // code === null means killed (cancelled) — renderer already handles that case
      event.sender.send('query-done', { queryId, success: false, error: `Process exited with code ${code}` });
    }
  });

  proc.on('error', (err) => {
    activeQueryProcs.delete(queryId);
    if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: false, error: err.message });
  });
});

// Cross-note chat (Chat tab). Same wire protocol as query-transcript-stream
// (CHAT_CHUNK / CHAT_STREAM_COMPLETE / CHAT_STREAM_ERROR -> query-chunk /
// query-done) so the renderer can reuse useStreamingQuery. Cloud-only —
// the Python CLI rejects local providers because we don't have retrieval
// yet and a full-corpus prompt blows local context windows.
ipcMain.on('chat-global-stream', (event, queryId, question, folderId) => {
  sendDebugLog(`💬 Global chat query: ${String(question || '').slice(0, 80)}... (folder: ${folderId || 'all'})`);
  const cloudKey = loadCloudApiKey();
  const env = cloudKey ? { ...process.env, STENOAI_CLOUD_API_KEY: cloudKey } : process.env;

  const args = ['chat-global-streaming', '-q', question];
  if (folderId && typeof folderId === 'string' && folderId !== 'all') {
    args.push('-f', folderId);
  }

  let proc;
  try {
    proc = require('child_process').spawn(
      getBackendPath(),
      args,
      { env, cwd: getBackendCwd() },
    );
  } catch (err) {
    event.sender.send('query-done', { queryId, success: false, error: err.message });
    return;
  }

  activeQueryProcs.set(queryId, proc);
  const onSenderDestroyed = () => {
    if (activeQueryProcs.has(queryId)) {
      proc.kill();
      activeQueryProcs.delete(queryId);
    }
  };
  event.sender.once('destroyed', onSenderDestroyed);

  let buf = '';
  let chunkCount = 0;
  proc.stdout.on('data', (data) => {
    buf += data.toString();
    const lines = buf.split('\n');
    buf = lines.pop();
    for (const line of lines) {
      if (line.startsWith('CHAT_CHUNK:')) {
        try {
          const chunk = Buffer.from(line.slice(11), 'base64').toString('utf-8');
          chunkCount++;
          if (!event.sender.isDestroyed()) {
            event.sender.send('query-chunk', { queryId, chunk });
          } else {
            proc.kill();
            activeQueryProcs.delete(queryId);
          }
        } catch (e) { /* ignore decode errors */ }
      } else if (line === 'CHAT_STREAM_COMPLETE') {
        if (!event.sender.isDestroyed()) {
          event.sender.send('query-done', { queryId, success: true });
        }
      } else if (line.startsWith('CHAT_STREAM_ERROR:')) {
        const errMsg = line.slice(18);
        if (!event.sender.isDestroyed()) {
          event.sender.send('query-done', { queryId, success: false, error: errMsg });
        }
      }
    }
  });

  proc.stderr.on('data', (data) => {
    const msg = data.toString().trim();
    if (msg) sendDebugLog(`[chat-global stderr] ${msg.slice(0, 200)}`);
  });

  proc.on('close', (code) => {
    activeQueryProcs.delete(queryId);
    if (!event.sender.isDestroyed()) {
      event.sender.removeListener('destroyed', onSenderDestroyed);
    }
    if (buf.trim() === 'CHAT_STREAM_COMPLETE') {
      if (!event.sender.isDestroyed()) event.sender.send('query-done', { queryId, success: true });
    } else if (code !== 0 && code !== null && !event.sender.isDestroyed()) {
      event.sender.send('query-done', { queryId, success: false, error: `Process exited with code ${code}` });
    }
  });

  proc.on('error', (err) => {
    activeQueryProcs.delete(queryId);
    if (!event.sender.isDestroyed()) {
      event.sender.send('query-done', { queryId, success: false, error: err.message });
    }
  });
});

// Chat sessions persistence.
//
// The legacy renderer reads/writes `chat_sessions.json` as a flat array.
// The new renderer uses an enriched `{ sessions: [...] }` shape. To avoid
// silently breaking the legacy UI when a user toggles between renderers, we
// store the new shape in a separate file (`chat_sessions_v2.json`) and never
// modify the legacy file. On first load, if v2 is absent we read the legacy
// file once for migration; subsequent saves only touch v2.
//
// Writes use tmp+rename to keep the file atomic across crashes / power loss
// (a truncated chat_sessions file is hard to recover and would lose all
// chat history on next launch).
const CHAT_SESSIONS_V2_FILENAME = 'chat_sessions_v2.json';
const CHAT_SESSIONS_LEGACY_FILENAME = 'chat_sessions.json';

function chatSessionsV2Path() {
  return path.join(app.getPath('userData'), CHAT_SESSIONS_V2_FILENAME);
}

function chatSessionsLegacyPath() {
  return path.join(app.getPath('userData'), CHAT_SESSIONS_LEGACY_FILENAME);
}

ipcMain.handle('save-chat-sessions', async (event, data) => {
  const filePath = chatSessionsV2Path();
  const tmpPath = `${filePath}.tmp`;
  try {
    fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf-8');
    fs.renameSync(tmpPath, filePath);
    return { success: true };
  } catch (err) {
    try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch (_) {}
    return { success: false, error: err.message };
  }
});

ipcMain.handle('load-chat-sessions', async () => {
  const v2Path = chatSessionsV2Path();
  // Prefer v2 file when present
  if (fs.existsSync(v2Path)) {
    try {
      const raw = fs.readFileSync(v2Path, 'utf-8');
      return { success: true, data: JSON.parse(raw) };
    } catch (err) {
      // Corrupt v2 file — quarantine it so we don't keep failing on every load,
      // then fall through to legacy migration / empty state.
      const corruptPath = `${v2Path}.corrupt-${Date.now()}`;
      try { fs.renameSync(v2Path, corruptPath); } catch (_) {}
      console.error(`[chat-sessions] v2 file unreadable, quarantined to ${corruptPath}:`, err.message);
    }
  }
  // First run on the new renderer: try to migrate from the legacy file.
  // Legacy file is read but never modified, so legacy renderer remains intact.
  const legacyPath = chatSessionsLegacyPath();
  if (fs.existsSync(legacyPath)) {
    try {
      const raw = fs.readFileSync(legacyPath, 'utf-8');
      return { success: true, data: JSON.parse(raw), migratedFromLegacy: true };
    } catch (err) {
      console.error('[chat-sessions] legacy file unreadable:', err.message);
    }
  }
  return { success: true, data: null };
});

ipcMain.handle('save-meeting-notes', async (event, sessionName, notes) => {
  try {
    const outputDir = path.join(getBackendCwd(), '_internal', 'output');
    if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
    const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_');
    const notesFile = path.join(outputDir, `${safeName}_notes.txt`);
    fs.writeFileSync(notesFile, notes, 'utf-8');
    return { success: true, path: notesFile };
  } catch (error) {
    console.error('Failed to save meeting notes:', error);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('update-meeting', async (event, summaryFilePath, updates) => {
  try {
    const projectRoot = path.join(__dirname, '..');

    // Define allowed base directories for file operations (includes custom storage)
    const allowedBaseDirs = getAllowedBaseDirs();

    // Convert to absolute path if needed
    const absolutePath = path.isAbsolute(summaryFilePath)
      ? summaryFilePath
      : path.join(projectRoot, summaryFilePath);

    // Security: Validate file path is within allowed directories
    if (!validateSafeFilePath(absolutePath, allowedBaseDirs)) {
      console.error(`Security: Blocked attempt to update file outside allowed directories: ${absolutePath}`);
      return {
        success: false,
        error: 'Invalid file path'
      };
    }

    // Read existing data
    if (!fs.existsSync(absolutePath)) {
      return {
        success: false,
        error: 'Meeting file not found'
      };
    }

    const isMarkdown = absolutePath.endsWith('.md');
    let data;

    if (isMarkdown) {
      const raw = fs.readFileSync(absolutePath, 'utf8');
      // Escape a string for a YAML double-quoted scalar. Backslash MUST be
      // escaped before the quote, and embedded newlines must become literal
      // \n so they don't end the scalar mid-line.
      const yamlQuote = (s) =>
        '"' + String(s)
          .replace(/\\/g, '\\\\')
          .replace(/"/g, '\\"')
          .replace(/\n/g, '\\n')
          .replace(/\r/g, '')
        + '"';

      // Strip the outer quotes only — the simple frontmatter we read here is
      // for the response shape (data.session_info.name) and doesn't need to
      // reverse YAML escapes for its sole consumer (the renderer).
      const readTitle = (rawValue) => rawValue.trim().replace(/^"|"$/g, '');

      // Line-based rewrite: only mutate the keys we're updating, leave every
      // other line (including non-string values like arrays/booleans) byte-
      // identical so we don't corrupt structured fields like `folders: [...]`.
      let title = '';
      let updatedAt = new Date().toISOString();
      let body = raw;
      let updatedRaw = raw;

      if (raw.startsWith('---')) {
        const parts = raw.split('---', 3);
        if (parts.length >= 3) {
          const fmText = parts[1];
          body = parts[2];
          const lines = fmText.split('\n');
          let titleSeen = false;
          let updatedAtSeen = false;
          const newLines = lines.map((line) => {
            const colon = line.indexOf(':');
            if (colon === -1) return line;
            const key = line.slice(0, colon).trim();
            if (key === 'title') {
              titleSeen = true;
              const original = line.slice(colon + 1);
              if (updates.name !== undefined) {
                return `title: ${yamlQuote(updates.name)}`;
              }
              title = readTitle(original);
              return line;
            }
            if (key === 'updated_at') {
              updatedAtSeen = true;
              return `updated_at: ${yamlQuote(updatedAt)}`;
            }
            return line;
          });
          if (!titleSeen && updates.name !== undefined) {
            // Insert before the trailing blank line (if any) for readability.
            const insertIdx = newLines[newLines.length - 1] === '' ? newLines.length - 1 : newLines.length;
            newLines.splice(insertIdx, 0, `title: ${yamlQuote(updates.name)}`);
            title = updates.name;
          } else if (updates.name !== undefined) {
            title = updates.name;
          }
          if (!updatedAtSeen) {
            const insertIdx = newLines[newLines.length - 1] === '' ? newLines.length - 1 : newLines.length;
            newLines.splice(insertIdx, 0, `updated_at: ${yamlQuote(updatedAt)}`);
          }
          updatedRaw = `---${newLines.join('\n')}---${body}`;
        }
      }

      fs.writeFileSync(absolutePath, updatedRaw, 'utf8');

      data = {
        session_info: {
          name: updates.name !== undefined ? updates.name : title,
          summary_file: absolutePath,
          updated_at: updatedAt,
        },
      };
    } else {
      data = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));

      if (updates.name !== undefined) {
        data.session_info.name = updates.name;
      }
      if (updates.summary !== undefined) {
        data.summary = updates.summary;
      }
      if (updates.participants !== undefined) {
        data.participants = updates.participants;
      }
      if (updates.key_points !== undefined) {
        data.key_points = updates.key_points;
      }
      if (updates.action_items !== undefined) {
        data.action_items = updates.action_items;
      }

      data.session_info.updated_at = new Date().toISOString();
      fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf8');
    }

    console.log(`Updated meeting: ${absolutePath}`);

    return {
      success: true,
      message: 'Meeting updated successfully'
    };
  } catch (error) {
    console.error('Update meeting error:', error);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('reveal-meeting-folder', async (event, filePath) => {
  try {
    const projectRoot = path.join(__dirname, '..');
    const allowedBaseDirs = getAllowedBaseDirs();
    const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
    if (!validateSafeFilePath(absolutePath, allowedBaseDirs)) {
      return { success: false, error: 'Invalid file path: outside allowed directories' };
    }
    shell.showItemInFolder(absolutePath);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('delete-meeting', async (event, meetingData) => {
  try {
    const fs = require('fs');
    const path = require('path');

    // meetingData is the actual meeting object, not a file path
    const meeting = meetingData;

    // Build correct file paths from the meeting data - convert to absolute paths
    const projectRoot = path.join(__dirname, '..');

    // Define allowed base directories for file operations (includes custom storage)
    const allowedBaseDirs = getAllowedBaseDirs();

    const summaryFile = meeting.session_info?.summary_file;
    const transcriptFile = meeting.session_info?.transcript_file;
    const audioFile = meeting.session_info?.audio_file;
    const sessionName = meeting.session_info?.name;

    // Convert relative paths to absolute paths
    const absolutePaths = [];
    if (summaryFile) {
      absolutePaths.push(path.isAbsolute(summaryFile) ? summaryFile : path.join(projectRoot, summaryFile));
    }
    if (transcriptFile) {
      absolutePaths.push(path.isAbsolute(transcriptFile) ? transcriptFile : path.join(projectRoot, transcriptFile));
    }
    if (audioFile) {
      absolutePaths.push(path.isAbsolute(audioFile) ? audioFile : path.join(projectRoot, audioFile));
    }
    if (summaryFile && sessionName) {
      const outputDir = path.dirname(path.isAbsolute(summaryFile) ? summaryFile : path.join(projectRoot, summaryFile));
      const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_');
      absolutePaths.push(path.join(outputDir, `${safeName}_notes.txt`));
    }

    console.log('Attempting to delete files:', absolutePaths);

    let deletedCount = 0;
    let validationErrors = 0;

    // Delete all related files with path validation
    for (const file of absolutePaths) {
      try {
        // Security: Validate file path is within allowed directories
        if (!validateSafeFilePath(file, allowedBaseDirs)) {
          console.error(`Security: Blocked attempt to delete file outside allowed directories: ${file}`);
          validationErrors++;
          continue;
        }

        if (fs.existsSync(file)) {
          fs.unlinkSync(file);
          deletedCount++;
          console.log(`Deleted: ${file}`);
        } else {
          console.log(`File not found (already deleted?): ${file}`);
        }
      } catch (err) {
        console.warn(`Could not delete ${file}:`, err.message);
      }
    }

    if (validationErrors > 0) {
      return {
        success: false,
        error: `Blocked ${validationErrors} file deletion(s) due to security validation`
      };
    }
    
    return { 
      success: true, 
      message: `Deleted meeting and ${deletedCount} associated files` 
    };
  } catch (error) {
    console.error('Delete meeting error:', error);
    return { success: false, error: error.message };
  }
});

// Queue status handler
ipcMain.handle('get-queue-status', async () => {
  return {
    success: true,
    isProcessing,
    queueSize: processingQueue.length,
    currentJob: currentProcessingJob?.sessionName || null,
    hasRecording: currentRecordingProcess !== null || systemAudioRecordingActive,
    isPaused: currentRecordingProcess !== null && recordingRuntimeState.isPaused,
    elapsedSeconds: currentRecordingProcess !== null ? getRecordingElapsedSeconds() : 0,
    sessionName: currentRecordingSessionName
  };
});

// Global recording state management
let systemAudioRecordingActive = false;  // Track system audio recording for tray/quit
let currentRecordingProcess = null;
let currentRecordingSessionName = null;  // Surfaced in get-queue-status so renderer knows which meeting is live
let processingQueue = [];
let isProcessing = false;
let currentProcessingJob = null;
let recordingRuntimeState = {
  startedAtMs: null,
  pausedAtMs: null,
  pausedTotalMs: 0,
  isPaused: false
};
let ollamaProcess = null;  // Track spawned Ollama process for cleanup on quit
let ollamaPid = null;      // Store PID separately since unref() disconnects the process
let ollamaStartedByUs = false;

function resetRecordingRuntimeState() {
  recordingRuntimeState = {
    startedAtMs: null,
    pausedAtMs: null,
    pausedTotalMs: 0,
    isPaused: false
  };
}

function startRecordingRuntimeState() {
  recordingRuntimeState = {
    startedAtMs: Date.now(),
    pausedAtMs: null,
    pausedTotalMs: 0,
    isPaused: false
  };
}

function markRecordingPaused() {
  if (!recordingRuntimeState.startedAtMs || recordingRuntimeState.isPaused) {
    return;
  }
  recordingRuntimeState.isPaused = true;
  recordingRuntimeState.pausedAtMs = Date.now();
}

function markRecordingResumed() {
  if (!recordingRuntimeState.isPaused) {
    return;
  }
  if (recordingRuntimeState.pausedAtMs) {
    recordingRuntimeState.pausedTotalMs += Date.now() - recordingRuntimeState.pausedAtMs;
  }
  recordingRuntimeState.isPaused = false;
  recordingRuntimeState.pausedAtMs = null;
}

function getRecordingElapsedSeconds() {
  if (!recordingRuntimeState.startedAtMs) {
    return 0;
  }

  let pausedMs = recordingRuntimeState.pausedTotalMs;
  if (recordingRuntimeState.isPaused && recordingRuntimeState.pausedAtMs) {
    pausedMs += Date.now() - recordingRuntimeState.pausedAtMs;
  }

  return Math.max(
    0,
    Math.floor((Date.now() - recordingRuntimeState.startedAtMs - pausedMs) / 1000)
  );
}

// Processing queue management
async function processNextInQueue() {
  if (isProcessing || processingQueue.length === 0) {
    return;
  }
  
  isProcessing = true;
  currentProcessingJob = processingQueue.shift();
  
  console.log(`🔄 Processing queued job: ${currentProcessingJob.sessionName}`);
  
  try {
    const queueCloudKey = loadCloudApiKey();
    const queueEnv = queueCloudKey ? { ...require('process').env, STENOAI_CLOUD_API_KEY: queueCloudKey } : undefined;
    const processArgs = ['process-streaming', currentProcessingJob.audioFile, '--name', currentProcessingJob.sessionName];
    if (currentProcessingJob.notesFile && fs.existsSync(currentProcessingJob.notesFile)) {
      processArgs.push('--notes', currentProcessingJob.notesFile);
    }

    await new Promise((resolve, reject) => {
      const proc = spawn(getBackendPath(), processArgs, {
        cwd: getBackendCwd(),
        env: queueEnv
      });

      let stderrBuf = '';

      // Timeout: kill process if it runs longer than 30 minutes
      const procTimeout = setTimeout(() => {
        console.error('process-streaming timed out after 30 minutes, killing');
        proc.kill();
      }, 30 * 60 * 1000);

      proc.on('error', (err) => {
        clearTimeout(procTimeout);
        reject(new Error(`process-streaming spawn error: ${err.message}`));
      });

      proc.stdout.on('data', (data) => {
        const text = data.toString();
        // Parse protocol lines
        text.split('\n').forEach(line => {
          if (line.startsWith('CHUNK:')) {
            try {
              const encoded = line.slice(6);
              const chunk = Buffer.from(encoded, 'base64').toString('utf-8');
              if (mainWindow && !mainWindow.isDestroyed()) {
                mainWindow.webContents.send('summary-chunk', { chunk, sessionName: currentProcessingJob.sessionName });
              }
            } catch (e) { console.log('CHUNK decode error:', e.message); }
          } else if (line.startsWith('TRANSCRIPTION_COMPLETE:')) {
            sendDebugLog(`Transcription complete (${line.split(':')[1]} chars)`);
            trackEvent('transcription_completed', { success: true });
          } else if (line.startsWith('TITLE:')) {
            const title = line.slice(6);
            if (mainWindow && !mainWindow.isDestroyed()) {
              mainWindow.webContents.send('summary-title', { title, sessionName: currentProcessingJob.sessionName });
            }
          } else if (line === 'STREAM_COMPLETE') {
            trackEvent('summarization_completed', { success: true });
          } else if (line.startsWith('SAVED:')) {
            sendDebugLog(`Summary saved: ${line.slice(6)}`);
          } else if (line.trim()) {
            sendDebugLog(line.trim());
          }
        });
      });

      proc.stderr.on('data', (data) => {
        const msg = data.toString().trim();
        if (msg) {
          stderrBuf += msg + '\n';
          sendDebugLog(`STDERR: ${msg}`);
        }
      });

      proc.on('close', (code) => {
        clearTimeout(procTimeout);
        if (code === 0) {
          console.log(`✅ Completed streaming processing: ${currentProcessingJob.sessionName}`);
          // Notify frontend that streaming is done and meeting is saved
          if (mainWindow && !mainWindow.isDestroyed()) {
            mainWindow.webContents.send('summary-complete', {
              success: true,
              sessionName: currentProcessingJob.sessionName
            });
            // Also send processing-complete for backward compat (reloads meeting list)
            mainWindow.webContents.send('processing-complete', {
              success: true,
              sessionName: currentProcessingJob.sessionName,
              message: 'Processing completed successfully'
            });
          }
          resolve();
        } else {
          reject(new Error(`process-streaming exited with code ${code}: ${stderrBuf.slice(-500)}`));
        }
      });
    });

  } catch (error) {
    console.error(`❌ Processing failed for ${currentProcessingJob.sessionName}:`, error);
    trackEvent('error_occurred', { error_type: 'processing_queue' });

    if (mainWindow && !mainWindow.isDestroyed()) {
      mainWindow.webContents.send('processing-complete', {
        success: false,
        sessionName: currentProcessingJob.sessionName,
        error: error.message
      });
    }
  } finally {
    isProcessing = false;
    currentProcessingJob = null;
    // Process next job in queue
    setTimeout(processNextInQueue, 1000);
  }
}

function addToProcessingQueue(audioFile, sessionName, notesFile) {
  processingQueue.push({ audioFile, sessionName, notesFile });
  console.log(`📋 Added to processing queue: ${sessionName} (Queue size: ${processingQueue.length})`);
  processNextInQueue();
}

ipcMain.handle('start-recording-ui', async (_, sessionName) => {
  try {
    if (currentRecordingProcess) {
      return { success: false, error: 'Recording already in progress' };
    }

    // Start recording (removed clear-state to prevent race conditions)

    console.log('Starting long recording process...');
    sendDebugLog(`Starting recording process: ${sessionName || 'Meeting'}`);
    sendDebugLog('$ stenoai record 7200');

    const actualSessionName = sessionName || 'Meeting';

    // Start background recording with 2-hour limit
    // Pass cloud API key via env var for cloud summarization
    const recordEnv = {};
    const cloudKey = loadCloudApiKey();
    if (cloudKey) recordEnv.STENOAI_CLOUD_API_KEY = cloudKey;

    currentRecordingProcess = spawn(getBackendPath(), ['record', '7200', actualSessionName], {
      cwd: getBackendCwd(),
      env: Object.keys(recordEnv).length > 0 ? { ...require('process').env, ...recordEnv } : undefined
    });
    currentRecordingSessionName = actualSessionName;
    startRecordingRuntimeState();

    let hasStarted = false;
    let processingSucceeded = false;
    let recordedAudioFile = null;
    // Authoritative pointer to the final summary file once Python finishes
    // auto-renaming + writing it (emitted as `SAVED:<path>`). Use this in
    // preference to the name/audio fallbacks since it can't drift.
    let savedSummaryFile = null;

    currentRecordingProcess.stdout.on('data', (data) => {
      const output = data.toString();

      // Capture the audio file path when the recording is saved
      const audioMatch = output.match(/Recording saved:\s*(.+\.wav)/);
      if (audioMatch) {
        recordedAudioFile = audioMatch[1].trim();
      }
      console.log('Recording stdout:', output);

      // Parse streaming protocol + send to debug panel
      output.split('\n').forEach(line => {
        if (line.startsWith('CHUNK:')) {
          const encoded = line.slice(6);
          try {
            const chunk = Buffer.from(encoded, 'base64').toString('utf-8');
            if (mainWindow && !mainWindow.isDestroyed()) {
              mainWindow.webContents.send('summary-chunk', { chunk, sessionName: actualSessionName });
            }
          } catch (e) { /* ignore decode errors */ }
        } else if (line.startsWith('TITLE:')) {
          const title = line.slice(6);
          if (mainWindow && !mainWindow.isDestroyed()) {
            mainWindow.webContents.send('summary-title', { title, sessionName: actualSessionName });
          }
        } else if (line === 'STREAM_COMPLETE') {
          if (mainWindow && !mainWindow.isDestroyed()) {
            mainWindow.webContents.send('summary-complete', { success: true, sessionName: actualSessionName });
          }
        } else if (line.startsWith('SAVED:')) {
          savedSummaryFile = line.slice(6).trim();
        } else if (line.trim()) {
          sendDebugLog(line.trim());
        }
      });

      // Background recording process handles complete pipeline - just notify when done
      if (output.includes('✅ Complete processing finished!')) {
        processingSucceeded = true;
        console.log(`🎉 Recording and processing completed for: ${actualSessionName}`);
        // Notify frontend that everything is done
        if (mainWindow) {
          // Get the processed meeting data to send to frontend
          runPythonScript('simple_recorder.py', ['list-meetings'], true)
            .then(meetingsResult => {
              const allMeetings = JSON.parse(meetingsResult);
              // Prefer the SAVED:<path> pointer Python emits — that's the
              // exact summary file written this session and survives the
              // auto-rename. Fall back to name match (only if user kept the
              // placeholder), then to audio-file basename.
              let processedMeeting = null;
              if (savedSummaryFile) {
                processedMeeting = allMeetings.find(
                  m => m.session_info?.summary_file === savedSummaryFile,
                );
              }
              if (!processedMeeting) {
                processedMeeting = allMeetings.find(m => m.session_info?.name === actualSessionName);
              }
              if (!processedMeeting && recordedAudioFile) {
                const audioBasename = path.basename(recordedAudioFile);
                processedMeeting = allMeetings.find(m =>
                  m.session_info?.audio_file && path.basename(m.session_info.audio_file) === audioBasename
                );
              }

              mainWindow.webContents.send('processing-complete', {
                success: true,
                sessionName: actualSessionName,
                message: 'Recording and processing completed successfully',
                meetingData: processedMeeting
              });
            })
            .catch(error => {
              console.error('Error getting processed meeting data:', error);
              // Fallback - send without meetingData, frontend will refresh
              mainWindow.webContents.send('processing-complete', {
                success: true,
                sessionName: actualSessionName,
                message: 'Recording and processing completed successfully'
              });
            });
        }
      }

      // Detect explicit processing failure from backend
      if (output.includes('❌ Processing pipeline failed')) {
        processingSucceeded = true; // Prevent duplicate notification from close handler
        console.error(`Processing failed for: ${actualSessionName}`);
        if (mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('processing-complete', {
            success: false,
            sessionName: actualSessionName,
            message: 'Processing failed: summarization error (check that Ollama and a model are available)'
          });
        }
      }

      // Don't queue background recordings for additional processing - they handle it themselves!

      if (output.includes('Recording to:') && !hasStarted) {
        hasStarted = true;
      }
    });

    currentRecordingProcess.stderr.on('data', (data) => {
      const output = data.toString();
      console.log('Recording stderr:', output);

      // Send real-time stderr to debug panel (same as runPythonScript)
      output.split('\n').forEach(line => {
        if (line.trim()) sendDebugLog('STDERR: ' + line.trim());
      });
    });

    currentRecordingProcess.on('close', (code) => {
      console.log(`Recording process closed with code ${code}`);
      sendDebugLog(`Recording process completed with exit code: ${code}`);
      currentRecordingProcess = null;
      currentRecordingSessionName = null;
      resetRecordingRuntimeState();
      updateTrayIcon(false);

      // If process exited without a success or failure message, notify the user
      if (!processingSucceeded && hasStarted && mainWindow && !mainWindow.isDestroyed()) {
        console.error(`Recording process exited (code ${code}) without completing processing`);
        mainWindow.webContents.send('processing-complete', {
          success: false,
          sessionName: actualSessionName,
          message: `Processing failed unexpectedly (exit code ${code})`
        });
      }
    });

    // Give it time to start
    await new Promise(resolve => setTimeout(resolve, 2000));
    
    if (currentRecordingProcess) {
      trackEvent('recording_started');
      updateTrayIcon(true);
      return { success: true, message: 'Recording started successfully' };
    } else {
      return { success: false, error: 'Failed to start recording process' };
    }
  } catch (error) {
    console.error('Start recording UI error:', error.message);
    currentRecordingProcess = null;
    currentRecordingSessionName = null;
    resetRecordingRuntimeState();
    updateTrayIcon(false);
    trackEvent('error_occurred', { error_type: 'start_recording_ui' });
    return { success: false, error: error.message };
  }
});

ipcMain.handle('pause-recording-ui', async () => {
  try {
    if (!currentRecordingProcess) {
      sendDebugLog('Pause failed: No recording process found');
      return { success: false, error: 'No recording in progress' };
    }

    console.log('Pausing recording process...');
    sendDebugLog('Sending SIGUSR1 to pause recording...');

    // Send SIGUSR1 to pause recording (Unix only)
    if (process.platform !== 'win32') {
      currentRecordingProcess.kill('SIGUSR1');
      markRecordingPaused();
      sendDebugLog('SIGUSR1 sent successfully');
      return { success: true, message: 'Recording paused' };
    } else {
      return { success: false, error: 'Pause not supported on Windows' };
    }
  } catch (error) {
    console.error('Pause recording UI error:', error.message);
    sendDebugLog(`Pause error: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('resume-recording-ui', async () => {
  try {
    if (!currentRecordingProcess) {
      sendDebugLog('Resume failed: No recording process found');
      return { success: false, error: 'No recording in progress' };
    }

    console.log('Resuming recording process...');
    sendDebugLog('Sending SIGUSR2 to resume recording...');

    // Send SIGUSR2 to resume recording (Unix only)
    if (process.platform !== 'win32') {
      currentRecordingProcess.kill('SIGUSR2');
      markRecordingResumed();
      sendDebugLog('SIGUSR2 sent successfully');
      return { success: true, message: 'Recording resumed' };
    } else {
      return { success: false, error: 'Resume not supported on Windows' };
    }
  } catch (error) {
    console.error('Resume recording UI error:', error.message);
    sendDebugLog(`Resume error: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('stop-recording-ui', async () => {
  try {
    if (!currentRecordingProcess) {
      return { success: false, error: 'No recording in progress' };
    }

    console.log('Stopping recording process...');

    // Send SIGTERM to trigger graceful stop and processing
    currentRecordingProcess.kill('SIGTERM');

    // Don't wait - let the process complete independently
    // The process will handle: stop recording → transcribe → summarize → exit
    currentRecordingProcess = null;
    currentRecordingSessionName = null;
    resetRecordingRuntimeState();
    updateTrayIcon(false);

    trackEvent('recording_stopped');
    return {
      success: true,
      message: 'Recording stopped - processing will complete in background'
    };
  } catch (error) {
    console.error('Stop recording UI error:', error.message);
    currentRecordingProcess = null;
    currentRecordingSessionName = null;
    resetRecordingRuntimeState();
    updateTrayIcon(false);
    trackEvent('error_occurred', { error_type: 'stop_recording_ui' });
    return { success: false, error: error.message };
  }
});

// Setup IPC handlers

ipcMain.handle('startup-setup-check', async () => {
  try {
    console.log('Running startup setup check...');
    
    // Use Python backend to check setup
    const result = await runPythonScript('simple_recorder.py', ['setup-check']);
    console.log('Setup check result:', result);
    
    // Parse the output to determine if setup is complete
    const allGood = result.includes('🎉 System check passed!');
    
    // Extract check results for UI display
    const lines = result.split('\n');
    const checks = [];
    
    lines.forEach(line => {
      if (line.includes('✅') || line.includes('❌') || line.includes('⚠️')) {
        const parts = line.split(/\s{2,}/); // Split on multiple spaces
        if (parts.length >= 2) {
          checks.push([parts[0].trim(), parts[1].trim()]);
        }
      }
    });
    
    console.log('Parsed checks:', checks);
    console.log('All good:', allGood);
    
    return { 
      success: true, 
      allGood,
      checks
    };
  } catch (error) {
    console.error('Setup check error:', error);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('setup-system-check', async () => {
  try {
    // Check Python installation
    const pythonResult = await new Promise((resolve) => {
      exec('python3 --version', (error, stdout, stderr) => {
        if (error) {
          resolve(false);
        } else {
          resolve(true);
        }
      });
    });
    
    if (!pythonResult) {
      return { success: false, error: 'Python 3 not found. Please install Python 3.8+' };
    }
    
    // Create required directories - match Python logic for DMG vs development
    const os = require('os');
    const currentPath = __dirname;
    let baseDir;
    
    // Detect if running from app bundle (DMG install) or development
    if (currentPath.includes('StenoAI.app') || currentPath.includes('Applications')) {
      // DMG/Production: Use Application Support folder
      baseDir = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai');
    } else {
      // Development: Use project relative paths  
      baseDir = path.join(__dirname, '..');
    }
    
    const dirs = ['recordings', 'transcripts', 'output'];
    
    for (const dir of dirs) {
      const dirPath = path.join(baseDir, dir);
      if (!fs.existsSync(dirPath)) {
        fs.mkdirSync(dirPath, { recursive: true });
      }
    }
    
    // Create venv directory if it doesn't exist  
    const projectRoot = path.join(__dirname, '..');
    const venvPath = path.join(projectRoot, 'venv');
    if (!fs.existsSync(venvPath)) {
      await new Promise((resolve, reject) => {
        const process = spawn('python3', ['-m', 'venv', 'venv'], {
          cwd: projectRoot
        });
        
        process.on('close', (code) => {
          if (code === 0) {
            resolve();
          } else {
            reject(new Error('Failed to create virtual environment'));
          }
        });
        
        process.on('error', reject);
      });
    }
    
    trackEvent('setup_completed', { step: 'system_check' });
    return { success: true, message: 'System setup complete - Python and directories ready' };
  } catch (error) {
    trackEvent('setup_failed', { step: 'system_check' });
    return { success: false, error: error.message };
  }
});

ipcMain.handle('setup-ffmpeg', async () => {
  try {
    sendDebugLog('$ Checking for existing ffmpeg installation...');

    // Check bundled ffmpeg first (shipped with the app), then system paths
    const bundledFfmpeg = app.isPackaged
      ? path.join(process.resourcesPath, 'stenoai', 'ffmpeg')
      : path.join(__dirname, '..', 'dist', 'stenoai', 'ffmpeg');
    const ffmpegPaths = [bundledFfmpeg, 'ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'];
    sendDebugLog(`$ Checking: ${ffmpegPaths.join(', ')}`);
    let ffmpegPath = null;

    for (const testPath of ffmpegPaths) {
      try {
        const found = await new Promise((resolve) => {
          const proc = spawn(testPath, ['-version'], { timeout: 5000 });
          proc.on('error', () => resolve(false));
          proc.on('close', (code) => resolve(code === 0));
        });

        if (found) {
          ffmpegPath = testPath;
          sendDebugLog(`Found ffmpeg at: ${testPath}`);
          break;
        }
      } catch (error) {
        // Try next path
        continue;
      }
    }

    if (!ffmpegPath) {
      sendDebugLog('ffmpeg not found in any common locations');
    }
    
    // Install ffmpeg if not present
    if (!ffmpegPath) {
      sendDebugLog('ffmpeg not found, checking for Homebrew...');
      sendDebugLog('$ Checking: brew, /opt/homebrew/bin/brew, /usr/local/bin/brew');

      // First check if Homebrew is installed and get its path
      const brewPaths = ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
      let brewPath = null;

      for (const testPath of brewPaths) {
        try {
          const found = await new Promise((resolve) => {
            const proc = spawn(testPath, ['--version'], { timeout: 5000 });
            proc.on('error', () => resolve(false));
            proc.on('close', (code) => resolve(code === 0));
          });

          if (found) {
            brewPath = testPath;
            sendDebugLog(`Found Homebrew at: ${testPath}`);
            break;
          }
        } catch (error) {
          // Try next path
          continue;
        }
      }

      if (!brewPath) {
        sendDebugLog('Homebrew not found in any common locations');
      }
      
      // Install Homebrew if missing
      if (!brewPath) {
        sendDebugLog('Homebrew not found, installing...');
        sendDebugLog('$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"');

        // Note: This uses the official Homebrew installation script
        // Using exec here is intentional as this is the documented installation method
        // The URL is hardcoded and not user-controlled
        await new Promise((resolve, reject) => {
          const process = exec('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
               { timeout: 600000 });

          process.stdout.on('data', (data) => {
            sendDebugLog(data.toString().trim());
          });

          process.stderr.on('data', (data) => {
            sendDebugLog('STDERR: ' + data.toString().trim());
          });

          process.on('close', (code) => {
            if (code === 0) {
              sendDebugLog('Homebrew installation completed successfully');
              resolve();
            } else {
              sendDebugLog(`Homebrew installation failed with exit code: ${code}`);
              reject(new Error('Failed to install Homebrew automatically'));
            }
          });
        });

        // After installing, set brewPath to the default location
        brewPath = '/opt/homebrew/bin/brew';
      } else {
        sendDebugLog('Homebrew found, proceeding with ffmpeg installation...');
      }

      // Now install ffmpeg via Homebrew using spawn for security
      sendDebugLog(`$ ${brewPath} install ffmpeg`);
      await new Promise((resolve, reject) => {
        const process = spawn(brewPath, ['install', 'ffmpeg'], { timeout: 300000 });

        process.stdout.on('data', (data) => {
          sendDebugLog(data.toString().trim());
        });

        process.stderr.on('data', (data) => {
          sendDebugLog('STDERR: ' + data.toString().trim());
        });

        process.on('close', (code) => {
          if (code === 0) {
            sendDebugLog('ffmpeg installation completed successfully');
            resolve();
          } else {
            sendDebugLog(`ffmpeg installation failed with exit code: ${code}`);
            reject(new Error('Failed to install ffmpeg via Homebrew'));
          }
        });

        process.on('error', (error) => {
          sendDebugLog(`ffmpeg installation error: ${error.message}`);
          reject(error);
        });
      });
    } else {
      sendDebugLog('ffmpeg already installed, skipping installation');
    }
    
    return { success: true, message: 'ffmpeg ready' };
  } catch (error) {
    sendDebugLog(`ffmpeg setup failed: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('setup-python', async () => {
  try {
    // Python backend is bundled via PyInstaller - no setup needed
    sendDebugLog('Python backend is bundled, skipping setup');
    return { success: true, message: 'Python backend bundled' };

    // Legacy code below - kept for reference but never runs
    const projectRoot = path.join(__dirname, '..');
    const venvPath = path.join(projectRoot, 'venv');

    sendDebugLog(`Working directory: ${projectRoot}`);
    
    // Create virtual environment if it doesn't exist
    if (!fs.existsSync(venvPath)) {
      sendDebugLog('Python virtual environment not found, creating...');
      sendDebugLog('$ python3 -m venv venv');
      
      await new Promise((resolve, reject) => {
        const process = spawn('python3', ['-m', 'venv', 'venv'], {
          cwd: projectRoot,
          stdio: 'pipe'
        });
        
        process.stdout.on('data', (data) => {
          sendDebugLog(data.toString().trim());
        });
        
        process.stderr.on('data', (data) => {
          sendDebugLog('STDERR: ' + data.toString().trim());
        });
        
        process.on('close', (code) => {
          if (code === 0) {
            sendDebugLog('Virtual environment created successfully');
            resolve();
          } else {
            sendDebugLog(`Virtual environment creation failed with exit code: ${code}`);
            reject(new Error('Failed to create virtual environment'));
          }
        });
        
        process.on('error', (error) => {
          sendDebugLog(`Process error: ${error.message}`);
          reject(error);
        });
      });
    } else {
      sendDebugLog('Python virtual environment already exists');
    }
    
    // Install requirements including Whisper
    sendDebugLog('Installing Python dependencies...');
    sendDebugLog('$ pip install -r requirements.txt openai-whisper');
    
    return new Promise((resolve) => {
      const pythonPath = path.join(venvPath, 'bin', 'python');
      const process = spawn(pythonPath, ['-m', 'pip', 'install', '-r', 'requirements.txt', 'openai-whisper'], {
        cwd: projectRoot,
        stdio: 'pipe'
      });
      
      let output = '';
      
      process.stdout.on('data', (data) => {
        const text = data.toString().trim();
        if (text) {
          sendDebugLog(text);
          output += text;
        }
      });
      
      process.stderr.on('data', (data) => {
        const text = data.toString().trim();
        if (text) {
          sendDebugLog('STDERR: ' + text);
          output += text;
        }
      });
      
      process.on('close', (code) => {
        if (code === 0) {
          sendDebugLog('Python dependencies installation completed successfully');
          trackEvent('setup_completed', { step: 'python_dependencies' });
          resolve({ success: true, message: 'Python dependencies and Whisper installed' });
        } else {
          sendDebugLog(`Python dependencies installation failed with exit code: ${code}`);
          trackEvent('setup_failed', { step: 'python_dependencies' });
          resolve({ success: false, error: `Installation failed: ${output}` });
        }
      });
      
      process.on('error', (error) => {
        resolve({ success: false, error: `Process error: ${error.message}` });
      });
    });
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// ── Auto-updater ──
function setupAutoUpdater() {
  if (IS_E2E) {
    sendDebugLog('Auto-updater: skipped (E2E mode)');
    return;
  }
  // Don't check for updates in dev mode
  if (!app.isPackaged) {
    sendDebugLog('Auto-updater: skipped (dev mode)');
    return;
  }

  autoUpdater.autoDownload = true;
  autoUpdater.autoInstallOnAppQuit = true;

  autoUpdater.on('checking-for-update', () => {
    sendDebugLog('Auto-updater: checking for updates...');
  });

  autoUpdater.on('update-available', (info) => {
    sendDebugLog(`Auto-updater: update available (v${info.version})`);
    if (mainWindow) {
      mainWindow.webContents.send('update-available', { version: info.version });
    }
  });

  autoUpdater.on('update-not-available', () => {
    sendDebugLog('Auto-updater: up to date');
  });

  autoUpdater.on('download-progress', (progress) => {
    sendDebugLog(`Auto-updater: downloading ${Math.round(progress.percent)}%`);
    if (mainWindow) {
      mainWindow.webContents.send('update-download-progress', { percent: Math.round(progress.percent) });
    }
  });

  autoUpdater.on('update-downloaded', (info) => {
    sendDebugLog(`Auto-updater: v${info.version} ready to install`);
    if (mainWindow) {
      mainWindow.webContents.send('update-downloaded', { version: info.version });
    }
  });

  autoUpdater.on('error', (err) => {
    sendDebugLog(`Auto-updater error: ${err.message}`);
  });

  // Check on launch (after a short delay to not block startup)
  setTimeout(() => {
    autoUpdater.checkForUpdates().catch(() => {});
  }, 10000);

  // Re-check every 30 minutes
  setInterval(() => {
    autoUpdater.checkForUpdates().catch(() => {});
  }, 30 * 60 * 1000);
}

ipcMain.on('install-update', () => {
  // Bypass the mainWindow 'close' handler's preventDefault+hide so that
  // quitAndInstall's window-close step actually quits the app. Without this
  // the app just minimises and Squirrel never gets to apply the update.
  isQuitting = true;
  autoUpdater.quitAndInstall(false, true);
});

// Add IPC handler for sending debug logs to frontend
function sendDebugLog(message) {
  // Send to main window (both setup console and debug panel)
  if (mainWindow) {
    mainWindow.webContents.send('debug-log', message);
  }
}

ipcMain.handle('setup-ollama-and-model', async () => {
  try {
    // Check AI provider -- skip local Ollama setup for remote/cloud
    try {
      const providerResult = await runPythonScript('simple_recorder.py', ['get-ai-provider'], true);
      const providerConfig = JSON.parse(providerResult.trim());
      if (providerConfig.ai_provider === 'remote' || providerConfig.ai_provider === 'cloud') {
        sendDebugLog(`AI provider is "${providerConfig.ai_provider}" -- skipping local Ollama setup`);
        return { success: true, skipped: true };
      }
    } catch (e) {
      sendDebugLog(`Could not read AI provider, proceeding with local setup: ${e.message}`);
    }

    // Check macOS version — bundled Ollama requires macOS 14 (Sonoma) or later
    const macosRelease = os.release(); // e.g. "23.1.0" for macOS 14.1
    const darwinMajor = parseInt(macosRelease.split('.')[0], 10);
    // Darwin 23 = macOS 14 (Sonoma), Darwin 22 = macOS 13 (Ventura), etc.
    if (darwinMajor < 23) {
      const macosVersion = darwinMajor >= 22 ? '13 (Ventura)' : darwinMajor >= 21 ? '12 (Monterey)' : `(Darwin ${darwinMajor})`;
      sendDebugLog(`macOS ${macosVersion} detected — Ollama requires macOS 14 (Sonoma) or later`);
      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.' };
    }

    sendDebugLog('Locating bundled Ollama...');
    const finalOllamaPath = await findOllamaExecutable();
    if (!finalOllamaPath) {
      sendDebugLog('Error: Bundled Ollama not found');
      return { success: false, error: 'Bundled Ollama not found. Please reinstall StenoAI.' };
    }
    sendDebugLog(`Found bundled Ollama at: ${finalOllamaPath}`);

    // Reuse already-running Ollama if its API is reachable on 11434.
    // Avoids "address already in use" when the user (or a previous launch)
    // already has Ollama up.
    const httpProbe = require('http');
    const ollamaAlreadyRunning = await new Promise((resolve) => {
      const req = httpProbe.get('http://127.0.0.1:11434/api/tags', { timeout: 1500 }, (res) => {
        resolve(res.statusCode === 200);
      });
      req.on('error', () => resolve(false));
      req.on('timeout', () => { req.destroy(); resolve(false); });
    });
    if (ollamaAlreadyRunning) {
      sendDebugLog('Ollama already running on 127.0.0.1:11434 — reusing existing instance');
    }

    let ollamaExited = false;
    let ollamaExitCode = null;
    let ollamaDyldError = false;
    if (!ollamaAlreadyRunning) {
      sendDebugLog('Starting Ollama service...');
      sendDebugLog(`$ ${finalOllamaPath} serve`);
      ollamaProcess = spawn(finalOllamaPath, ['serve'], { detached: true, stdio: ['ignore', 'ignore', 'pipe'], env: getOllamaEnv() });
      ollamaPid = ollamaProcess.pid;
      // Write PID file so quit handler can find the process
      try { require('fs').writeFileSync(path.join(getBackendCwd(), '_internal', 'ollama.pid'), String(ollamaPid)); } catch (_) {}
      ollamaProcess.stderr.on('data', (data) => {
        const msg = data.toString().trim();
        if (msg) sendDebugLog(`Ollama: ${msg}`);
        if (msg.includes('Symbol not found') || msg.includes('dyld')) ollamaDyldError = true;
      });
      ollamaProcess.on('exit', (code) => {
        ollamaExited = true;
        ollamaExitCode = code;
        ollamaPid = null;
        if (code !== 0 && code !== null) {
          sendDebugLog(`Ollama process exited with code ${code}`);
        }
      });
      ollamaProcess.unref();
      ollamaStartedByUs = true;
    }

    // Wait for Ollama to be ready (poll with early exit detection).
    // When we reused an existing instance, skip the wait — it's already up.
    sendDebugLog('Waiting for Ollama service to be ready...');
    const maxAttempts = ollamaAlreadyRunning ? 1 : 30;
    let ready = ollamaAlreadyRunning;
    for (let i = 0; i < maxAttempts && !ready; i++) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      if (ollamaExited) {
        sendDebugLog(`Ollama process died during startup (exit code: ${ollamaExitCode})`);
        break;
      }
      try {
        const http = require('http');
        ready = await new Promise((resolve) => {
          const req = http.get('http://127.0.0.1:11434/api/tags', { timeout: 2000 }, (res) => {
            resolve(res.statusCode === 200);
          });
          req.on('error', () => resolve(false));
          req.on('timeout', () => { req.destroy(); resolve(false); });
        });
        if (ready) {
          sendDebugLog(`Ollama ready after ${i + 1} seconds`);
          break;
        }
      } catch (e) {
        // Continue polling
      }
    }

    if (!ready) {
      if (ollamaExited) {
        if (ollamaDyldError) {
          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.' };
        }
        return { success: false, error: `Ollama failed to start (exit code: ${ollamaExitCode}). Check debug logs for details.` };
      }
      sendDebugLog('Warning: Ollama may not be fully ready, attempting pull anyway...');
    }
    
    sendDebugLog('Downloading AI model (this may take several minutes)...');
    sendDebugLog('POST http://127.0.0.1:11434/api/pull {name: "llama3.2:3b"}');

    const http = require('http');
    return new Promise((resolve) => {
      const postData = JSON.stringify({ name: 'llama3.2:3b' });
      const req = http.request({
        hostname: '127.0.0.1',
        port: 11434,
        path: '/api/pull',
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        timeout: 600000
      }, (res) => {
        let lastStatus = '';
        res.on('data', (chunk) => {
          // Ollama streams newline-delimited JSON
          const lines = chunk.toString().split('\n').filter(Boolean);
          for (const line of lines) {
            try {
              const json = JSON.parse(line);
              if (json.error) {
                sendDebugLog(`Pull error: ${json.error}`);
                return;
              }
              // Log progress without spamming duplicate status
              const status = json.status || '';
              if (json.total && json.completed) {
                const pct = Math.round((json.completed / json.total) * 100);
                const msg = `${status} ${pct}%`;
                if (msg !== lastStatus) {
                  sendDebugLog(msg);
                  lastStatus = msg;
                }
              } else if (status !== lastStatus) {
                sendDebugLog(status);
                lastStatus = status;
              }
            } catch (e) {
              // Non-JSON line, log as-is
              sendDebugLog(chunk.toString().trim());
            }
          }
        });

        res.on('end', async () => {
          if (res.statusCode === 200) {
            sendDebugLog('AI model download completed successfully');
            try {
              await runPythonScript('simple_recorder.py', ['set-model', 'llama3.2:3b'], true);
            } catch (e) {
              // Non-fatal -- config reset is best-effort
            }
            trackEvent('setup_completed', { step: 'ollama_and_model' });
            resolve({ success: true, message: 'Ollama and AI model ready' });
          } else {
            sendDebugLog(`AI model download failed with status: ${res.statusCode}`);
            trackEvent('setup_failed', { step: 'ollama_and_model' });
            resolve({ success: false, error: 'Failed to download AI model', details: `HTTP ${res.statusCode}` });
          }
        });
      });

      req.on('error', (error) => {
        sendDebugLog(`Pull request error: ${error.message}`);
        resolve({ success: false, error: 'Failed to download AI model', details: error.message });
      });

      req.on('timeout', () => {
        req.destroy();
        sendDebugLog('Model pull timed out after 10 minutes');
        resolve({ success: false, error: 'Model download timed out' });
      });

      req.write(postData);
      req.end();
    });
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('setup-whisper', async () => {
  try {
    // Download whisper model using the bundled backend
    const backendPath = getBackendPath();
    sendDebugLog('Downloading Whisper transcription model (~500MB)...');
    sendDebugLog(`$ ${backendPath} download-whisper-model`);

    return new Promise((resolve) => {
      const process = spawn(backendPath, ['download-whisper-model'], {
        stdio: 'pipe'
      });

      process.stdout.on('data', (data) => {
        const text = data.toString().trim();
        if (text) sendDebugLog(text);
      });

      process.stderr.on('data', (data) => {
        const text = data.toString().trim();
        if (text) sendDebugLog('STDERR: ' + text);
      });

      process.on('close', (code) => {
        if (code === 0) {
          sendDebugLog('Whisper model downloaded successfully');
          resolve({ success: true, message: 'Whisper model ready' });
        } else {
          sendDebugLog(`Whisper model download failed with exit code: ${code}`);
          resolve({ success: false, error: 'Failed to download Whisper model' });
        }
      });

      process.on('error', (error) => {
        sendDebugLog(`Process error: ${error.message}`);
        resolve({ success: false, error: error.message });
      });
    });

    // Legacy code below - kept for reference but never runs
    const projectRoot = path.join(__dirname, '..');
    const pythonPath = path.join(projectRoot, 'venv', 'bin', 'python');

    sendDebugLog('Installing Whisper speech recognition...');
    sendDebugLog(`$ ${pythonPath} -m pip install openai-whisper`);
    
    return new Promise((resolve) => {
      const process = spawn(pythonPath, ['-m', 'pip', 'install', 'openai-whisper'], {
        cwd: projectRoot,
        stdio: 'pipe'
      });
      
      let output = '';
      
      process.stdout.on('data', (data) => {
        const text = data.toString().trim();
        if (text) {
          sendDebugLog(text);
          output += text;
        }
      });
      
      process.stderr.on('data', (data) => {
        const text = data.toString().trim();
        if (text) {
          sendDebugLog('STDERR: ' + text);
          output += text;
        }
      });
      
      process.on('close', (code) => {
        if (code === 0) {
          sendDebugLog('Whisper installation completed successfully');
          resolve({ success: true, message: 'Whisper installed successfully' });
        } else {
          sendDebugLog(`Whisper installation failed with exit code: ${code}`);
          resolve({ success: false, error: `Whisper installation failed: ${output}` });
        }
      });
      
      process.on('error', (error) => {
        resolve({ success: false, error: `Process error: ${error.message}` });
      });
    });
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('setup-test', async () => {
  try {
    sendDebugLog('Running system test...');
    sendDebugLog('$ python simple_recorder.py test');
    
    // Test the complete system
    const result = await runPythonScript('simple_recorder.py', ['test']);
    
    // Log the full result to debug console
    result.split('\n').forEach(line => {
      if (line.trim()) sendDebugLog(line.trim());
    });
    
    if (result.includes('System check passed') || result.includes('SUCCESS')) {
      sendDebugLog('System test completed successfully');
      trackEvent('setup_completed', { step: 'system_test' });
      return { success: true, message: 'System test passed' };
    } else {
      // Extract specific error details from the output
      const errorLines = result.split('\n').filter(line => line.includes('ERROR:'));
      const specificError = errorLines.length > 0 ? errorLines[errorLines.length - 1].replace('ERROR: ', '') : 'Unknown error';
      sendDebugLog(`System test failed: ${specificError}`);
      trackEvent('setup_failed', { step: 'system_test' });
      return { success: false, error: `System test failed: ${specificError}`, details: result };
    }
  } catch (error) {
    sendDebugLog(`System test error: ${error.message}`);
    return { success: false, error: error.message };
  }
});

// Settings window IPC handlers  
ipcMain.handle('trigger-setup-wizard', async () => {
  try {
    console.log('🔧 Starting setup wizard from settings...');
    
    // Trigger the main window's setup flow
    if (mainWindow) {
      mainWindow.webContents.send('trigger-setup-flow');
    }
    
    return { success: true, message: 'Setup wizard triggered in main window' };
  } catch (error) {
    console.error('Setup wizard failed:', error);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-app-version', async () => {
  try {
    const packagePath = path.join(__dirname, 'package.json');
    const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
    return {
      success: true,
      version: packageContent.version,
      name: packageContent.productName || packageContent.name
    };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// Storage path handlers
ipcMain.handle('get-storage-path', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-storage-path'], true);
    const jsonData = JSON.parse(result.trim());
    // Python only returns the user's custom path (empty string when not set).
    // Augment with the platform default so the renderer can show "where your
    // data actually lives" without hardcoding the path. custom_path mirrors
    // storage_path but is null when empty for cleaner conditionals.
    const customPath = jsonData.storage_path && jsonData.storage_path.trim()
      ? jsonData.storage_path
      : null;
    const defaultPath = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai');
    return {
      success: true,
      storage_path: customPath || defaultPath,
      custom_path: customPath,
      default_path: defaultPath,
    };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-storage-path', async (event, storagePath) => {
  try {
    const args = ['set-storage-path'];
    if (storagePath) {
      args.push(storagePath);
    }
    const result = await runPythonScript('simple_recorder.py', args);
    // Update cached custom path for file validation
    _cachedCustomStoragePath = storagePath || null;
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[0]);
    }
    return { success: true, storage_path: storagePath };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('select-storage-folder', async () => {
  try {
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openDirectory', 'createDirectory'],
      title: 'Choose storage location for StenoAI data',
      buttonLabel: 'Select Folder'
    });

    if (!result.canceled && result.filePaths.length > 0) {
      return { success: true, folderPath: result.filePaths[0] };
    }
    return { success: false, error: 'No folder selected' };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// Folder management handlers
ipcMain.handle('list-folders', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['list-folders'], true);
    return { success: true, ...JSON.parse(result.trim()) };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('create-folder', async (event, name, color) => {
  try {
    const args = ['create-folder', name];
    if (color) args.push('--color', color);
    const result = await runPythonScript('simple_recorder.py', args);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('rename-folder', async (event, folderId, name) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['rename-folder', folderId, name]);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('update-folder-icon', async (event, folderId, icon) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['update-folder-icon', folderId, icon]);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('delete-folder', async (event, folderId) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['delete-folder', folderId]);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('reorder-folders', async (event, folderIds) => {
  try {
    const args = ['reorder-folders', ...folderIds];
    const result = await runPythonScript('simple_recorder.py', args);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('add-meeting-to-folder', async (event, summaryFile, folderId) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['add-meeting-to-folder', summaryFile, folderId]);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('remove-meeting-from-folder', async (event, summaryFile, folderId) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['remove-meeting-from-folder', summaryFile, folderId]);
    const jsonMatch = result.match(/\{.*\}/s);
    return jsonMatch ? JSON.parse(jsonMatch[0]) : { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-ai-prompts', async () => {
  try {
    // Read the summarization prompt from the Python backend
    const summarizerPath = path.join(__dirname, '..', 'src', 'summarizer.py');
    
    if (fs.existsSync(summarizerPath)) {
      const content = fs.readFileSync(summarizerPath, 'utf8');
      
      // Extract the full prompt from the _create_permissive_prompt method
      const promptMatch = content.match(/def _create_permissive_prompt[\s\S]*?return f"""([\s\S]*?)"""/);
      
      if (promptMatch) {
        return {
          success: true,
          summarization: promptMatch[1].trim()
        };
      }
    }
    
    return {
      success: true,
      summarization: 'Prompt not found in summarizer.py'
    };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// Helper function to ensure Ollama service is running
async function ensureOllamaRunning() {
  try {
    // Check if Ollama service is responding
    const http = require('http');
    const response = await new Promise((resolve) => {
      const req = http.get('http://127.0.0.1:11434/api/version', { timeout: 3000 }, (res) => {
        resolve(res.statusCode === 200);
      });
      req.on('error', () => resolve(false));
      req.on('timeout', () => { req.destroy(); resolve(false); });
    });

    if (response) {
      return true; // Service is running
    }

    // Service not running, try to start it
    // Check macOS version — bundled Ollama requires macOS 14 (Sonoma) or later
    const macRelease = os.release();
    if (parseInt(macRelease.split('.')[0], 10) < 23) {
      sendDebugLog('macOS version too old for bundled Ollama — requires macOS 14 (Sonoma) or later');
      return false;
    }

    const ollamaPath = await findOllamaExecutable();
    if (!ollamaPath) {
      return false;
    }

    // Start Ollama service in background with proper env vars for dylibs
    ollamaProcess = spawn(ollamaPath, ['serve'], { detached: true, stdio: 'ignore', env: getOllamaEnv() });
    ollamaPid = ollamaProcess.pid;
    try { require('fs').writeFileSync(path.join(getBackendCwd(), '_internal', 'ollama.pid'), String(ollamaPid)); } catch (_) {}
    ollamaProcess.on('exit', () => { ollamaPid = null; });
    ollamaProcess.unref();
    ollamaStartedByUs = true;

    // Wait for service to start
    await new Promise(resolve => setTimeout(resolve, 2000));
    return true;
  } catch (error) {
    console.error('Error ensuring Ollama is running:', error);
    return false;
  }
}

// Check if Ollama is installed (for setup wizard)
ipcMain.handle('check-ollama-installed', async () => {
  try {
    const ollamaPath = await findOllamaExecutable();
    if (!ollamaPath) {
      return { success: true, installed: false };
    }
    return { success: true, installed: true, path: ollamaPath };
  } catch (error) {
    return { success: false, installed: false, error: error.message };
  }
});

// Model management handlers
ipcMain.handle('check-model-installed', async (event, modelName) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['check-model', modelName]);
    // Parse the last JSON line from output (skip any log lines)
    const lines = result.trim().split('\n');
    for (let i = lines.length - 1; i >= 0; i--) {
      try {
        const data = JSON.parse(lines[i]);
        return { success: true, installed: data.installed };
      } catch (e) {
        continue;
      }
    }
    return { success: false, installed: false, error: 'Could not parse backend response' };
  } catch (error) {
    return { success: false, installed: false, error: error.message };
  }
});

ipcMain.handle('list-models', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['list-models']);
    const jsonData = JSON.parse(result);

    return {
      success: true,
      ...jsonData
    };
  } catch (error) {
    sendDebugLog(`Error listing models: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-current-model', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-model']);
    const jsonData = JSON.parse(result);

    return {
      success: true,
      ...jsonData
    };
  } catch (error) {
    sendDebugLog(`Error getting current model: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-model', async (event, modelName) => {
  try {
    sendDebugLog(`Setting model to: ${modelName}`);
    const result = await runPythonScript('simple_recorder.py', ['set-model', modelName]);

    // Extract JSON from output (might have other text before it)
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      const jsonData = JSON.parse(jsonMatch[0]);
      trackEvent('model_changed', { model: modelName });
      return jsonData;
    }

    trackEvent('model_changed', { model: modelName });
    return { success: true, model: modelName };
  } catch (error) {
    sendDebugLog(`Error setting model: ${error.message}`);
    return { success: false, error: error.message };
  }
});



ipcMain.handle('get-whisper-model', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-whisper-model'], true);
    return JSON.parse(result.trim());
  } catch (e) { return { success: false, error: e.message }; }
});

ipcMain.handle('set-whisper-model', async (event, modelSize) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-whisper-model', modelSize]);
    return JSON.parse(result.trim());
  } catch (e) { return { success: false, error: e.message }; }
});

ipcMain.handle('get-keep-recordings', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-keep-recordings'], true);
    return JSON.parse(result.trim());
  } catch (e) { return { success: false, error: e.message }; }
});

ipcMain.handle('set-keep-recordings', async (event, enabled) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-keep-recordings', enabled.toString()]);
    return JSON.parse(result.trim());
  } catch (e) { return { success: false, error: e.message }; }
});

ipcMain.handle('get-notifications', handleGetNotifications);

ipcMain.handle('set-notifications', async (event, enabled) => {
  try {
    sendDebugLog(`Setting notifications to: ${enabled}`);
    const result = await runPythonScript('simple_recorder.py', ['set-notifications', enabled ? 'True' : 'False']);

    // Extract JSON from output
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      const jsonData = JSON.parse(jsonMatch[0]);
      return jsonData;
    }

    return { success: true, notifications_enabled: enabled };
  } catch (error) {
    sendDebugLog(`Error setting notifications: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-telemetry', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-telemetry']);
    const jsonData = JSON.parse(result);

    return {
      success: true,
      ...jsonData
    };
  } catch (error) {
    sendDebugLog(`Error getting telemetry settings: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-telemetry', async (event, enabled) => {
  try {
    sendDebugLog(`Setting telemetry to: ${enabled}`);
    const result = await runPythonScript('simple_recorder.py', ['set-telemetry', enabled ? 'True' : 'False']);

    // Update in-memory state
    telemetryEnabled = enabled;

    if (enabled && !posthogClient) {
      posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST });
      console.log('Telemetry re-enabled');
    } else if (!enabled && posthogClient) {
      await shutdownTelemetry();
      console.log('Telemetry disabled');
    }

    // Extract JSON from output
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      const jsonData = JSON.parse(jsonMatch[0]);
      return jsonData;
    }

    return { success: true, telemetry_enabled: enabled };
  } catch (error) {
    sendDebugLog(`Error setting telemetry: ${error.message}`);
    return { success: false, error: error.message };
  }
});

// Hide dock icon IPC handlers
ipcMain.handle('get-dock-icon', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-dock-icon']);
    const jsonData = JSON.parse(result);

    return {
      success: true,
      ...jsonData
    };
  } catch (error) {
    sendDebugLog(`Error getting dock icon settings: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-dock-icon', async (event, hidden) => {
  try {
    sendDebugLog(`Setting hide dock icon to: ${hidden}`);
    const result = await runPythonScript('simple_recorder.py', ['set-dock-icon', hidden ? 'True' : 'False']);

    // Apply immediately
    if (process.platform === 'darwin' && app.dock) {
      if (hidden) {
        app.dock.hide();
      } else {
        app.dock.show();
      }
    }

    // Extract JSON from output
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      const jsonData = JSON.parse(jsonMatch[0]);
      return jsonData;
    }

    return { success: true, hide_dock_icon: hidden };
  } catch (error) {
    sendDebugLog(`Error setting dock icon: ${error.message}`);
    return { success: false, error: error.message };
  }
});

// System audio capture IPC handlers
ipcMain.handle('get-system-audio', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-system-audio'], true);
    const jsonData = JSON.parse(result);
    return { success: true, ...jsonData };
  } catch (error) {
    sendDebugLog(`Error getting system audio setting: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-system-audio', async (event, enabled) => {
  try {
    sendDebugLog(`Setting system audio to: ${enabled}`);
    const result = await runPythonScript('simple_recorder.py', ['set-system-audio', enabled ? 'True' : 'False']);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[0]);
    }
    return { success: true, system_audio_enabled: enabled };
  } catch (error) {
    sendDebugLog(`Error setting system audio: ${error.message}`);
    return { success: false, error: error.message };
  }
});

// Language IPC handlers
ipcMain.handle('get-language', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-language'], true);
    const jsonData = JSON.parse(result);
    return { success: true, ...jsonData };
  } catch (error) {
    sendDebugLog(`Error getting language setting: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-language', async (event, languageCode) => {
  try {
    sendDebugLog(`Setting language to: ${languageCode}`);
    const result = await runPythonScript('simple_recorder.py', ['set-language', languageCode]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[0]);
    }
    return { success: true, language: languageCode };
  } catch (error) {
    sendDebugLog(`Error setting language: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-user-name', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-user-name'], true);
    const jsonData = JSON.parse(result.trim());
    return { success: true, ...jsonData };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-user-name', async (event, name) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-user-name', String(name ?? '')]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: true, user_name: String(name ?? '').trim() };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// AI Provider IPC handlers

function getCloudKeyPath() {
  return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.cloud-api-key');
}

function saveCloudApiKey(key) {
  try {
    const keyDir = path.dirname(getCloudKeyPath());
    if (!fs.existsSync(keyDir)) {
      fs.mkdirSync(keyDir, { recursive: true });
    }
    const encrypted = safeStorage.encryptString(key);
    fs.writeFileSync(getCloudKeyPath(), encrypted);
    return true;
  } catch (error) {
    console.error('Failed to save cloud API key:', error.message);
    return false;
  }
}

function loadCloudApiKey() {
  try {
    const keyPath = getCloudKeyPath();
    if (!fs.existsSync(keyPath)) return null;
    const encrypted = fs.readFileSync(keyPath);
    return safeStorage.decryptString(encrypted);
  } catch (error) {
    console.error('Failed to load cloud API key:', error.message);
    return null;
  }
}

function hasCloudApiKey() {
  return fs.existsSync(getCloudKeyPath());
}

ipcMain.handle('get-ai-provider', async () => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['get-ai-provider'], true);
    const jsonData = JSON.parse(result.trim());
    // Override cloud_api_key_set with safeStorage check
    jsonData.cloud_api_key_set = hasCloudApiKey();
    return { success: true, ...jsonData };
  } catch (error) {
    sendDebugLog(`Error getting AI provider: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-ai-provider', async (event, provider) => {
  try {
    sendDebugLog(`Setting AI provider to: ${provider}`);
    const result = await runPythonScript('simple_recorder.py', ['set-ai-provider', provider]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: true, ai_provider: provider };
  } catch (error) {
    sendDebugLog(`Error setting AI provider: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-remote-ollama-url', async (event, url) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-remote-ollama-url', url]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-cloud-api-url', async (event, url) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-cloud-api-url', url]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-cloud-api-key', async (event, key) => {
  try {
    const saved = saveCloudApiKey(key);
    return { success: saved, cloud_api_key_set: saved };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-cloud-provider', async (event, provider) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-cloud-provider', provider]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('set-cloud-model', async (event, model) => {
  try {
    const result = await runPythonScript('simple_recorder.py', ['set-cloud-model', model]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

ipcMain.handle('test-remote-ollama', async (event, url) => {
  try {
    sendDebugLog(`Testing remote Ollama at: ${url}`);
    const result = await runPythonScript('simple_recorder.py', ['test-remote-ollama', url]);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: false, error: 'No response' };
  } catch (error) {
    sendDebugLog(`Remote Ollama test failed: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('test-cloud-api', async () => {
  try {
    sendDebugLog('Testing cloud API connection...');
    const apiKey = loadCloudApiKey();
    const env = apiKey ? { STENOAI_CLOUD_API_KEY: apiKey } : {};
    const result = await runPythonScript('simple_recorder.py', ['test-cloud-api'], false, env);
    const jsonMatch = result.match(/\{.*\}/s);
    if (jsonMatch) return JSON.parse(jsonMatch[0]);
    return { success: false, error: 'No response' };
  } catch (error) {
    sendDebugLog(`Cloud API test failed: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('get-recordings-dir', async () => {
  try {
    // Get recordings directory from Python config
    const result = await runPythonScript('simple_recorder.py', ['get-storage-path'], true);
    const jsonData = JSON.parse(result.trim());

    let recordingsDir;
    if (jsonData.storage_path) {
      recordingsDir = path.join(jsonData.storage_path, 'recordings');
    } else if (app.isPackaged) {
      recordingsDir = path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', 'recordings');
    } else {
      recordingsDir = path.join(__dirname, '..', 'recordings');
    }

    // Ensure directory exists
    if (!fs.existsSync(recordingsDir)) {
      fs.mkdirSync(recordingsDir, { recursive: true });
    }

    return { success: true, path: recordingsDir };
  } catch (error) {
    sendDebugLog(`Error getting recordings dir: ${error.message}`);
    return { success: false, error: error.message };
  }
});

ipcMain.handle('process-system-audio-recording', async (event, audioFilePath, sessionName) => {
  try {
    sendDebugLog(`Queuing system audio recording for processing: ${audioFilePath}`);

    // Validate file path
    const allowedBaseDirs = getAllowedBaseDirs();
    if (!validateSafeFilePath(audioFilePath, allowedBaseDirs)) {
      return { success: false, error: 'Invalid file path' };
    }

    if (!fs.existsSync(audioFilePath)) {
      return { success: false, error: 'Audio file not found' };
    }

    const actualSessionName = sessionName || 'Meeting';

    // Check for user notes file
    const safeName = actualSessionName.replace(/[^a-zA-Z0-9_-]/g, '_');
    const notesFile = path.join(getBackendCwd(), '_internal', 'output', `${safeName}_notes.txt`);
    const notesPath = fs.existsSync(notesFile) ? notesFile : undefined;

    // Use the existing processing queue to avoid concurrent Ollama/Whisper runs
    addToProcessingQueue(audioFilePath, actualSessionName, notesPath);

    trackEvent('recording_stopped', { recording_mode: 'system_audio' });
    return { success: true, message: 'Added to processing queue' };
  } catch (error) {
    sendDebugLog(`Error queuing system audio: ${error.message}`);
    trackEvent('error_occurred', { error_type: 'process_system_audio' });
    return { success: false, error: error.message };
  }
});

// Track system audio recording state for tray icon
ipcMain.on('system-audio-recording-state', (event, isRecording) => {
  systemAudioRecordingActive = isRecording;
  updateTrayIcon(isRecording);
  updateTrayMenu();
});

ipcMain.handle('pull-model', async (event, modelName) => {
  try {
    sendDebugLog(`Pulling model: ${modelName}`);
    sendDebugLog('This may take several minutes...');

    return new Promise((resolve) => {
      const proc = spawn(getBackendPath(), ['pull-model', modelName], {
        cwd: getBackendCwd()
      });

      let lastStdoutLine = '';

      proc.stdout.on('data', (data) => {
        const output = data.toString().trim();
        sendDebugLog(output);
        if (output) lastStdoutLine = output;

        if (mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('model-pull-progress', {
            model: modelName,
            progress: output
          });
        }
      });

      proc.stderr.on('data', (data) => {
        const output = data.toString().trim();
        sendDebugLog(output);

        if (mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('model-pull-progress', {
            model: modelName,
            progress: output
          });
        }
      });

      proc.on('close', (code) => {
        // The backend prints a JSON result as the last stdout line.
        // Check it even on exit code 0, since the Python CLI may
        // catch errors and still exit cleanly.
        let pullResult = null;
        try { pullResult = JSON.parse(lastStdoutLine); } catch (_) {}

        const succeeded = code === 0 && (!pullResult || pullResult.success !== false);

        if (succeeded) {
          sendDebugLog(`Successfully pulled model: ${modelName}`);

          if (mainWindow && !mainWindow.isDestroyed()) {
            mainWindow.webContents.send('model-pull-complete', {
              model: modelName,
              success: true
            });
          }

          resolve({ success: true, model: modelName });
        } else {
          const errorMsg = (pullResult && pullResult.error) || `Process exited with code ${code}`;
          sendDebugLog(`Failed to pull model: ${modelName} - ${errorMsg}`);

          if (mainWindow && !mainWindow.isDestroyed()) {
            mainWindow.webContents.send('model-pull-complete', {
              model: modelName,
              success: false,
              error: errorMsg
            });
          }

          resolve({ success: false, error: errorMsg });
        }
      });

      proc.on('error', (error) => {
        sendDebugLog(`Error pulling model: ${error.message}`);

        if (mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('model-pull-complete', {
            model: modelName,
            success: false,
            error: error.message
          });
        }

        resolve({ success: false, error: error.message });
      });
    });
  } catch (error) {
    sendDebugLog(`Error in pull-model handler: ${error.message}`);
    return { success: false, error: error.message };
  }
});

// Helper to build env vars for running the bundled Ollama binary directly
function getOllamaEnv() {
  let ollamaDir;
  if (app.isPackaged) {
    ollamaDir = path.join(process.resourcesPath, 'stenoai', '_internal', 'ollama');
  } else {
    ollamaDir = path.join(__dirname, '..', 'bin');
  }
  const env = { ...process.env };
  const existing = env.DYLD_LIBRARY_PATH || '';
  env.DYLD_LIBRARY_PATH = existing ? `${ollamaDir}:${existing}` : ollamaDir;
  env.MLX_METAL_PATH = path.join(ollamaDir, 'mlx.metallib');
  return env;
}

// Helper function to find Ollama executable (bundled only)
async function findOllamaExecutable() {
  let bundledOllamaPath;
  if (app.isPackaged) {
    // Production: bundled inside PyInstaller _internal directory
    bundledOllamaPath = path.join(process.resourcesPath, 'stenoai', '_internal', 'ollama', 'ollama');
  } else {
    // Development: in project bin/ directory
    bundledOllamaPath = path.join(__dirname, '..', 'bin', 'ollama');
  }

  if (fs.existsSync(bundledOllamaPath)) {
    console.log(`Using bundled Ollama: ${bundledOllamaPath}`);
    return bundledOllamaPath;
  }

  console.error(`Bundled Ollama not found at: ${bundledOllamaPath}`);
  return null;
}

// Update checking functionality
async function checkForUpdates() {
  return new Promise((resolve) => {
    const options = {
      hostname: 'api.github.com',
      path: '/repos/ruzin/stenoai/releases/latest',
      method: 'GET',
      headers: {
        'User-Agent': 'StenoAI-Updater'
      }
    };

    const req = https.request(options, (res) => {
      let data = '';
      
      res.on('data', (chunk) => {
        data += chunk;
      });
      
      res.on('end', () => {
        try {
          const release = JSON.parse(data);
          const latestVersion = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present
          
          // Get current version from package.json
          const packagePath = path.join(__dirname, 'package.json');
          const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
          const currentVersion = packageContent.version;
          
          console.log(`Current version: ${currentVersion}, Latest version: ${latestVersion}`);
          
          // Simple version comparison (works for semantic versioning)
          const isUpdateAvailable = compareVersions(currentVersion, latestVersion) < 0;
          
          resolve({
            success: true,
            updateAvailable: isUpdateAvailable,
            currentVersion: currentVersion,
            latestVersion: latestVersion,
            releaseUrl: release.html_url,
            releaseName: release.name || `Version ${latestVersion}`,
            downloadUrl: getDownloadUrl(release.assets)
          });
        } catch (error) {
          console.error('Error parsing GitHub API response:', error);
          resolve({ success: false, error: 'Failed to parse update data' });
        }
      });
    });
    
    req.on('error', (error) => {
      console.error('Error checking for updates:', error);
      resolve({ success: false, error: error.message });
    });
    
    req.setTimeout(10000, () => {
      req.destroy();
      resolve({ success: false, error: 'Update check timeout' });
    });
    
    req.end();
  });
}

function compareVersions(current, latest) {
  const currentParts = current.split('.').map(Number);
  const latestParts = latest.split('.').map(Number);
  
  for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
    const currentPart = currentParts[i] || 0;
    const latestPart = latestParts[i] || 0;
    
    if (currentPart < latestPart) return -1;
    if (currentPart > latestPart) return 1;
  }
  
  return 0;
}

function getDownloadUrl(assets) {
  // Find the appropriate download URL based on platform/architecture
  const platform = process.platform;
  const arch = process.arch;
  
  if (platform === 'darwin') {
    // Look for macOS DMG files
    const armAsset = assets.find(asset => 
      asset.name.includes('arm64') && asset.name.includes('dmg')
    );
    const intelAsset = assets.find(asset => 
      asset.name.includes('x64') && asset.name.includes('dmg')
    );
    
    // Prefer ARM64 for Apple Silicon, fallback to Intel
    if (arch === 'arm64' && armAsset) return armAsset.browser_download_url;
    if (intelAsset) return intelAsset.browser_download_url;
    if (armAsset) return armAsset.browser_download_url;
  }
  
  // Fallback to first asset or releases page
  return assets.length > 0 ? assets[0].browser_download_url : null;
}

ipcMain.handle('check-for-updates', async () => {
  return await checkForUpdates();
});

ipcMain.handle('check-announcements', async () => {
  const packagePath = path.join(__dirname, 'package.json');
  const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
  const currentVersion = packageContent.version;

  // Try local file first (for development/testing)
  const localPath = path.join(__dirname, '..', 'announcements.json');
  if (fs.existsSync(localPath)) {
    try {
      const localData = JSON.parse(fs.readFileSync(localPath, 'utf8'));
      console.log('Loaded announcements from local file');
      return {
        success: true,
        announcements: localData.announcements || [],
        currentVersion
      };
    } catch (error) {
      console.error('Error reading local announcements.json:', error);
    }
  }

  // Fall back to remote
  return new Promise((resolve) => {
    const options = {
      hostname: 'raw.githubusercontent.com',
      path: '/ruzin/stenoai/main/announcements.json',
      method: 'GET',
      headers: {
        'User-Agent': 'StenoAI-App'
      }
    };

    const req = https.request(options, (res) => {
      let data = '';

      res.on('data', (chunk) => {
        data += chunk;
      });

      res.on('end', () => {
        try {
          const parsed = JSON.parse(data);
          resolve({
            success: true,
            announcements: parsed.announcements || [],
            currentVersion
          });
        } catch (error) {
          console.error('Error parsing announcements:', error);
          resolve({ success: false, error: 'Failed to parse announcements' });
        }
      });
    });

    req.on('error', (error) => {
      console.error('Error fetching announcements:', error);
      resolve({ success: false, error: error.message });
    });

    req.setTimeout(10000, () => {
      req.destroy();
      resolve({ success: false, error: 'Announcements fetch timeout' });
    });

    req.end();
  });
});

ipcMain.handle('open-release-page', async (event, url) => {
  try {
    if (typeof url !== 'string' || !url) {
      return { success: false, error: 'invalid url' };
    }
    let parsed;
    try { parsed = new URL(url); } catch {
      return { success: false, error: 'invalid url' };
    }
    // Release pages live on github.com -- restrict to that origin so a
    // compromised renderer cannot launch arbitrary external URLs through
    // this channel.
    if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') {
      return { success: false, error: 'unsupported url' };
    }
    await shell.openExternal(url);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// Generic external-URL opener for renderer-triggered links (e.g. meeting
// join URLs on Home). Http/https only — rejects custom schemes so a
// compromised renderer cannot launch arbitrary protocol handlers.
ipcMain.handle('open-external', async (event, url) => {
  try {
    if (typeof url !== 'string' || !url) {
      return { success: false, error: 'invalid url' };
    }
    let parsed;
    try { parsed = new URL(url); } catch {
      return { success: false, error: 'invalid url' };
    }
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
      return { success: false, error: 'unsupported scheme' };
    }
    await shell.openExternal(url);
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});
// ── Google Calendar: Token Storage ──────────────────────────────────────

function getTokenFilePath() {
  return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.google-tokens');
}

function saveGoogleTokens(tokens) {
  try {
    const tokenDir = path.dirname(getTokenFilePath());
    if (!fs.existsSync(tokenDir)) {
      fs.mkdirSync(tokenDir, { recursive: true });
    }
    const encrypted = safeStorage.encryptString(JSON.stringify(tokens));
    fs.writeFileSync(getTokenFilePath(), encrypted);
    console.log('Google tokens saved');
  } catch (error) {
    console.error('Failed to save Google tokens:', error.message);
  }
}

function loadGoogleTokens() {
  try {
    const tokenPath = getTokenFilePath();
    if (!fs.existsSync(tokenPath)) return null;
    const encrypted = fs.readFileSync(tokenPath);
    const decrypted = safeStorage.decryptString(encrypted);
    return JSON.parse(decrypted);
  } catch (error) {
    console.error('Failed to load Google tokens:', error.message);
    return null;
  }
}

function deleteGoogleTokens() {
  try {
    const tokenPath = getTokenFilePath();
    if (fs.existsSync(tokenPath)) {
      fs.unlinkSync(tokenPath);
      console.log('Google tokens deleted');
    }
  } catch (error) {
    console.error('Failed to delete Google tokens:', error.message);
  }
}

// ── Outlook Calendar: Token Storage ─────────────────────────────────────

function getOutlookTokenFilePath() {
  return path.join(os.homedir(), 'Library', 'Application Support', 'stenoai', '.outlook-tokens');
}

function saveOutlookTokens(tokens) {
  try {
    const tokenDir = path.dirname(getOutlookTokenFilePath());
    if (!fs.existsSync(tokenDir)) {
      fs.mkdirSync(tokenDir, { recursive: true });
    }
    const encrypted = safeStorage.encryptString(JSON.stringify(tokens));
    fs.writeFileSync(getOutlookTokenFilePath(), encrypted);
    console.log('Outlook tokens saved');
  } catch (error) {
    console.error('Failed to save Outlook tokens:', error.message);
  }
}

function loadOutlookTokens() {
  try {
    const tokenPath = getOutlookTokenFilePath();
    if (!fs.existsSync(tokenPath)) return null;
    const encrypted = fs.readFileSync(tokenPath);
    const decrypted = safeStorage.decryptString(encrypted);
    return JSON.parse(decrypted);
  } catch (error) {
    console.error('Failed to load Outlook tokens:', error.message);
    return null;
  }
}

function deleteOutlookTokens() {
  try {
    const tokenPath = getOutlookTokenFilePath();
    if (fs.existsSync(tokenPath)) {
      fs.unlinkSync(tokenPath);
      console.log('Outlook tokens deleted');
    }
  } catch (error) {
    console.error('Failed to delete Outlook tokens:', error.message);
  }
}

// ── Google Calendar: OAuth2 Flow with PKCE ──────────────────────────────

function startGoogleAuth() {
  return new Promise((resolve, reject) => {
    // Generate PKCE code verifier and challenge
    const codeVerifier = crypto.randomBytes(32).toString('base64url');
    const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
    const state = crypto.randomBytes(16).toString('hex');
    let timeoutId = null;

    // Start temporary HTTP server on loopback for OAuth redirect
    const server = http.createServer(async (req, res) => {
      try {
        const reqUrl = new URL(req.url, `http://127.0.0.1`);
        if (!reqUrl.pathname.startsWith('/callback')) {
          res.writeHead(404);
          res.end();
          return;
        }

        const code = reqUrl.searchParams.get('code');
        const error = reqUrl.searchParams.get('error');
        const returnedState = reqUrl.searchParams.get('state');

        if (returnedState !== state) {
          res.writeHead(400, { 'Content-Type': 'text/html' });
          res.end('<html><body><h2>Invalid state parameter</h2><p>Possible CSRF attack. Please try again.</p></body></html>');
          return;
        }

        if (error) {
          res.writeHead(200, { 'Content-Type': 'text/html' });
          res.end('<html><body><h2>Authorization denied</h2><p>You can close this tab.</p></body></html>');
          server.close();
          if (timeoutId) clearTimeout(timeoutId);
          reject(new Error(`Auth denied: ${error}`));
          return;
        }

        if (!code) {
          res.writeHead(400, { 'Content-Type': 'text/html' });
          res.end('<html><body><h2>Missing authorization code</h2></body></html>');
          return;
        }

        // Exchange code for tokens
        const port = server.address().port;
        const tokens = await exchangeCodeForTokens(code, codeVerifier, port);
        saveGoogleTokens(tokens);

        res.writeHead(200, { 'Content-Type': 'text/html' });
        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>');

        server.close();
        if (timeoutId) clearTimeout(timeoutId);

        // Notify renderer and bring app to foreground
        if (mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('google-auth-changed');
          mainWindow.show();
          mainWindow.focus();
        }

        resolve({ success: true });
      } catch (err) {
        res.writeHead(500, { 'Content-Type': 'text/html' });
        res.end('<html><body><h2>Authentication failed</h2><p>Please try again.</p></body></html>');
        server.close();
        if (timeoutId) clearTimeout(timeoutId);
        reject(err);
      }
    });

    // Listen on loopback only (security: not 0.0.0.0)
    server.listen(0, '127.0.0.1', () => {
      const port = server.address().port;
      const redirectUri = `http://127.0.0.1:${port}/callback`;

      const authParams = new URLSearchParams({
        client_id: GOOGLE_CLIENT_ID,
        redirect_uri: redirectUri,
        response_type: 'code',
        scope: GOOGLE_SCOPES,
        access_type: 'offline',
        prompt: 'consent',
        state: state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256'
      });

      const authUrl = `${GOOGLE_AUTH_URL}?${authParams.toString()}`;
      shell.openExternal(authUrl);
    });

    timeoutId = setTimeout(() => {
      if (server.listening) {
        server.close();
        reject(new Error('OAuth timeout: no response within 5 minutes'));
      }
    }, 5 * 60 * 1000);
  });
}

function exchangeCodeForTokens(code, codeVerifier, port) {
  return new Promise((resolve, reject) => {
    const redirectUri = `http://127.0.0.1:${port}/callback`;
    const postData = new URLSearchParams({
      code,
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      redirect_uri: redirectUri,
      grant_type: 'authorization_code',
      code_verifier: codeVerifier
    }).toString();

    const options = {
      hostname: 'oauth2.googleapis.com',
      path: '/token',
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(postData)
      }
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        try {
          const parsed = JSON.parse(data);
          if (parsed.error) {
            reject(new Error(`Token exchange failed: ${parsed.error_description || parsed.error}`));
            return;
          }
          // Store expiry as absolute timestamp
          parsed.expires_at = Date.now() + (parsed.expires_in * 1000);
          resolve(parsed);
        } catch (err) {
          reject(new Error('Failed to parse token response'));
        }
      });
    });

    req.on('error', reject);
    req.write(postData);
    req.end();
  });
}

// ── Google Calendar: Token Refresh ──────────────────────────────────────

async function getValidAccessToken() {
  const tokens = loadGoogleTokens();
  if (!tokens) return null;

  // Check if token is expired or about to expire (5-min buffer)
  const bufferMs = 5 * 60 * 1000;
  if (tokens.expires_at && Date.now() < tokens.expires_at - bufferMs) {
    return tokens.access_token;
  }

  // Token expired, try to refresh
  if (!tokens.refresh_token) {
    deleteGoogleTokens();
    if (mainWindow && !mainWindow.isDestroyed()) {
      mainWindow.webContents.send('google-auth-changed');
    }
    return null;
  }

  try {
    const newTokens = await refreshAccessToken(tokens.refresh_token);
    // Preserve the refresh token (Google may not return it again)
    newTokens.refresh_token = newTokens.refresh_token || tokens.refresh_token;
    newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000);
    saveGoogleTokens(newTokens);
    return newTokens.access_token;
  } catch (error) {
    console.error('Token refresh failed:', error.message);
    if (error.message && (error.message.includes('invalid_grant') || error.message.includes('Token has been expired or revoked'))) {
      deleteGoogleTokens();
      if (mainWindow && !mainWindow.isDestroyed()) {
        mainWindow.webContents.send('google-auth-changed');
      }
    }
    return null;
  }
}

function refreshAccessToken(refreshToken) {
  return new Promise((resolve, reject) => {
    const postData = new URLSearchParams({
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      refresh_token: refreshToken,
      grant_type: 'refresh_token'
    }).toString();

    const options = {
      hostname: 'oauth2.googleapis.com',
      path: '/token',
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(postData)
      }
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        try {
          const parsed = JSON.parse(data);
          if (parsed.error) {
            reject(new Error(`Refresh failed: ${parsed.error_description || parsed.error}`));
            return;
          }
          resolve(parsed);
        } catch (err) {
          reject(new Error('Failed to parse refresh response'));
        }
      });
    });

    req.on('error', reject);
    req.write(postData);
    req.end();
  });
}

// ── Google Calendar: Fetch Events ───────────────────────────────────────

function fetchCalendarEvents(accessToken, maxResults = 7) {
  return new Promise((resolve, reject) => {
    const now = new Date();
    const weekAhead = new Date(now);
    weekAhead.setDate(weekAhead.getDate() + 7);
    const params = new URLSearchParams({
      timeMin: now.toISOString(),
      timeMax: weekAhead.toISOString(),
      maxResults: String(maxResults),
      singleEvents: 'true',
      orderBy: 'startTime',
      fields: 'items(id,summary,description,start,end,attendees,htmlLink,conferenceData)'
    });

    const options = {
      hostname: 'www.googleapis.com',
      path: `/calendar/v3/calendars/primary/events?${params.toString()}`,
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${accessToken}`
      }
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        try {
          const parsed = JSON.parse(data);
          if (parsed.error) {
            reject(new Error(`Calendar API error: ${parsed.error.message || parsed.error}`));
            return;
          }
          resolve(parsed.items || []);
        } catch (err) {
          reject(new Error('Failed to parse calendar response'));
        }
      });
    });

    req.on('error', reject);
    req.end();
  });
}

// ── Outlook Calendar: OAuth2 Flow with PKCE ─────────────────────────────

function startOutlookAuth() {
  return new Promise((resolve, reject) => {
    const codeVerifier = crypto.randomBytes(32).toString('base64url');
    const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
    const state = crypto.randomBytes(16).toString('hex');
    let timeoutId = null;

    const server = http.createServer(async (req, res) => {
      try {
        const reqUrl = new URL(req.url, `http://localhost`);
        // Ignore favicon and other noise — only handle the root path
        if (reqUrl.pathname !== '/') {
          res.writeHead(404);
          res.end();
          return;
        }

        const code = reqUrl.searchParams.get('code');
        const error = reqUrl.searchParams.get('error');
        const returnedState = reqUrl.searchParams.get('state');

        if (returnedState !== state) {
          res.writeHead(400, { 'Content-Type': 'text/html' });
          res.end('<html><body><h2>Invalid state parameter</h2><p>Possible CSRF attack. Please try again.</p></body></html>');
          return;
        }

        if (error) {
          res.writeHead(200, { 'Content-Type': 'text/html' });
          res.end('<html><body><h2>Authorization denied</h2><p>You can close this tab.</p></body></html>');
          server.close();
          if (timeoutId) clearTimeout(timeoutId);
          reject(new Error(`Auth denied: ${error}`));
          return;
        }

        if (!code) {
          res.writeHead(400, { 'Content-Type': 'text/html' });
          res.end('<html><body><h2>Missing authorization code</h2></body></html>');
          return;
        }

        const port = server.address().port;
        const tokens = await exchangeOutlookCodeForTokens(code, codeVerifier, port);
        saveOutlookTokens(tokens);

        res.writeHead(200, { 'Content-Type': 'text/html' });
        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>');

        server.close();
        if (timeoutId) clearTimeout(timeoutId);

        if (mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('outlook-auth-changed');
          mainWindow.show();
          mainWindow.focus();
        }

        resolve({ success: true });
      } catch (err) {
        res.writeHead(500, { 'Content-Type': 'text/html' });
        res.end('<html><body><h2>Authentication failed</h2><p>Please try again.</p></body></html>');
        server.close();
        if (timeoutId) clearTimeout(timeoutId);
        reject(err);
      }
    });

    server.listen(0, '127.0.0.1', () => {
      const port = server.address().port;
      const redirectUri = `http://localhost:${port}`;

      const authParams = new URLSearchParams({
        client_id: OUTLOOK_CLIENT_ID,
        redirect_uri: redirectUri,
        response_type: 'code',
        scope: OUTLOOK_SCOPES,
        response_mode: 'query',
        state: state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256'
      });

      const authUrl = `${OUTLOOK_AUTH_URL}?${authParams.toString()}`;
      shell.openExternal(authUrl);
    });

    timeoutId = setTimeout(() => {
      if (server.listening) {
        server.close();
        reject(new Error('OAuth timeout: no response within 5 minutes'));
      }
    }, 5 * 60 * 1000);
  });
}

function exchangeOutlookCodeForTokens(code, codeVerifier, port) {
  return new Promise((resolve, reject) => {
    const redirectUri = `http://localhost:${port}`;
    const postData = new URLSearchParams({
      code,
      client_id: OUTLOOK_CLIENT_ID,
      redirect_uri: redirectUri,
      grant_type: 'authorization_code',
      code_verifier: codeVerifier
    }).toString();

    const tokenUrl = new URL(OUTLOOK_TOKEN_URL);
    const options = {
      hostname: tokenUrl.hostname,
      path: tokenUrl.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(postData)
      }
    };

    const req = https.request(options, (res) => {
      let dat
Download .txt
gitextract_90ku06c9/

├── .clabot
├── .github/
│   ├── FUNDING.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── build-release.yml
│       └── deploy-website.yml
├── .gitignore
├── CLA.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── announcements.json
├── app/
│   ├── build/
│   │   ├── entitlements.mac.plist
│   │   ├── icon-dragonfly.icns
│   │   └── icon.icns
│   ├── electron-builder.ci.yml
│   ├── main.js
│   ├── package-lock.json
│   ├── package.json
│   ├── preload.js
│   ├── renderer/
│   │   ├── .eslintrc.cjs
│   │   ├── .prettierrc.json
│   │   ├── components.json
│   │   ├── index.html
│   │   ├── postcss.config.cjs
│   │   ├── src/
│   │   │   ├── App.tsx
│   │   │   ├── components/
│   │   │   │   ├── AppShell.tsx
│   │   │   │   ├── AskBar.tsx
│   │   │   │   ├── AudioWave.tsx
│   │   │   │   ├── BottomDockSlot.tsx
│   │   │   │   ├── ChatHistoryRow.tsx
│   │   │   │   ├── FolderScopePicker.tsx
│   │   │   │   ├── IconPicker.tsx
│   │   │   │   ├── LiveDock.tsx
│   │   │   │   ├── MainToolbar.tsx
│   │   │   │   ├── MeetingsShell.tsx
│   │   │   │   ├── QuitDialog.tsx
│   │   │   │   ├── Sidebar.tsx
│   │   │   │   ├── TranscriptPanel.tsx
│   │   │   │   ├── home/
│   │   │   │   │   ├── PreviousRow.tsx
│   │   │   │   │   └── UpcomingCard.tsx
│   │   │   │   └── ui/
│   │   │   │       ├── app-icon.tsx
│   │   │   │       ├── button.tsx
│   │   │   │       ├── card.tsx
│   │   │   │       ├── chip.tsx
│   │   │   │       ├── confirm-dialog.tsx
│   │   │   │       ├── dialog.tsx
│   │   │   │       ├── input.tsx
│   │   │   │       ├── kbd.tsx
│   │   │   │       ├── popover.tsx
│   │   │   │       ├── row.tsx
│   │   │   │       ├── select.tsx
│   │   │   │       ├── switch.tsx
│   │   │   │       ├── tabs.tsx
│   │   │   │       ├── tooltip.tsx
│   │   │   │       └── typography.tsx
│   │   │   ├── globals.css
│   │   │   ├── hooks/
│   │   │   │   ├── index.ts
│   │   │   │   ├── liveDraftStore.ts
│   │   │   │   ├── meetingKeys.ts
│   │   │   │   ├── useAi.ts
│   │   │   │   ├── useAiPrompts.ts
│   │   │   │   ├── useAudioLevel.ts
│   │   │   │   ├── useCalendarEvents.ts
│   │   │   │   ├── useChatSessions.ts
│   │   │   │   ├── useFolders.ts
│   │   │   │   ├── useLiveMeeting.ts
│   │   │   │   ├── useMeetings.ts
│   │   │   │   ├── useModels.ts
│   │   │   │   ├── useRecording.ts
│   │   │   │   ├── useSettings.ts
│   │   │   │   ├── useSetup.ts
│   │   │   │   ├── useStreamingQuery.ts
│   │   │   │   └── useTheme.ts
│   │   │   ├── lib/
│   │   │   │   ├── askBarContext.tsx
│   │   │   │   ├── chat.ts
│   │   │   │   ├── chatPresets.tsx
│   │   │   │   ├── debugLogs.ts
│   │   │   │   ├── ipc.ts
│   │   │   │   ├── markdown.tsx
│   │   │   │   ├── meetingDetailState.ts
│   │   │   │   ├── meetingsListContext.tsx
│   │   │   │   ├── queryClient.ts
│   │   │   │   ├── result.ts
│   │   │   │   ├── router.ts
│   │   │   │   └── utils.ts
│   │   │   ├── main.tsx
│   │   │   └── routes/
│   │   │       ├── Chat.tsx
│   │   │       ├── ChatConversation.tsx
│   │   │       ├── FolderDetail.tsx
│   │   │       ├── Home.tsx
│   │   │       ├── MeetingDetail.tsx
│   │   │       ├── Processing.tsx
│   │   │       ├── Recording.tsx
│   │   │       ├── Sandbox.tsx
│   │   │       ├── Settings.tsx
│   │   │       └── Setup.tsx
│   │   ├── tailwind.config.cjs
│   │   └── tsconfig.json
│   └── vite.config.ts
├── prompt_tests/
│   ├── PROMPT_TESTING.md
│   └── test_prompts.py
├── requirements.txt
├── scripts/
│   ├── build-backend.sh
│   ├── download-ollama.sh
│   ├── test_dmg_fresh_install.sh
│   └── test_first_time_setup.sh
├── setup.py
├── simple_recorder.py
├── src/
│   ├── __init__.py
│   ├── audio_recorder.py
│   ├── config.py
│   ├── folders.py
│   ├── models.py
│   ├── ollama_manager.py
│   ├── summarizer.py
│   └── transcriber.py
├── tests/
│   ├── __init__.py
│   ├── test_config.py
│   └── test_transcriber.py
└── website/
    ├── .gitignore
    ├── README.md
    ├── eslint.config.js
    ├── index.html
    ├── package.json
    ├── postcss.config.js
    ├── public/
    │   ├── CNAME
    │   ├── privacy.html
    │   └── terms.html
    ├── src/
    │   ├── App.jsx
    │   ├── analytics.js
    │   ├── components/
    │   │   ├── Brand.jsx
    │   │   └── ThemeToggle.jsx
    │   ├── index.css
    │   ├── main.jsx
    │   └── sections/
    │       ├── CTAFooter.jsx
    │       ├── FAQ.jsx
    │       ├── Features.jsx
    │       ├── Footer.jsx
    │       ├── Hero.jsx
    │       ├── HowItWorks.jsx
    │       ├── Industries.jsx
    │       ├── Models.jsx
    │       ├── Nav.jsx
    │       └── TrustStrip.jsx
    ├── tailwind.config.js
    └── vite.config.js
Download .txt
SYMBOL INDEX (767 symbols across 91 files)

FILE: app/main.js
  constant IS_E2E (line 25) | const IS_E2E = process.env.STENOAI_E2E === '1';
  constant IS_E2E_MOCK_IPC (line 26) | const IS_E2E_MOCK_IPC = process.env.STENOAI_E2E_MOCK_IPC === '1';
  constant SHORTCUT_PROTOCOL (line 46) | const SHORTCUT_PROTOCOL = 'stenoai';
  constant SHORTCUT_HOST (line 47) | const SHORTCUT_HOST = 'record';
  constant SHORTCUT_SESSION_NAME_MAX_LENGTH (line 48) | const SHORTCUT_SESSION_NAME_MAX_LENGTH = 120;
  function extractShortcutUrlFromArgv (line 51) | function extractShortcutUrlFromArgv(argv = []) {
  function sanitizeShortcutUrlForLogs (line 55) | function sanitizeShortcutUrlForLogs(incomingUrl) {
  function sanitizeShortcutSessionName (line 64) | function sanitizeShortcutSessionName(rawValue) {
  function registerShortcutProtocolClient (line 80) | function registerShortcutProtocolClient() {
  function getBackendPath (line 98) | function getBackendPath() {
  function getBackendCwd (line 108) | function getBackendCwd() {
  function parseShortcutUrl (line 116) | function parseShortcutUrl(incomingUrl) {
  function ensureMainWindow (line 146) | function ensureMainWindow() {
  function dispatchShortcutAction (line 159) | function dispatchShortcutAction(action) {
  function flushShortcutQueue (line 181) | function flushShortcutQueue() {
  function enqueueShortcutAction (line 196) | function enqueueShortcutAction(action) {
  function shouldShowShortcutNotifications (line 205) | async function shouldShowShortcutNotifications() {
  function showShortcutNotification (line 217) | async function showShortcutNotification(body) {
  constant BACKEND_STATUS_RETRY_ATTEMPTS (line 237) | const BACKEND_STATUS_RETRY_ATTEMPTS = 3;
  constant BACKEND_STATUS_RETRY_DELAY_MS (line 238) | const BACKEND_STATUS_RETRY_DELAY_MS = 250;
  function wait (line 240) | function wait(ms) {
  function isBackendRecording (line 244) | async function isBackendRecording() {
  function handleShortcutUrl (line 266) | async function handleShortcutUrl(incomingUrl) {
  constant POSTHOG_API_KEY (line 317) | const POSTHOG_API_KEY = 'phc_U2cnTyIyKGNSVaK18FyBMltd8nmN7uHxhhm21fAHwqb';
  constant POSTHOG_HOST (line 318) | const POSTHOG_HOST = 'https://us.i.posthog.com';
  constant GOOGLE_CLIENT_ID (line 321) | const GOOGLE_CLIENT_ID = '281073275073-20da4u5t9luk2366vd5ai0a2r55d5pf5....
  constant GOOGLE_CLIENT_SECRET (line 322) | const GOOGLE_CLIENT_SECRET = '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';
  constant GOOGLE_SCOPES (line 323) | const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar.readonly';
  constant GOOGLE_AUTH_URL (line 324) | const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
  constant GOOGLE_TOKEN_URL (line 325) | const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
  constant OUTLOOK_CLIENT_ID (line 328) | const OUTLOOK_CLIENT_ID = '53a8ba1f-3a2e-4fc9-afb1-b9b8ff13de19';
  constant OUTLOOK_SCOPES (line 329) | const OUTLOOK_SCOPES = 'Calendars.Read offline_access';
  constant OUTLOOK_AUTH_URL (line 330) | const OUTLOOK_AUTH_URL = 'https://login.microsoftonline.com/common/oauth...
  constant OUTLOOK_TOKEN_URL (line 331) | const OUTLOOK_TOKEN_URL = 'https://login.microsoftonline.com/common/oaut...
  function durationBucket (line 336) | function durationBucket(seconds) {
  function initTelemetry (line 348) | async function initTelemetry() {
  function trackEvent (line 394) | function trackEvent(eventName, properties = {}) {
  function shutdownTelemetry (line 419) | async function shutdownTelemetry() {
  function getAllowedBaseDirs (line 435) | function getAllowedBaseDirs() {
  function validateSafeFilePath (line 452) | function validateSafeFilePath(filepath, allowedBaseDirs) {
  function createWindow (line 474) | function createWindow(options = {}) {
  function getTrayIconPath (line 551) | function getTrayIconPath(recording) {
  function createTray (line 559) | function createTray() {
  function updateTrayIcon (line 568) | function updateTrayIcon(recording) {
  function showAndFocusWindow (line 577) | function showAndFocusWindow() {
  function updateTrayMenu (line 584) | function updateTrayMenu() {
  function showCustomQuitDialog (line 668) | async function showCustomQuitDialog(type, jobCount) {
  function runPythonScript (line 945) | function runPythonScript(script, args = [], silent = false, extraEnv = {...
  function getBackendStatusInternal (line 1005) | async function getBackendStatusInternal(silent = true) {
  function handleGetStatus (line 1010) | async function handleGetStatus() {
  function handleGetNotifications (line 1018) | async function handleGetNotifications() {
  constant CHAT_SESSIONS_V2_FILENAME (line 1511) | const CHAT_SESSIONS_V2_FILENAME = 'chat_sessions_v2.json';
  constant CHAT_SESSIONS_LEGACY_FILENAME (line 1512) | const CHAT_SESSIONS_LEGACY_FILENAME = 'chat_sessions.json';
  function chatSessionsV2Path (line 1514) | function chatSessionsV2Path() {
  function chatSessionsLegacyPath (line 1518) | function chatSessionsLegacyPath() {
  function resetRecordingRuntimeState (line 1849) | function resetRecordingRuntimeState() {
  function startRecordingRuntimeState (line 1858) | function startRecordingRuntimeState() {
  function markRecordingPaused (line 1867) | function markRecordingPaused() {
  function markRecordingResumed (line 1875) | function markRecordingResumed() {
  function getRecordingElapsedSeconds (line 1886) | function getRecordingElapsedSeconds() {
  function processNextInQueue (line 1903) | async function processNextInQueue() {
  function addToProcessingQueue (line 2021) | function addToProcessingQueue(audioFile, sessionName, notesFile) {
  function setupAutoUpdater (line 2656) | function setupAutoUpdater() {
  function sendDebugLog (line 2723) | function sendDebugLog(message) {
  function ensureOllamaRunning (line 3247) | async function ensureOllamaRunning() {
  function getCloudKeyPath (line 3594) | function getCloudKeyPath() {
  function saveCloudApiKey (line 3598) | function saveCloudApiKey(key) {
  function loadCloudApiKey (line 3613) | function loadCloudApiKey() {
  function hasCloudApiKey (line 3625) | function hasCloudApiKey() {
  function getOllamaEnv (line 3897) | function getOllamaEnv() {
  function findOllamaExecutable (line 3912) | async function findOllamaExecutable() {
  function checkForUpdates (line 3932) | async function checkForUpdates() {
  function compareVersions (line 3995) | function compareVersions(current, latest) {
  function getDownloadUrl (line 4010) | function getDownloadUrl(assets) {
  function getTokenFilePath (line 4151) | function getTokenFilePath() {
  function saveGoogleTokens (line 4155) | function saveGoogleTokens(tokens) {
  function loadGoogleTokens (line 4169) | function loadGoogleTokens() {
  function deleteGoogleTokens (line 4182) | function deleteGoogleTokens() {
  function getOutlookTokenFilePath (line 4196) | function getOutlookTokenFilePath() {
  function saveOutlookTokens (line 4200) | function saveOutlookTokens(tokens) {
  function loadOutlookTokens (line 4214) | function loadOutlookTokens() {
  function deleteOutlookTokens (line 4227) | function deleteOutlookTokens() {
  function startGoogleAuth (line 4241) | function startGoogleAuth() {
  function exchangeCodeForTokens (line 4342) | function exchangeCodeForTokens(code, codeVerifier, port) {
  function getValidAccessToken (line 4391) | async function getValidAccessToken() {
  function refreshAccessToken (line 4429) | function refreshAccessToken(refreshToken) {
  function fetchCalendarEvents (line 4473) | function fetchCalendarEvents(accessToken, maxResults = 7) {
  function startOutlookAuth (line 4520) | function startOutlookAuth() {
  function exchangeOutlookCodeForTokens (line 4616) | function exchangeOutlookCodeForTokens(code, codeVerifier, port) {
  function getValidOutlookAccessToken (line 4664) | async function getValidOutlookAccessToken() {
  function refreshOutlookAccessToken (line 4701) | function refreshOutlookAccessToken(refreshToken) {
  function fetchOutlookCalendarEvents (line 4746) | function fetchOutlookCalendarEvents(accessToken, maxResults = 7) {
  function normalizeOutlookEvent (line 4793) | function normalizeOutlookEvent(event) {
  function normalizeCalendarEvent (line 4898) | function normalizeCalendarEvent(event) {

FILE: app/preload.js
  constant VERSION (line 12) | const VERSION = 1;

FILE: app/renderer/src/App.tsx
  function App (line 27) | function App() {
  function RouteView (line 131) | function RouteView({ route }: { route: string }) {
  function safeDecode (line 155) | function safeDecode(s: string): string {

FILE: app/renderer/src/components/AppShell.tsx
  type AppShellProps (line 6) | interface AppShellProps {
  function AppShell (line 30) | function AppShell({

FILE: app/renderer/src/components/AskBar.tsx
  function TranscriptBar (line 27) | function TranscriptBar() {
  function AskBar (line 85) | function AskBar() {
  type ChatHeaderProps (line 380) | interface ChatHeaderProps {
  function ChatHeader (line 393) | function ChatHeader({
  type SessionDropdownProps (line 463) | interface SessionDropdownProps {
  function SessionDropdown (line 470) | function SessionDropdown({ sessions, activeId, onPick, onDelete }: Sessi...
  type MessageListProps (line 520) | interface MessageListProps {
  function MessageList (line 526) | function MessageList({ messages, liveText, streaming }: MessageListProps) {
  function MessageBubble (line 553) | function MessageBubble({ message }: { message: ChatMessage }) {
  constant SUGGESTION_CHIPS (line 579) | const SUGGESTION_CHIPS: { label: string; prompt: string }[] = [
  function deriveSessionName (line 585) | function deriveSessionName(prompt: string): string {

FILE: app/renderer/src/components/AudioWave.tsx
  type AudioWaveProps (line 3) | interface AudioWaveProps {
  function AudioWave (line 25) | function AudioWave({

FILE: app/renderer/src/components/BottomDockSlot.tsx
  type BottomDockSlotProps (line 4) | interface BottomDockSlotProps {
  function BottomDockSlot (line 16) | function BottomDockSlot({ children, bottomOffset = 0 }: BottomDockSlotPr...

FILE: app/renderer/src/components/ChatHistoryRow.tsx
  type ChatHistoryRowSession (line 12) | interface ChatHistoryRowSession {
  type ChatHistoryRowProps (line 18) | interface ChatHistoryRowProps {
  function ChatHistoryRow (line 41) | function ChatHistoryRow({

FILE: app/renderer/src/components/FolderScopePicker.tsx
  type FolderScopePickerProps (line 11) | interface FolderScopePickerProps {
  function FolderScopePicker (line 23) | function FolderScopePicker({ value, onChange }: FolderScopePickerProps) {

FILE: app/renderer/src/components/IconPicker.tsx
  constant ICON_LIST (line 10) | const ICON_LIST = [
  constant ICONS (line 139) | const ICONS = Array.from(new Map(ICON_LIST.map((i) => [i.name, i])).valu...
  function toPascalCase (line 146) | function toPascalCase(name: string): string {
  function LucideIcon (line 154) | function LucideIcon({
  constant PANEL_W (line 176) | const PANEL_W = 276;
  constant PANEL_MAX_H (line 177) | const PANEL_MAX_H = 320;
  constant SEARCH_H (line 178) | const SEARCH_H = 52;
  constant GAP (line 179) | const GAP = 6;
  type IconPickerProps (line 181) | interface IconPickerProps {
  function IconPicker (line 187) | function IconPicker({ anchorRect, onSelect, onClose }: IconPickerProps) {
  function IconButton (line 353) | function IconButton({ name, onSelect }: { name: string; onSelect: () => ...

FILE: app/renderer/src/components/LiveDock.tsx
  function LiveDock (line 10) | function LiveDock() {
  function RecordingPill (line 68) | function RecordingPill({
  function formatElapsed (line 105) | function formatElapsed(seconds: number): string {

FILE: app/renderer/src/components/MainToolbar.tsx
  type MainToolbarProps (line 20) | interface MainToolbarProps {
  function MainToolbar (line 28) | function MainToolbar({
  function RecordingOptionsPopover (line 170) | function RecordingOptionsPopover() {
  function formatElapsed (line 229) | function formatElapsed(seconds: number): string {

FILE: app/renderer/src/components/MeetingsShell.tsx
  type MeetingsShellProps (line 37) | interface MeetingsShellProps {
  function MeetingsShell (line 55) | function MeetingsShell({
  type ContextMenuProps (line 323) | interface ContextMenuProps {
  function ContextMenu (line 332) | function ContextMenu({
  function RenamePopover (line 386) | function RenamePopover({
  type BuildArgs (line 453) | interface BuildArgs {
  function buildSidebar (line 460) | function buildSidebar({ meetings, folders, search, activeSummaryFile }: ...
  function meetingToSidebar (line 490) | function meetingToSidebar(meeting: Meeting, activeSummaryFile: string | ...
  function formatDateLabel (line 499) | function formatDateLabel(info: Meeting['session_info']): string | undefi...

FILE: app/renderer/src/components/QuitDialog.tsx
  type DialogState (line 6) | interface DialogState {
  function QuitDialog (line 11) | function QuitDialog() {
  function CancelButton (line 114) | function CancelButton({ onClick }: { onClick: () => void }) {
  function ConfirmButton (line 141) | function ConfirmButton({ onClick, label }: { onClick: () => void; label:...

FILE: app/renderer/src/components/Sidebar.tsx
  type SidebarMeeting (line 16) | interface SidebarMeeting {
  type SidebarFolder (line 24) | interface SidebarFolder {
  type SidebarContextAction (line 34) | interface SidebarContextAction {
  constant COLLAPSED_KEY (line 43) | const COLLAPSED_KEY = 'steno-sidebar-collapsed';
  constant WIDTH_KEY (line 44) | const WIDTH_KEY = 'steno-sidebar-width';
  constant DEFAULT_WIDTH (line 45) | const DEFAULT_WIDTH = 270;
  constant MIN_WIDTH (line 46) | const MIN_WIDTH = 220;
  constant MAX_WIDTH (line 47) | const MAX_WIDTH = 480;
  type Listener (line 53) | type Listener = () => void;
  function useSidebarCollapsed (line 107) | function useSidebarCollapsed() {
  function useSidebarWidth (line 119) | function useSidebarWidth() {
  type SidebarProps (line 129) | interface SidebarProps {
  function Sidebar (line 144) | function Sidebar({

FILE: app/renderer/src/components/TranscriptPanel.tsx
  type Segment (line 9) | interface Segment {
  function TranscriptPanelContent (line 15) | function TranscriptPanelContent({
  function TranscriptBody (line 33) | function TranscriptBody({ meeting }: { meeting: Meeting }) {
  function TranscriptRow (line 99) | function TranscriptRow({ segment, highlight }: { segment: Segment; highl...
  function renderHighlighted (line 127) | function renderHighlighted(text: string, highlight: string): React.React...
  function parseTranscript (line 153) | function parseTranscript(meeting: Meeting): Segment[] {

FILE: app/renderer/src/components/home/PreviousRow.tsx
  type PreviousRowProps (line 6) | interface PreviousRowProps {
  function PreviousRow (line 11) | function PreviousRow({ meeting, folderName }: PreviousRowProps) {
  function LiveBadge (line 115) | function LiveBadge() {
  function ProcessingBadge (line 134) | function ProcessingBadge() {
  function formatTime (line 150) | function formatTime(iso?: string): string | undefined {
  function formatDuration (line 158) | function formatDuration(seconds?: number): string | undefined {
  function previewText (line 168) | function previewText(meeting: Meeting): string | undefined {

FILE: app/renderer/src/components/home/UpcomingCard.tsx
  type UpcomingCardProps (line 8) | interface UpcomingCardProps {
  function UpcomingCard (line 12) | function UpcomingCard({ event }: UpcomingCardProps) {
  function relativeLabel (line 148) | function relativeLabel(startIso: string): { prefix: string | null; value...
  function formatStartEnd (line 161) | function formatStartEnd(startIso: string, endIso: string) {

FILE: app/renderer/src/components/ui/app-icon.tsx
  type AppIconProps (line 3) | interface AppIconProps {
  function AppIcon (line 8) | function AppIcon({ size = 80, className }: AppIconProps) {

FILE: app/renderer/src/components/ui/button.tsx
  type ButtonProps (line 35) | interface ButtonProps

FILE: app/renderer/src/components/ui/card.tsx
  type CardProps (line 19) | interface CardProps

FILE: app/renderer/src/components/ui/chip.tsx
  type ChipProps (line 26) | interface ChipProps

FILE: app/renderer/src/components/ui/confirm-dialog.tsx
  type ConfirmDialogProps (line 13) | interface ConfirmDialogProps {
  function ConfirmDialog (line 25) | function ConfirmDialog({

FILE: app/renderer/src/components/ui/dialog.tsx
  function DialogHeader (line 50) | function DialogHeader({ className, ...props }: React.HTMLAttributes<HTML...
  function DialogFooter (line 54) | function DialogFooter({ className, ...props }: React.HTMLAttributes<HTML...

FILE: app/renderer/src/components/ui/input.tsx
  type Size (line 25) | type Size = NonNullable<VariantProps<typeof inputVariants>['size']>;
  type Variant (line 26) | type Variant = NonNullable<VariantProps<typeof inputVariants>['variant']>;
  type InputProps (line 28) | interface InputProps
  type TextareaProps (line 74) | interface TextareaProps

FILE: app/renderer/src/components/ui/kbd.tsx
  function KbdKey (line 4) | function KbdKey({ className, children }: { className?: string; children:...

FILE: app/renderer/src/components/ui/row.tsx
  type RowProps (line 28) | interface RowProps

FILE: app/renderer/src/components/ui/tooltip.tsx
  function TooltipProvider (line 5) | function TooltipProvider({ delayDuration = 120, ...props }: React.Compon...

FILE: app/renderer/src/components/ui/typography.tsx
  type HProps (line 4) | type HProps = React.HTMLAttributes<HTMLHeadingElement>;
  constant DISPLAY_VAR (line 8) | const DISPLAY_VAR = "'opsz' 144, 'SOFT' 30";
  constant H2_VAR (line 9) | const H2_VAR = "'opsz' 96";
  function Display (line 11) | function Display({ className, style, ...props }: HProps) {
  function H1 (line 24) | function H1({ className, style, ...props }: HProps) {
  function H2 (line 37) | function H2({ className, style, ...props }: HProps) {
  function H3 (line 50) | function H3({ className, ...props }: HProps) {
  function Lead (line 59) | function Lead({
  function Muted (line 71) | function Muted({

FILE: app/renderer/src/hooks/liveDraftStore.ts
  type DraftEntry (line 13) | interface DraftEntry {
  type LiveDraftStore (line 19) | interface LiveDraftStore {
  function getLiveDraft (line 61) | function getLiveDraft(sessionName: string): DraftEntry | undefined {

FILE: app/renderer/src/hooks/useAi.ts
  function useAiProvider (line 12) | function useAiProvider() {
  function useSetAiProvider (line 19) | function useSetAiProvider() {
  function useSetRemoteOllamaUrl (line 42) | function useSetRemoteOllamaUrl() {
  function useTestRemoteOllama (line 50) | function useTestRemoteOllama() {
  function useSetCloudApiUrl (line 56) | function useSetCloudApiUrl() {
  function useSetCloudApiKey (line 64) | function useSetCloudApiKey() {
  function useSetCloudProvider (line 72) | function useSetCloudProvider() {
  function useSetCloudModel (line 80) | function useSetCloudModel() {
  function useTestCloudApi (line 88) | function useTestCloudApi() {

FILE: app/renderer/src/hooks/useAiPrompts.ts
  function useAiPrompts (line 5) | function useAiPrompts() {

FILE: app/renderer/src/hooks/useAudioLevel.ts
  type UseAudioLevelOptions (line 3) | interface UseAudioLevelOptions {
  function useAudioLevel (line 23) | function useAudioLevel({

FILE: app/renderer/src/hooks/useCalendarEvents.ts
  type CalendarState (line 6) | type CalendarState =
  function useCalendarEvents (line 17) | function useCalendarEvents() {
  function useGoogleCalendarAuth (line 43) | function useGoogleCalendarAuth() {
  function useOutlookCalendarAuth (line 64) | function useOutlookCalendarAuth() {

FILE: app/renderer/src/hooks/useChatSessions.ts
  type ChatSession (line 5) | type ChatSession = ChatSessionsBlob['sessions'][number];
  type ChatMessage (line 6) | type ChatMessage = ChatSession['messages'][number];
  function newSessionId (line 8) | function newSessionId() {
  function emptyBlob (line 12) | function emptyBlob(): ChatSessionsBlob {
  constant CHAT_KEY (line 16) | const CHAT_KEY = ['chat-sessions'] as const;
  type LegacyMessage (line 20) | type LegacyMessage = { role: 'user' | 'ai'; content: string };
  type LegacySession (line 21) | type LegacySession = { id: string; title: string; messages: LegacyMessag...
  type LegacyBlob (line 22) | type LegacyBlob = [meetingName: string, sessions: LegacySession[]][];
  function migrateLegacyBlob (line 24) | function migrateLegacyBlob(legacy: LegacyBlob): ChatSessionsBlob {
  function useAllChatSessions (line 50) | function useAllChatSessions() {
  function useChatSessions (line 66) | function useChatSessions(summaryFile: string | null, meetingName?: strin...

FILE: app/renderer/src/hooks/useFolders.ts
  function useFolders (line 11) | function useFolders() {
  function useCreateFolder (line 18) | function useCreateFolder() {
  function useRenameFolder (line 27) | function useRenameFolder() {
  function useUpdateFolderIcon (line 36) | function useUpdateFolderIcon() {
  function useDeleteFolder (line 66) | function useDeleteFolder() {
  function useReorderFolders (line 74) | function useReorderFolders() {
  function patchMeetingFolders (line 82) | function patchMeetingFolders(
  function restoreMeetingsSnapshot (line 97) | function restoreMeetingsSnapshot(
  function useAddMeetingToFolder (line 104) | function useAddMeetingToFolder() {
  function useRemoveMeetingFromFolder (line 124) | function useRemoveMeetingFromFolder() {

FILE: app/renderer/src/hooks/useLiveMeeting.ts
  function useLiveMeeting (line 6) | function useLiveMeeting() {

FILE: app/renderer/src/hooks/useMeetings.ts
  constant LIVE_SUMMARY_PREFIX (line 13) | const LIVE_SUMMARY_PREFIX = '__live__/';
  function useMeetings (line 15) | function useMeetings() {
  function buildLiveMeeting (line 75) | function buildLiveMeeting(
  function useMeeting (line 102) | function useMeeting(summaryFile: string | null | undefined) {
  function useTranscript (line 114) | function useTranscript(summaryFile: string | null | undefined) {
  function useUpdateMeeting (line 119) | function useUpdateMeeting() {
  function useDeleteMeeting (line 128) | function useDeleteMeeting() {
  function useReprocessMeeting (line 136) | function useReprocessMeeting() {
  function useSaveMeetingNotes (line 145) | function useSaveMeetingNotes() {

FILE: app/renderer/src/hooks/useModels.ts
  function parseSizeGb (line 13) | function parseSizeGb(size?: string): number | undefined {
  function useModels (line 25) | function useModels() {
  function useCurrentModel (line 46) | function useCurrentModel() {
  function useOllamaStatus (line 53) | function useOllamaStatus() {
  function useSetCurrentModel (line 60) | function useSetCurrentModel() {
  function usePullModel (line 77) | function usePullModel() {

FILE: app/renderer/src/hooks/useRecording.ts
  type RecordingStatus (line 9) | type RecordingStatus = 'idle' | 'recording' | 'paused' | 'processing';
  function useRecording (line 13) | function useRecording() {
  function useRecordingEvents (line 126) | function useRecordingEvents() {
  function useRecordingProcessingEffects (line 161) | function useRecordingProcessingEffects() {

FILE: app/renderer/src/hooks/useSettings.ts
  function useNotificationsSetting (line 17) | function useNotificationsSetting() {
  function useSetNotifications (line 24) | function useSetNotifications() {
  function useTelemetrySetting (line 32) | function useTelemetrySetting() {
  function useSetTelemetry (line 39) | function useSetTelemetry() {
  function useDockIconSetting (line 47) | function useDockIconSetting() {
  function useSetDockIcon (line 54) | function useSetDockIcon() {
  function useSystemAudioSetting (line 62) | function useSystemAudioSetting() {
  function useSetSystemAudio (line 69) | function useSetSystemAudio() {
  function useLanguageSetting (line 77) | function useLanguageSetting() {
  function useSetLanguage (line 84) | function useSetLanguage() {
  function useStoragePath (line 92) | function useStoragePath() {
  function useSetStoragePath (line 99) | function useSetStoragePath() {
  function usePickStorageFolder (line 107) | function usePickStorageFolder() {
  function useAppVersion (line 113) | function useAppVersion() {
  function useClearSystemState (line 121) | function useClearSystemState() {
  constant USER_NAME_CACHE_KEY (line 130) | const USER_NAME_CACHE_KEY = 'steno-user-name';
  function readCachedUserName (line 132) | function readCachedUserName(): string {
  function writeCachedUserName (line 140) | function writeCachedUserName(name: string) {
  function useUserName (line 148) | function useUserName() {
  function useSetUserName (line 168) | function useSetUserName() {

FILE: app/renderer/src/hooks/useSetup.ts
  function useSetupCheck (line 11) | function useSetupCheck() {
  function useSetupStep (line 18) | function useSetupStep(name: 'systemCheck' | 'ffmpeg' | 'python' | 'ollam...
  function useCheckMicPermission (line 28) | function useCheckMicPermission() {
  function useRequestMicPermission (line 34) | function useRequestMicPermission() {
  function useDebugLog (line 40) | function useDebugLog() {

FILE: app/renderer/src/hooks/useStreamingQuery.ts
  type StreamStatus (line 4) | type StreamStatus = 'streaming' | 'done' | 'error';
  type StreamState (line 6) | interface StreamState {
  function newId (line 12) | function newId() {
  function useStreamingQuery (line 16) | function useStreamingQuery() {
  type StreamingQueryApi (line 155) | type StreamingQueryApi = ReturnType<typeof useStreamingQuery>;
  function StreamingProvider (line 163) | function StreamingProvider({ children }: { children: React.ReactNode }) {
  function useGlobalStreaming (line 168) | function useGlobalStreaming(): StreamingQueryApi {

FILE: app/renderer/src/hooks/useTheme.ts
  type Theme (line 3) | type Theme = 'light' | 'dark' | 'system';
  constant STORAGE_KEY (line 5) | const STORAGE_KEY = 'steno-theme';
  function systemPrefersDark (line 7) | function systemPrefersDark(): boolean {
  function readStoredTheme (line 14) | function readStoredTheme(): Theme {
  function applyResolved (line 22) | function applyResolved(resolved: 'light' | 'dark') {
  function useTheme (line 30) | function useTheme() {

FILE: app/renderer/src/lib/askBarContext.tsx
  type AskBarContextValue (line 3) | interface AskBarContextValue {
  function AskBarProvider (line 13) | function AskBarProvider({ children }: { children: React.ReactNode }) {
  function useAskBar (line 46) | function useAskBar(): AskBarContextValue {
  function useActiveMeeting (line 52) | function useActiveMeeting(

FILE: app/renderer/src/lib/chat.ts
  constant GLOBAL_SCOPE (line 7) | const GLOBAL_SCOPE = '__global__';
  function deriveSessionName (line 9) | function deriveSessionName(question: string): string {
  function bucketKey (line 19) | function bucketKey(ts: number, now: number = Date.now()): string {
  function toBucketLabel (line 50) | function toBucketLabel(key: string): string {
  function relativeTime (line 64) | function relativeTime(ts: number): string {

FILE: app/renderer/src/lib/chatPresets.tsx
  type ChatPreset (line 6) | interface ChatPreset {
  constant PRESETS (line 12) | const PRESETS: ChatPreset[] = [
  function PresetGlyph (line 39) | function PresetGlyph() {

FILE: app/renderer/src/lib/debugLogs.ts
  constant MAX_LINES (line 12) | const MAX_LINES = 1000;
  type Listener (line 14) | type Listener = () => void;
  function notify (line 20) | function notify() {
  function appendDebugLog (line 24) | function appendDebugLog(line: string) {
  function clearDebugLogs (line 29) | function clearDebugLogs() {
  function getDebugLogs (line 35) | function getDebugLogs(): string[] {
  function subscribeDebugLogs (line 39) | function subscribeDebugLogs(l: Listener): () => void {
  function primeDebugLogs (line 51) | function primeDebugLogs(

FILE: app/renderer/src/lib/ipc.ts
  type Result (line 11) | type Result<T> = ({ success: true } & T) | { success: false; error: stri...
  type SessionInfo (line 14) | interface SessionInfo {
  type Meeting (line 25) | interface Meeting {
  type Folder (line 43) | interface Folder {
  type ListedModel (line 51) | interface ListedModel {
  type CalendarEvent (line 63) | interface CalendarEvent {
  type Announcement (line 74) | interface Announcement {
  type UpdateMeetingPatch (line 85) | interface UpdateMeetingPatch {
  type ChatSessionsBlob (line 93) | interface ChatSessionsBlob {
  type MicPermissionStatus (line 104) | type MicPermissionStatus =
  type AiProvider (line 111) | type AiProvider = 'local' | 'remote' | 'cloud';
  type CloudProvider (line 112) | type CloudProvider = 'openai' | 'anthropic' | 'custom';
  type AppVersionResponse (line 115) | type AppVersionResponse = Result<{ version: string; name: string }>;
  type StatusResponse (line 116) | type StatusResponse = Result<{ status: string; details?: unknown }>;
  type SetupCheckResponse (line 117) | type SetupCheckResponse = Result<{
  type MicPermissionResponse (line 122) | type MicPermissionResponse = Result<{ status: MicPermissionStatus }>;
  type MicPermissionGrantResponse (line 123) | type MicPermissionGrantResponse = Result<{ granted: boolean }>;
  type StartRecordingResponse (line 125) | type StartRecordingResponse = Result<{ message: string; sessionName?: st...
  type StopRecordingResponse (line 126) | type StopRecordingResponse = Result<{ message: string; sessionName?: str...
  type PauseRecordingResponse (line 127) | type PauseRecordingResponse = Result<{ message: string }>;
  type ResumeRecordingResponse (line 128) | type ResumeRecordingResponse = Result<{ message: string }>;
  type QueueStatus (line 130) | interface QueueStatus {
  type PickAudioFileResponse (line 141) | type PickAudioFileResponse = Result<{ filePath: string }>;
  type RecordingsDirResponse (line 142) | type RecordingsDirResponse = Result<{ path: string }>;
  type ListMeetingsResponse (line 144) | type ListMeetingsResponse = Result<{ meetings: Meeting[] }>;
  type UpdateMeetingResponse (line 145) | type UpdateMeetingResponse = Result<{ message: string; updatedData: Meet...
  type DeleteMeetingResponse (line 146) | type DeleteMeetingResponse = Result<{ message: string }>;
  type SaveMeetingNotesResponse (line 147) | type SaveMeetingNotesResponse = Result<{ path: string }>;
  type QueryResponse (line 149) | type QueryResponse = Result<{ answer: string }>;
  type LoadChatSessionsResponse (line 150) | type LoadChatSessionsResponse = Result<{ data: ChatSessionsBlob | null }>;
  type ListFoldersResponse (line 152) | type ListFoldersResponse = Result<{ folders: Folder[] }>;
  type CreateFolderResponse (line 153) | type CreateFolderResponse = Result<{ folder: Folder }>;
  type CheckOllamaResponse (line 155) | type CheckOllamaResponse = Result<{ installed: boolean; path?: string }>;
  type CheckModelInstalledResponse (line 156) | type CheckModelInstalledResponse = Result<{ installed: boolean }>;
  type RawSupportedModel (line 157) | interface RawSupportedModel {
  type ListModelsResponse (line 168) | type ListModelsResponse = Result<{
  type GetCurrentModelResponse (line 173) | type GetCurrentModelResponse = Result<{ model: string }>;
  type GetNotificationsResponse (line 175) | type GetNotificationsResponse = Result<{ notifications_enabled: boolean }>;
  type GetTelemetryResponse (line 176) | type GetTelemetryResponse = Result<{
  type GetDockIconResponse (line 180) | type GetDockIconResponse = Result<{ hide_dock_icon: boolean }>;
  type GetSystemAudioResponse (line 181) | type GetSystemAudioResponse = Result<{ system_audio_enabled: boolean }>;
  type GetLanguageResponse (line 182) | type GetLanguageResponse = Result<{ language: string }>;
  type GetUserNameResponse (line 183) | type GetUserNameResponse = Result<{ user_name: string }>;
  type StoragePathResponse (line 184) | type StoragePathResponse = Result<{
  type PickStorageFolderResponse (line 189) | type PickStorageFolderResponse = Result<{ folderPath: string }>;
  type GetAiPromptsResponse (line 190) | type GetAiPromptsResponse = Result<{ summarization: string }>;
  type GetAiProviderResponse (line 192) | type GetAiProviderResponse = Result<{
  type AuthStatusResponse (line 201) | type AuthStatusResponse = Result<{ connected: boolean }>;
  type GetCalendarEventsResponse (line 202) | type GetCalendarEventsResponse =
  type CheckForUpdatesResponse (line 207) | type CheckForUpdatesResponse = Result<{
  type CheckAnnouncementsResponse (line 215) | type CheckAnnouncementsResponse = Result<{
  type SummaryChunkEvent (line 221) | interface SummaryChunkEvent {
  type SummaryTitleEvent (line 225) | interface SummaryTitleEvent {
  type SummaryCompleteEvent (line 229) | interface SummaryCompleteEvent {
  type ProcessingCompleteEvent (line 233) | interface ProcessingCompleteEvent {
  type QueryChunkEvent (line 239) | interface QueryChunkEvent {
  type QueryDoneEvent (line 243) | interface QueryDoneEvent {
  type ModelPullProgressEvent (line 248) | interface ModelPullProgressEvent {
  type ModelPullCompleteEvent (line 252) | interface ModelPullCompleteEvent {
  type UpdateAvailableEvent (line 257) | interface UpdateAvailableEvent {
  type UpdateProgressEvent (line 260) | interface UpdateProgressEvent {
  type UpdateDownloadedEvent (line 263) | interface UpdateDownloadedEvent {
  type ShortcutStartRecordingEvent (line 266) | interface ShortcutStartRecordingEvent {
  type RequestFn (line 271) | type RequestFn<Args extends unknown[], Res> = (...args: Args) => Promise...
  type SendFn (line 272) | type SendFn<Args extends unknown[]> = (...args: Args) => void;
  type Subscribe (line 273) | type Subscribe<P = void> = (cb: (payload: P) => void) => () => void;
  type StenoaiBridge (line 275) | interface StenoaiBridge {
  type Window (line 468) | interface Window {
  function ipc (line 480) | function ipc(): StenoaiBridge {

FILE: app/renderer/src/lib/markdown.tsx
  function renderMarkdown (line 11) | function renderMarkdown(text: string): React.ReactNode {
  function isTableRow (line 248) | function isTableRow(line: string): boolean {
  function isTableSeparator (line 258) | function isTableSeparator(line: string): boolean {
  function splitRow (line 268) | function splitRow(line: string): string[] {
  function parseAlign (line 295) | function parseAlign(separator: string): ('left' | 'center' | 'right' | n...
  function renderInline (line 310) | function renderInline(text: string): React.ReactNode {

FILE: app/renderer/src/lib/meetingDetailState.ts
  type StreamPhase (line 9) | type StreamPhase = 'idle' | 'analyzing' | 'generating' | 'done';
  type StreamState (line 11) | interface StreamState {

FILE: app/renderer/src/lib/meetingsListContext.tsx
  type MeetingsListActions (line 4) | interface MeetingsListActions {
  type ProviderProps (line 26) | interface ProviderProps {
  function MeetingsListProvider (line 31) | function MeetingsListProvider({ onContextAction, children }: ProviderPro...
  function useMeetingsList (line 62) | function useMeetingsList(): MeetingsListActions | null {

FILE: app/renderer/src/lib/result.ts
  function unwrap (line 3) | function unwrap<T>(result: Result<T>): T {

FILE: app/renderer/src/lib/router.ts
  function useHashRoute (line 3) | function useHashRoute(): string {
  function routeFromHash (line 15) | function routeFromHash(hash: string): string {
  function useRoute (line 20) | function useRoute(): string {
  function navigate (line 24) | function navigate(path: string) {
  function useNavigate (line 31) | function useNavigate() {
  function rememberNonSettingsRoute (line 41) | function rememberNonSettingsRoute(route: string) {
  function getLastNonSettingsRoute (line 45) | function getLastNonSettingsRoute(): string {
  function toggleSettings (line 54) | function toggleSettings(currentRoute: string) {

FILE: app/renderer/src/lib/utils.ts
  function cn (line 4) | function cn(...inputs: ClassValue[]) {
  function shortcut (line 17) | function shortcut(mac: string, other: string): string {

FILE: app/renderer/src/main.tsx
  class ErrorBoundary (line 9) | class ErrorBoundary extends React.Component<
    method constructor (line 13) | constructor(props: { children: React.ReactNode }) {
    method getDerivedStateFromError (line 17) | static getDerivedStateFromError(error: Error) {
    method render (line 20) | render() {

FILE: app/renderer/src/routes/Chat.tsx
  function Chat (line 26) | function Chat() {
  type PendingNewChat (line 353) | interface PendingNewChat {
  function recordPendingNewChat (line 360) | function recordPendingNewChat(pending: PendingNewChat) {
  function consumePendingNewChat (line 364) | function consumePendingNewChat(sessionId: string): PendingNewChat | null {
  function CloudRequiredBanner (line 371) | function CloudRequiredBanner() {
  function SectionHead (line 399) | function SectionHead({

FILE: app/renderer/src/routes/ChatConversation.tsx
  type ChatConversationProps (line 35) | interface ChatConversationProps {
  function ChatConversation (line 39) | function ChatConversation({ sessionId }: ChatConversationProps) {
  function Bubble (line 462) | function Bubble({

FILE: app/renderer/src/routes/FolderDetail.tsx
  type FolderDetailProps (line 9) | interface FolderDetailProps {
  function FolderDetail (line 13) | function FolderDetail({ folderId }: FolderDetailProps) {

FILE: app/renderer/src/routes/Home.tsx
  type HomeProps (line 17) | interface HomeProps {
  function Home (line 21) | function Home({ mode }: HomeProps) {
  type SectionHeadProps (line 257) | interface SectionHeadProps {
  function SectionHead (line 263) | function SectionHead({ title, count, action }: SectionHeadProps) {
  function summaryLine (line 288) | function summaryLine(_upcomingCount: number): string {
  function firstFolderName (line 292) | function firstFolderName(
  type Group (line 301) | interface Group {
  function groupPrevious (line 306) | function groupPrevious(meetings: Meeting[]): Group[] {
  function groupLabel (line 327) | function groupLabel(d: Date, now: Date): string {

FILE: app/renderer/src/routes/MeetingDetail.tsx
  constant LAST_OPENED_KEY (line 63) | const LAST_OPENED_KEY = 'steno-last-opened-meeting';
  type MeetingDetailProps (line 65) | interface MeetingDetailProps {
  function MeetingDetail (line 69) | function MeetingDetail({ summaryFile }: MeetingDetailProps) {
  function DetailContent (line 106) | function DetailContent({ meeting }: { meeting: Meeting }) {
  function SectionTitle (line 550) | function SectionTitle({ children }: { children: React.ReactNode }) {
  type ChipV2Props (line 565) | interface ChipV2Props {
  function ChipV2 (line 571) | function ChipV2({ icon, children, onClick }: ChipV2Props) {
  type MarkdownBlock (line 609) | interface MarkdownBlock {
  function parseMarkdownBlocks (line 614) | function parseMarkdownBlocks(text: string): MarkdownBlock[] {
  constant CHAR_TRANSITION (line 643) | const CHAR_TRANSITION = 'top 0.12s ease-out';
  constant ROW_TRANSITION (line 644) | const ROW_TRANSITION = 'top 0.35s cubic-bezier(0.45, 0, 0.55, 1)';
  function StreamingView (line 646) | function StreamingView({ text, phase }: { text: string; phase: StreamPha...
  function CalendarSection (line 803) | function CalendarSection({ meeting }: { meeting: Meeting }) {
  constant FOLDER_NONE (line 863) | const FOLDER_NONE = '__none__';
  constant FOLDER_NEW (line 864) | const FOLDER_NEW = '__new__';
  function FolderPicker (line 866) | function FolderPicker({ summaryFile, assignedFolderIds }: { summaryFile:...
  function formatDetailDate (line 1003) | function formatDetailDate(info: { processed_at?: string; updated_at?: st...
  function formatDuration (line 1018) | function formatDuration(seconds?: number): string | undefined {
  function formatEventTime (line 1028) | function formatEventTime(event: CalendarEvent): string {
  function findRelatedEvents (line 1047) | function findRelatedEvents(events: CalendarEvent[], meeting: Meeting): C...
  function asStringArray (line 1061) | function asStringArray(value: unknown): string[] {
  type DiscussionArea (line 1078) | interface DiscussionArea {
  function asDiscussionAreas (line 1083) | function asDiscussionAreas(value: unknown): DiscussionArea[] {
  function getErrorMessage (line 1098) | function getErrorMessage(error: unknown): string {

FILE: app/renderer/src/routes/Processing.tsx
  type ProcessingStage (line 16) | type ProcessingStage = 'transcribing' | 'summarizing' | 'finalizing' | '...
  constant STAGE_LABEL (line 18) | const STAGE_LABEL: Record<ProcessingStage, string> = {
  function Processing (line 25) | function Processing() {
  function StageCard (line 166) | function StageCard({
  function ErrorPanel (line 215) | function ErrorPanel({ onRetry }: { onRetry: () => void }) {
  function ProcessingChip (line 246) | function ProcessingChip() {
  function Chip (line 262) | function Chip({
  function ProcessingDock (line 289) | function ProcessingDock() {
  function formatDate (line 340) | function formatDate(d: Date): string {
  function formatDurationEnglish (line 350) | function formatDurationEnglish(seconds: number): string {

FILE: app/renderer/src/routes/Recording.tsx
  function Recording (line 14) | function Recording() {
  type EditableTitleProps (line 100) | interface EditableTitleProps {
  function EditableTitle (line 106) | function EditableTitle({ value, onChange, placeholder }: EditableTitlePr...
  function Chip (line 124) | function Chip({
  function formatDate (line 150) | function formatDate(d: Date): string {
  function formatTime (line 159) | function formatTime(d: Date): string {

FILE: app/renderer/src/routes/Sandbox.tsx
  type SectionProps (line 37) | interface SectionProps {
  function Section (line 44) | function Section({ id, title, hint, children }: SectionProps) {
  function Stack (line 60) | function Stack({
  function Swatch (line 79) | function Swatch({ name, value }: { name: string; value: string }) {
  function Sandbox (line 95) | function Sandbox() {

FILE: app/renderer/src/routes/Settings.tsx
  constant COMPACT_TRIGGER (line 87) | const COMPACT_TRIGGER =
  constant COMPACT_BTN (line 89) | const COMPACT_BTN = 'h-[30px] px-3 text-[13px]';
  constant LANGUAGES (line 91) | const LANGUAGES: Array<{ value: string; label: string }> = [
  constant TABS (line 106) | const TABS = [
  type TabId (line 113) | type TabId = (typeof TABS)[number]['id'];
  type SettingRowProps (line 119) | interface SettingRowProps {
  function SettingRow (line 128) | function SettingRow({
  function CopyableValue (line 170) | function CopyableValue({ value, mono = false }: { value: string; mono?: ...
  function SectionHeading (line 210) | function SectionHeading({ children }: { children: React.ReactNode }) {
  type TabButtonProps (line 225) | interface TabButtonProps {
  function TabButton (line 231) | function TabButton({ active, onClick, children }: TabButtonProps) {
  type ModelCardProps (line 255) | interface ModelCardProps {
  function ModelCard (line 267) | function ModelCard({
  function Settings (line 383) | function Settings() {
  function GeneralTab (line 463) | function GeneralTab() {
  function AiTab (line 695) | function AiTab() {
  function RemoteProviderConfig (line 750) | function RemoteProviderConfig() {
  function CloudProviderConfig (line 807) | function CloudProviderConfig() {
  function ConnectionStatus (line 1014) | function ConnectionStatus({
  type OAuthPromptProps (line 1033) | interface OAuthPromptProps {
  function OAuthPrompt (line 1045) | function OAuthPrompt({ state, onClose, onRetry }: OAuthPromptProps) {
  function ModelList (line 1091) | function ModelList() {
  function AdvancedTab (line 1208) | function AdvancedTab() {
  function DeveloperTab (line 1351) | function DeveloperTab() {

FILE: app/renderer/src/routes/Setup.tsx
  type StepStatus (line 31) | type StepStatus = 'waiting' | 'running' | 'done' | 'failed';
  type Step (line 33) | interface Step {
  function Badge (line 42) | function Badge({ status }: { status: StepStatus }) {
  function StepCard (line 66) | function StepCard({ step }: { step: Step }) {
  function Setup (line 88) | function Setup() {

FILE: prompt_tests/test_prompts.py
  function test_prompt (line 158) | def test_prompt(prompt_name: str, prompt_template: callable, transcript:...
  function compare_prompts (line 247) | def compare_prompts(transcript_file: str, output_dir: str = None, model:...
  function cli (line 373) | def cli():
  function compare (line 383) | def compare(transcript_file, model, output, prompts):
  function list_prompts (line 390) | def list_prompts():
  function show_prompt (line 403) | def show_prompt(prompt_name, transcript_file):

FILE: simple_recorder.py
  class SimpleRecorder (line 49) | class SimpleRecorder:
    method __init__ (line 52) | def __init__(self):
    method get_state (line 73) | def get_state(self) -> dict:
    method save_state (line 83) | def save_state(self, state: dict):
    method _resolve_output_language (line 88) | def _resolve_output_language(self, configured_language: str, detected_...
    method _load_user_notes (line 101) | def _load_user_notes(session_name: str, output_dir) -> Optional[str]:
    method _parse_streamed_markdown (line 120) | def _parse_streamed_markdown(md_text: str) -> dict:
    method start_recording (line 170) | def start_recording(self, session_name: str = "Recording") -> str:
    method stop_recording (line 200) | def stop_recording(self) -> Optional[str]:
    method transcribe_audio (line 245) | async def transcribe_audio(self, audio_file: str, session_name: str = ...
    method summarize_transcript (line 324) | async def summarize_transcript(
    method process_recording (line 398) | async def process_recording(self, audio_file: str, session_name: str =...
    method process_recording_streaming (line 508) | async def process_recording_streaming(self, audio_file: str, session_n...
  function cli (line 641) | def cli():
  function start (line 648) | def start(session_name):
  function stop (line 742) | def stop():
  function process (line 805) | def process(audio_file, name, notes):
  function process_streaming (line 839) | def process_streaming(audio_file, name, notes):
  function get_whisper_model_cmd (line 966) | def get_whisper_model_cmd():
  function set_whisper_model_cmd (line 978) | def set_whisper_model_cmd(model_size: str):
  function get_keep_recordings_cmd (line 993) | def get_keep_recordings_cmd():
  function set_keep_recordings_cmd (line 1002) | def set_keep_recordings_cmd(enabled: bool):
  function status (line 1013) | def status():
  function record (line 1042) | def record(duration, session_name):
  function test (line 1214) | def test():
  function _parse_meeting_markdown (line 1269) | def _parse_meeting_markdown(md_path):
  function list_meetings (line 1383) | def list_meetings():
  function reprocess (line 1461) | def reprocess(summary_file, regenerate_title):
  function regen_title (line 1608) | def regen_title(summary_file):
  function query (line 1669) | def query(transcript_file, question):
  function query_streaming (line 1760) | def query_streaming(transcript_file, question):
  function chat_global_streaming (line 1838) | def chat_global_streaming(question, folder):
  function list_failed (line 1967) | def list_failed():
  function clear_state (line 2028) | def clear_state():
  function setup_check (line 2040) | def setup_check():
  function list_models (line 2195) | def list_models():
  function get_model (line 2295) | def get_model():
  function set_model (line 2313) | def set_model(model_name):
  function get_notifications (line 2336) | def get_notifications():
  function set_notifications (line 2352) | def set_notifications(enabled):
  function get_dock_icon (line 2368) | def get_dock_icon():
  function set_dock_icon (line 2380) | def set_dock_icon(enabled):
  function get_telemetry (line 2396) | def get_telemetry():
  function set_telemetry (line 2414) | def set_telemetry(enabled):
  function get_system_audio (line 2430) | def get_system_audio():
  function set_system_audio (line 2442) | def set_system_audio(enabled):
  function get_language (line 2458) | def get_language():
  function set_language (line 2471) | def set_language(language_code):
  function get_user_name_cmd (line 2497) | def get_user_name_cmd():
  function set_user_name_cmd (line 2505) | def set_user_name_cmd(name):
  function get_storage_path (line 2516) | def get_storage_path():
  function set_storage_path (line 2526) | def set_storage_path(storage_path):
  function list_folders (line 2538) | def list_folders():
  function create_folder (line 2548) | def create_folder(name, color):
  function update_folder_icon (line 2562) | def update_folder_icon(folder_id, icon):
  function rename_folder (line 2573) | def rename_folder(folder_id, name):
  function reorder_folders (line 2583) | def reorder_folders(folder_ids):
  function delete_folder (line 2593) | def delete_folder(folder_id):
  function add_meeting_to_folder (line 2604) | def add_meeting_to_folder(summary_file, folder_id):
  function remove_meeting_from_folder (line 2615) | def remove_meeting_from_folder(summary_file, folder_id):
  function get_ai_provider (line 2624) | def get_ai_provider():
  function set_ai_provider (line 2642) | def set_ai_provider(provider):
  function set_remote_ollama_url (line 2663) | def set_remote_ollama_url(url):
  function set_cloud_api_url (line 2676) | def set_cloud_api_url(url):
  function set_cloud_provider (line 2690) | def set_cloud_provider(provider):
  function set_cloud_model (line 2711) | def set_cloud_model(model):
  function test_remote_ollama (line 2724) | def test_remote_ollama(url):
  function test_cloud_api (line 2737) | def test_cloud_api():
  function download_whisper_model (line 2770) | def download_whisper_model():
  function check_model (line 2792) | def check_model(model_name):
  function pull_model (line 2829) | def pull_model(model_name):

FILE: src/audio_recorder.py
  function cleanup_sounddevice (line 21) | def cleanup_sounddevice():
  class AudioRecorder (line 33) | class AudioRecorder:
    method __init__ (line 34) | def __init__(self, sample_rate: int = 44100, channels: int = 1):
    method _load_state (line 53) | def _load_state(self):
    method _save_state (line 58) | def _save_state(self):
    method _clear_state (line 62) | def _clear_state(self):
    method start_recording (line 66) | def start_recording(self) -> None:
    method stop_recording (line 90) | def stop_recording(self) -> None:
    method pause_recording (line 105) | def pause_recording(self) -> None:
    method resume_recording (line 117) | def resume_recording(self) -> None:
    method is_paused (line 129) | def is_paused(self) -> bool:
    method _record (line 134) | def _record(self) -> None:
    method _audio_callback (line 168) | def _audio_callback(self, indata, frames, time, status):
    method save_recording (line 177) | def save_recording(self, filepath: Path) -> bool:
    method get_recording_duration (line 217) | def get_recording_duration(self) -> float:
    method is_recording (line 226) | def is_recording(self) -> bool:
    method __del__ (line 230) | def __del__(self):

FILE: src/config.py
  class Config (line 16) | class Config:
    method __init__ (line 134) | def __init__(self, config_path: Optional[Path] = None):
    method _migrate_cloud_model_map (line 158) | def _migrate_cloud_model_map(self) -> None:
    method _load (line 179) | def _load(self) -> Dict[str, Any]:
    method _save (line 194) | def _save(self) -> bool:
    method _get_default_config (line 205) | def _get_default_config(self) -> Dict[str, Any]:
    method get_storage_path (line 225) | def get_storage_path(self) -> str:
    method set_storage_path (line 229) | def set_storage_path(self, storage_path: str) -> bool:
    method get_model (line 260) | def get_model(self) -> str:
    method set_model (line 264) | def set_model(self, model_name: str) -> bool:
    method get_model_info (line 281) | def get_model_info(self, model_name: str) -> Optional[Dict[str, str]]:
    method list_supported_models (line 293) | def list_supported_models(self) -> Dict[str, Dict[str, str]]:
    method get_notifications_enabled (line 297) | def get_notifications_enabled(self) -> bool:
    method set_notifications_enabled (line 301) | def set_notifications_enabled(self, enabled: bool) -> bool:
    method get_telemetry_enabled (line 314) | def get_telemetry_enabled(self) -> bool:
    method set_telemetry_enabled (line 318) | def set_telemetry_enabled(self, enabled: bool) -> bool:
    method get_hide_dock_icon (line 331) | def get_hide_dock_icon(self) -> bool:
    method set_hide_dock_icon (line 335) | def set_hide_dock_icon(self, enabled: bool) -> bool:
    method get_keep_recordings (line 349) | def get_keep_recordings(self) -> bool:
    method set_keep_recordings (line 353) | def set_keep_recordings(self, enabled: bool) -> bool:
    method get_whisper_model (line 359) | def get_whisper_model(self) -> str:
    method set_whisper_model (line 367) | def set_whisper_model(self, model_size: str) -> bool:
    method get_system_audio_enabled (line 375) | def get_system_audio_enabled(self) -> bool:
    method set_system_audio_enabled (line 379) | def set_system_audio_enabled(self, enabled: bool) -> bool:
    method get_language (line 392) | def get_language(self) -> str:
    method set_language (line 396) | def set_language(self, language_code: str) -> bool:
    method get_language_name (line 413) | def get_language_name(self, language_code: Optional[str] = None) -> str:
    method get_ai_provider (line 428) | def get_ai_provider(self) -> str:
    method set_ai_provider (line 433) | def set_ai_provider(self, provider: str) -> bool:
    method get_remote_ollama_url (line 441) | def get_remote_ollama_url(self) -> str:
    method set_remote_ollama_url (line 445) | def set_remote_ollama_url(self, url: str) -> bool:
    method get_cloud_api_url (line 450) | def get_cloud_api_url(self) -> str:
    method set_cloud_api_url (line 454) | def set_cloud_api_url(self, url: str) -> bool:
    method get_cloud_api_key (line 459) | def get_cloud_api_key(self) -> str:
    method get_cloud_provider (line 472) | def get_cloud_provider(self) -> str:
    method set_cloud_provider (line 477) | def set_cloud_provider(self, provider: str) -> bool:
    method _get_cloud_models_map (line 485) | def _get_cloud_models_map(self) -> dict:
    method get_cloud_model (line 494) | def get_cloud_model(self) -> str:
    method set_cloud_model (line 505) | def set_cloud_model(self, model: str) -> bool:
    method get_user_name (line 517) | def get_user_name(self) -> str:
    method set_user_name (line 524) | def set_user_name(self, name: str) -> bool:
    method get_anonymous_id (line 535) | def get_anonymous_id(self) -> str:
    method get (line 544) | def get(self, key: str, default: Any = None) -> Any:
    method set (line 548) | def set(self, key: str, value: Any) -> bool:
  function get_config (line 558) | def get_config() -> Config:
  function get_data_dirs (line 566) | def get_data_dirs() -> Dict[str, Path]:

FILE: src/folders.py
  class FoldersManager (line 18) | class FoldersManager:
    method __init__ (line 21) | def __init__(self, data_dir: Path):
    method _load (line 25) | def _load(self) -> Dict:
    method _save (line 34) | def _save(self) -> bool:
    method list_folders (line 44) | def list_folders(self) -> List[Dict]:
    method create_folder (line 47) | def create_folder(self, name: str, color: str = "#6366f1") -> Optional...
    method update_icon (line 61) | def update_icon(self, folder_id: str, icon: str) -> bool:
    method rename_folder (line 68) | def rename_folder(self, folder_id: str, name: str) -> bool:
    method delete_folder (line 75) | def delete_folder(self, folder_id: str) -> bool:
    method reorder_folders (line 81) | def reorder_folders(self, folder_ids: List[str]) -> bool:
    method _update_md_folders (line 97) | def _update_md_folders(self, summary_path: Path, update_fn) -> bool:
    method add_meeting_to_folder (line 132) | def add_meeting_to_folder(self, summary_path: Path, folder_id: str) ->...
    method remove_meeting_from_folder (line 152) | def remove_meeting_from_folder(self, summary_path: Path, folder_id: st...
  function get_folders_manager (line 173) | def get_folders_manager() -> FoldersManager:

FILE: src/models.py
  class ActionItem (line 7) | class ActionItem(BaseModel):
  class Decision (line 13) | class Decision(BaseModel):
  class DiscussionArea (line 19) | class DiscussionArea(BaseModel):
  class MeetingTranscript (line 24) | class MeetingTranscript(BaseModel):
    method to_json_file (line 35) | def to_json_file(self, filepath: str) -> None:
    method from_json_file (line 42) | def from_json_file(cls, filepath: str) -> 'MeetingTranscript':

FILE: src/ollama_manager.py
  function get_bundled_ollama_dir (line 22) | def get_bundled_ollama_dir() -> Optional[Path]:
  function get_ollama_binary (line 51) | def get_ollama_binary() -> Optional[Path]:
  function get_ollama_env (line 99) | def get_ollama_env() -> dict:
  function is_ollama_running (line 130) | def is_ollama_running() -> bool:
  function _get_pid_file (line 146) | def _get_pid_file() -> Path:
  function _write_pid (line 153) | def _write_pid(pid: int) -> None:
  function _clear_pid (line 161) | def _clear_pid() -> None:
  function start_ollama_server (line 169) | def start_ollama_server(wait: bool = True, timeout: int = 30) -> bool:
  function run_ollama_command (line 222) | def run_ollama_command(args: list, timeout: int = 300) -> Tuple[bool, st...
  function pull_model (line 253) | def pull_model(model_name: str, progress_callback=None) -> bool:
  function list_models (line 309) | def list_models() -> list:
  function has_model (line 334) | def has_model(model_name: str) -> bool:

FILE: src/summarizer.py
  class OllamaSummarizer (line 19) | class OllamaSummarizer:
    method __init__ (line 20) | def __init__(self, model_name: Optional[str] = None):
    method _is_ollama_running (line 98) | def _is_ollama_running(self) -> bool:
    method _find_ollama_path (line 102) | def _find_ollama_path(self) -> Optional[str]:
    method _start_ollama_service (line 110) | def _start_ollama_service(self) -> bool:
    method _repair_json (line 115) | def _repair_json(self, json_text: str) -> Optional[str]:
    method _create_enhanced_fallback (line 158) | def _create_enhanced_fallback(self, malformed_response: str, transcrip...
    method _ensure_model_available (line 232) | def _ensure_model_available(self) -> bool:
    method _ensure_ollama_ready (line 278) | def _ensure_ollama_ready(self) -> bool:
    method _cloud_chat (line 296) | def _cloud_chat(self, prompt: str, timeout_seconds: int = 300) -> str:
    method _openai_chat (line 311) | def _openai_chat(self, prompt: str, timeout_seconds: int = 300) -> str:
    method _anthropic_chat (line 333) | def _anthropic_chat(self, prompt: str, timeout_seconds: int = 300) -> ...
    method _create_permissive_prompt (line 359) | def _create_permissive_prompt(self, transcript: str, language: str = "...
    method summarize_transcript (line 476) | def summarize_transcript(self, transcript: str, duration_minutes: int,...
    method _create_markdown_prompt (line 664) | def _create_markdown_prompt(self, transcript: str, language: str = "en...
    method summarize_transcript_streaming (line 711) | def summarize_transcript_streaming(self, transcript: str, duration_min...
    method test_connection (line 773) | def test_connection(self) -> bool:
    method set_model (line 807) | def set_model(self, model_name: str) -> bool:
    method cleanup (line 833) | def cleanup(self):
    method __del__ (line 848) | def __del__(self):
    method generate_title (line 852) | def generate_title(self, summary: str, transcript: str, language: str ...
    method _build_query_prompt (line 933) | def _build_query_prompt(self, transcript: str, question: str, language...
    method query_transcript_streaming (line 950) | def query_transcript_streaming(self, transcript: str, question: str, l...
    method query_transcript (line 1004) | def query_transcript(self, transcript: str, question: str, language: s...

FILE: src/transcriber.py
  class WhisperTranscriber (line 40) | class WhisperTranscriber:
    method __init__ (line 48) | def __init__(self, model_size: str = "small"):
    method _ensure_ffmpeg_in_path (line 67) | def _ensure_ffmpeg_in_path(self) -> None:
    method _load_model (line 135) | def _load_model(self) -> None:
    method _load_whisper_cpp (line 148) | def _load_whisper_cpp(self) -> None:
    method _load_openai_whisper (line 161) | def _load_openai_whisper(self) -> None:
    method transcribe_audio (line 168) | def transcribe_audio(self, audio_filepath: Path, language: str = "en")...
    method _convert_to_16khz (line 227) | def _convert_to_16khz(self, audio_filepath: Path) -> tuple:
    method _transcribe_whisper_cpp (line 278) | def _transcribe_whisper_cpp(self, audio_filepath: Path, language: str ...
    method _transcribe_openai_whisper (line 343) | def _transcribe_openai_whisper(self, audio_filepath: Path, language: s...
    method _split_stereo_to_channels (line 367) | def _split_stereo_to_channels(self, audio_filepath: Path) -> Tuple[Opt...
    method _check_rms_energy (line 440) | def _check_rms_energy(self, audio_path: Path, threshold: float = 0.005...
    method transcribe_diarised (line 474) | def transcribe_diarised(self, audio_filepath: Path, language: str = "e...
    method transcribe_with_timestamps (line 564) | def transcribe_with_timestamps(self, audio_filepath: Path) -> Optional...
    method change_model (line 604) | def change_model(self, model_size: str) -> bool:
    method get_backend_info (line 626) | def get_backend_info(self) -> dict:

FILE: tests/test_config.py
  class ConfigStoragePathTests (line 9) | class ConfigStoragePathTests(unittest.TestCase):
    method test_set_storage_path_handles_permission_errors (line 10) | def test_set_storage_path_handles_permission_errors(self):
    method test_set_storage_path_accepts_none_as_reset (line 21) | def test_set_storage_path_accepts_none_as_reset(self):
  class ConfigLanguageTests (line 29) | class ConfigLanguageTests(unittest.TestCase):
    method test_set_language_accepts_supported_dutch_code (line 30) | def test_set_language_accepts_supported_dutch_code(self):
    method test_set_language_accepts_auto_detection_mode (line 38) | def test_set_language_accepts_auto_detection_mode(self):
  class ConfigWhisperModelTests (line 47) | class ConfigWhisperModelTests(unittest.TestCase):
    method test_default_whisper_model_is_small (line 48) | def test_default_whisper_model_is_small(self):
    method test_set_whisper_model_persists_supported_size (line 53) | def test_set_whisper_model_persists_supported_size(self):
    method test_set_whisper_model_rejects_unknown_size (line 63) | def test_set_whisper_model_rejects_unknown_size(self):
    method test_get_whisper_model_falls_back_when_stored_value_invalid (line 69) | def test_get_whisper_model_falls_back_when_stored_value_invalid(self):
  class ConfigKeepRecordingsTests (line 77) | class ConfigKeepRecordingsTests(unittest.TestCase):
    method test_default_keep_recordings_is_false (line 78) | def test_default_keep_recordings_is_false(self):
    method test_keep_recordings_round_trip (line 83) | def test_keep_recordings_round_trip(self):

FILE: tests/test_transcriber.py
  class WhisperTranscriberAutoLanguageTests (line 8) | class WhisperTranscriberAutoLanguageTests(unittest.TestCase):
    method _build_transcriber (line 9) | def _build_transcriber(self, model: Mock) -> WhisperTranscriber:
    method test_auto_mode_uses_detected_language (line 17) | def test_auto_mode_uses_detected_language(self):
    method test_auto_mode_falls_back_when_detection_fails (line 31) | def test_auto_mode_falls_back_when_detection_fails(self):
    method test_explicit_language_skips_auto_detection (line 45) | def test_explicit_language_skips_auto_detection(self):

FILE: website/src/App.jsx
  function App (line 12) | function App() {

FILE: website/src/analytics.js
  function trackDownload (line 3) | function trackDownload(location, arch) {
  function trackGitHub (line 7) | function trackGitHub(location) {

FILE: website/src/components/Brand.jsx
  function StenoMark (line 1) | function StenoMark({ size = 22, className = "" }) {
  function Wordmark (line 34) | function Wordmark({ size = 19 }) {

FILE: website/src/components/ThemeToggle.jsx
  function ThemeToggle (line 4) | function ThemeToggle() {

FILE: website/src/sections/CTAFooter.jsx
  constant DOWNLOAD_ARM (line 5) | const DOWNLOAD_ARM = "https://github.com/ruzin/stenoai/releases/latest/d...
  constant DOWNLOAD_X64 (line 6) | const DOWNLOAD_X64 = "https://github.com/ruzin/stenoai/releases/latest/d...
  function CTAFooter (line 8) | function CTAFooter() {

FILE: website/src/sections/FAQ.jsx
  function FAQ (line 14) | function FAQ() {

FILE: website/src/sections/Features.jsx
  function Features (line 13) | function Features() {

FILE: website/src/sections/Footer.jsx
  constant GITHUB_URL (line 4) | const GITHUB_URL = "https://github.com/ruzin/stenoai";
  function Footer (line 6) | function Footer() {

FILE: website/src/sections/Hero.jsx
  constant DOWNLOAD_ARM (line 6) | const DOWNLOAD_ARM = "https://github.com/ruzin/stenoai/releases/latest/d...
  function fmt (line 8) | function fmt(s) {
  function Hero (line 15) | function Hero() {

FILE: website/src/sections/HowItWorks.jsx
  function HowItWorks (line 25) | function HowItWorks() {

FILE: website/src/sections/Industries.jsx
  function Industries (line 10) | function Industries() {

FILE: website/src/sections/Models.jsx
  function Models (line 13) | function Models() {

FILE: website/src/sections/Nav.jsx
  constant GITHUB_URL (line 7) | const GITHUB_URL = "https://github.com/ruzin/stenoai";
  function Nav (line 9) | function Nav() {

FILE: website/src/sections/TrustStrip.jsx
  function TrustStrip (line 10) | function TrustStrip() {
Condensed preview — 147 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,267K chars).
[
  {
    "path": ".clabot",
    "chars": 540,
    "preview": "{\n  \"contributors\": [],\n  \"message\": \"Thank you for your pull request! Before we can merge it, we need you to sign our ["
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 62,
    "preview": "# These are supported funding model platforms\n\ngithub: ruzin\n\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 406,
    "preview": "## 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 "
  },
  {
    "path": ".github/workflows/build-release.yml",
    "chars": 6264,
    "preview": "name: Build and Release DMG\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n  workflow_dispatch:\n    inputs:\n      version:\n    "
  },
  {
    "path": ".github/workflows/deploy-website.yml",
    "chars": 1109,
    "preview": "name: Deploy Website to GitHub Pages\n\non:\n  push:\n    branches: [ main ]\n    paths: [ 'website/**' ]\n  workflow_dispatch"
  },
  {
    "path": ".gitignore",
    "chars": 2139,
    "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packagi"
  },
  {
    "path": "CLA.md",
    "chars": 3150,
    "preview": "# Contributor License Agreement\n\nThank you for your interest in contributing to StenoAI (the \"Project\").\n\nThis Contribut"
  },
  {
    "path": "CLAUDE.md",
    "chars": 7131,
    "preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nDo "
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 4960,
    "preview": "# Contributing to StenoAI\n\nThank you for your interest in contributing to StenoAI! This guide will help you get started."
  },
  {
    "path": "LICENSE",
    "chars": 1071,
    "preview": "MIT License\n\nCopyright (c) 2025 Skrape Limited\n\nPermission is hereby granted, free of charge, to any person obtaining a "
  },
  {
    "path": "README.md",
    "chars": 12001,
    "preview": "<div align=\"center\">\n  <img src=\"website/public/dragonfly-logo-512.png\" alt=\"StenoAI Logo\" width=\"120\" height=\"120\">\n\n  "
  },
  {
    "path": "announcements.json",
    "chars": 26,
    "preview": "{\n  \"announcements\": []\n}\n"
  },
  {
    "path": "app/build/entitlements.mac.plist",
    "chars": 1161,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "app/electron-builder.ci.yml",
    "chars": 736,
    "preview": "# electron-builder config for CI dry-runs (pack:unsigned).\n#\n# Inherits the base \"build\" config from package.json, but s"
  },
  {
    "path": "app/main.js",
    "chars": 167394,
    "preview": "const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, safeStorage, Tray, Menu, nativeIm"
  },
  {
    "path": "app/package-lock.json",
    "chars": 175142,
    "preview": "{\n  \"name\": \"stenoai\",\n  \"version\": \"0.2.13\",\n  \"lockfileVersion\": 3,\n  \"requires\": true,\n  \"packages\": {\n    \"\": {\n    "
  },
  {
    "path": "app/package.json",
    "chars": 5364,
    "preview": "{\n  \"name\": \"stenoai\",\n  \"version\": \"0.2.13\",\n  \"description\": \"AI-powered meeting transcription and analysis for Mac\",\n"
  },
  {
    "path": "app/preload.js",
    "chars": 9528,
    "preview": "/**\n * Preload — contextBridge boundary for the React renderer.\n *\n * This is the only surface the renderer gets. Every "
  },
  {
    "path": "app/renderer/.eslintrc.cjs",
    "chars": 697,
    "preview": "module.exports = {\n  root: true,\n  parser: '@typescript-eslint/parser',\n  parserOptions: { ecmaVersion: 2022, sourceType"
  },
  {
    "path": "app/renderer/.prettierrc.json",
    "chars": 133,
    "preview": "{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"arrowParens\""
  },
  {
    "path": "app/renderer/components.json",
    "chars": 444,
    "preview": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": "
  },
  {
    "path": "app/renderer/index.html",
    "chars": 582,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "app/renderer/postcss.config.cjs",
    "chars": 172,
    "preview": "const path = require('node:path');\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: { config: path.join(__dirname, 'tai"
  },
  {
    "path": "app/renderer/src/App.tsx",
    "chars": 6087,
    "preview": "import * as React from 'react';\nimport { Sandbox } from '@/routes/Sandbox';\nimport { Settings } from '@/routes/Settings'"
  },
  {
    "path": "app/renderer/src/components/AppShell.tsx",
    "chars": 3753,
    "preview": "import * as React from 'react';\nimport { MainToolbar } from '@/components/MainToolbar';\nimport { cn } from '@/lib/utils'"
  },
  {
    "path": "app/renderer/src/components/AskBar.tsx",
    "chars": 19896,
    "preview": "import * as React from 'react';\nimport {\n  ArrowUp,\n  Check,\n  ChevronDown,\n  ChevronUp,\n  Copy,\n  Square,\n  X,\n} from '"
  },
  {
    "path": "app/renderer/src/components/AudioWave.tsx",
    "chars": 1477,
    "preview": "import { useAudioLevel } from '@/hooks/useAudioLevel';\n\ninterface AudioWaveProps {\n  /** True while a recording is activ"
  },
  {
    "path": "app/renderer/src/components/BottomDockSlot.tsx",
    "chars": 2927,
    "preview": "import * as React from 'react';\nimport { useSidebarCollapsed, useSidebarWidth } from '@/components/Sidebar';\n\ninterface "
  },
  {
    "path": "app/renderer/src/components/ChatHistoryRow.tsx",
    "chars": 6342,
    "preview": "import * as React from 'react';\nimport { MessageSquare, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';\nimport {\n "
  },
  {
    "path": "app/renderer/src/components/FolderScopePicker.tsx",
    "chars": 3868,
    "preview": "import * as React from 'react';\nimport { ChevronDown, Folder as FolderIcon, Inbox } from 'lucide-react';\nimport {\n  Popo"
  },
  {
    "path": "app/renderer/src/components/IconPicker.tsx",
    "chars": 15808,
    "preview": "import * as React from 'react';\nimport * as ReactDOM from 'react-dom';\nimport * as LucideIcons from 'lucide-react';\nimpo"
  },
  {
    "path": "app/renderer/src/components/LiveDock.tsx",
    "chars": 3702,
    "preview": "import { Pause, Play, Square } from 'lucide-react';\nimport { AudioWave } from '@/components/AudioWave';\nimport { useReco"
  },
  {
    "path": "app/renderer/src/components/MainToolbar.tsx",
    "chars": 8499,
    "preview": "import * as React from 'react';\nimport { MessageSquare, Moon, MoreHorizontal, Monitor, PanelLeftClose, PanelLeftOpen, Pe"
  },
  {
    "path": "app/renderer/src/components/MeetingsShell.tsx",
    "chars": 16796,
    "preview": "import * as React from 'react';\nimport { AppShell } from '@/components/AppShell';\nimport {\n  Sidebar,\n  useSidebarCollap"
  },
  {
    "path": "app/renderer/src/components/QuitDialog.tsx",
    "chars": 5181,
    "preview": "import * as React from 'react';\nimport { createPortal } from 'react-dom';\nimport { CircleAlert } from 'lucide-react';\nim"
  },
  {
    "path": "app/renderer/src/components/Sidebar.tsx",
    "chars": 19635,
    "preview": "import * as React from 'react';\nimport {\n  ChevronDown,\n  Home as HomeIcon,\n  Inbox,\n  MessageSquare,\n  Plus,\n  Search,\n"
  },
  {
    "path": "app/renderer/src/components/TranscriptPanel.tsx",
    "chars": 5626,
    "preview": "import * as React from 'react';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport { Search as SearchIcon "
  },
  {
    "path": "app/renderer/src/components/home/PreviousRow.tsx",
    "chars": 5404,
    "preview": "import { Folder as FolderIcon, Loader2 } from 'lucide-react';\nimport type { Meeting } from '@/lib/ipc';\nimport { navigat"
  },
  {
    "path": "app/renderer/src/components/home/UpcomingCard.tsx",
    "chars": 6585,
    "preview": "import * as React from 'react';\nimport { Video } from 'lucide-react';\nimport type { CalendarEvent } from '@/lib/ipc';\nim"
  },
  {
    "path": "app/renderer/src/components/ui/app-icon.tsx",
    "chars": 1378,
    "preview": "import { cn } from '@/lib/utils';\n\ninterface AppIconProps {\n  size?: number;\n  className?: string;\n}\n\nexport function Ap"
  },
  {
    "path": "app/renderer/src/components/ui/button.tsx",
    "chars": 1856,
    "preview": "import * as React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'cla"
  },
  {
    "path": "app/renderer/src/components/ui/card.tsx",
    "chars": 2269,
    "preview": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@"
  },
  {
    "path": "app/renderer/src/components/ui/chip.tsx",
    "chars": 2138,
    "preview": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@"
  },
  {
    "path": "app/renderer/src/components/ui/confirm-dialog.tsx",
    "chars": 1749,
    "preview": "import * as React from 'react';\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n"
  },
  {
    "path": "app/renderer/src/components/ui/dialog.tsx",
    "chars": 3519,
    "preview": "import * as React from 'react';\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { X } from 'lucide-rea"
  },
  {
    "path": "app/renderer/src/components/ui/input.tsx",
    "chars": 3308,
    "preview": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@"
  },
  {
    "path": "app/renderer/src/components/ui/kbd.tsx",
    "chars": 476,
    "preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils';\n\nexport function KbdKey({ className, children }: { cla"
  },
  {
    "path": "app/renderer/src/components/ui/popover.tsx",
    "chars": 1064,
    "preview": "import * as React from 'react';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { cn } from '@/lib/u"
  },
  {
    "path": "app/renderer/src/components/ui/row.tsx",
    "chars": 2556,
    "preview": "import * as React from 'react';\nimport { ChevronRight } from 'lucide-react';\nimport { cva, type VariantProps } from 'cla"
  },
  {
    "path": "app/renderer/src/components/ui/select.tsx",
    "chars": 5402,
    "preview": "import * as React from 'react';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { Check, ChevronDown, "
  },
  {
    "path": "app/renderer/src/components/ui/switch.tsx",
    "chars": 1109,
    "preview": "import * as React from 'react';\nimport * as SwitchPrimitive from '@radix-ui/react-switch';\nimport { cn } from '@/lib/uti"
  },
  {
    "path": "app/renderer/src/components/ui/tabs.tsx",
    "chars": 1717,
    "preview": "import * as React from 'react';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport { cn } from '@/lib/utils';"
  },
  {
    "path": "app/renderer/src/components/ui/tooltip.tsx",
    "chars": 1419,
    "preview": "import * as React from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\nimport { cn } from '@/lib/u"
  },
  {
    "path": "app/renderer/src/components/ui/typography.tsx",
    "chars": 1802,
    "preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils';\n\ntype HProps = React.HTMLAttributes<HTMLHeadingElement"
  },
  {
    "path": "app/renderer/src/globals.css",
    "chars": 19765,
    "preview": "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600&family=JetBrains+Mono:wght@400;500&disp"
  },
  {
    "path": "app/renderer/src/hooks/index.ts",
    "chars": 375,
    "preview": "export * from './useMeetings';\nexport * from './useFolders';\nexport * from './useRecording';\nexport * from './useStreami"
  },
  {
    "path": "app/renderer/src/hooks/liveDraftStore.ts",
    "chars": 1919,
    "preview": "import { create } from 'zustand';\n\n/**\n * In-memory draft state for the in-progress recording. Title lives only in\n * me"
  },
  {
    "path": "app/renderer/src/hooks/meetingKeys.ts",
    "chars": 117,
    "preview": "export const meetingsKeys = {\n  all: ['meetings'] as const,\n  list: () => [...meetingsKeys.all, 'list'] as const,\n};\n"
  },
  {
    "path": "app/renderer/src/hooks/useAi.ts",
    "chars": 2915,
    "preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport {"
  },
  {
    "path": "app/renderer/src/hooks/useAiPrompts.ts",
    "chars": 284,
    "preview": "import { useQuery } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport { unwrap } from '@/lib/result'"
  },
  {
    "path": "app/renderer/src/hooks/useAudioLevel.ts",
    "chars": 3291,
    "preview": "import * as React from 'react';\n\ninterface UseAudioLevelOptions {\n  /** When false, the hook tears down the audio graph "
  },
  {
    "path": "app/renderer/src/hooks/useCalendarEvents.ts",
    "chars": 2848,
    "preview": "import * as React from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { "
  },
  {
    "path": "app/renderer/src/hooks/useChatSessions.ts",
    "chars": 7297,
    "preview": "import * as React from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc, type Cha"
  },
  {
    "path": "app/renderer/src/hooks/useFolders.ts",
    "chars": 4842,
    "preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport {"
  },
  {
    "path": "app/renderer/src/hooks/useLiveMeeting.ts",
    "chars": 2125,
    "preview": "import * as React from 'react';\nimport { useRecording } from '@/hooks/useRecording';\nimport { useSaveMeetingNotes } from"
  },
  {
    "path": "app/renderer/src/hooks/useMeetings.ts",
    "chars": 4757,
    "preview": "import * as React from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { "
  },
  {
    "path": "app/renderer/src/hooks/useModels.ts",
    "chars": 3698,
    "preview": "import * as React from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { "
  },
  {
    "path": "app/renderer/src/hooks/useRecording.ts",
    "chars": 6477,
    "preview": "import * as React from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@"
  },
  {
    "path": "app/renderer/src/hooks/useSettings.ts",
    "chars": 5767,
    "preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { ipc } from '@/lib/ipc';\nimport {"
  },
  {
    "path": "app/renderer/src/hooks/useSetup.ts",
    "chars": 1442,
    "preview": "import * as React from 'react';\nimport { useMutation, useQuery } from '@tanstack/react-query';\nimport { ipc } from '@/li"
  },
  {
    "path": "app/renderer/src/hooks/useStreamingQuery.ts",
    "chars": 5460,
    "preview": "import * as React from 'react';\nimport { ipc } from '@/lib/ipc';\n\nexport type StreamStatus = 'streaming' | 'done' | 'err"
  },
  {
    "path": "app/renderer/src/hooks/useTheme.ts",
    "chars": 2121,
    "preview": "import * as React from 'react';\n\nexport type Theme = 'light' | 'dark' | 'system';\n\nconst STORAGE_KEY = 'steno-theme';\n\nf"
  },
  {
    "path": "app/renderer/src/lib/askBarContext.tsx",
    "chars": 1872,
    "preview": "import * as React from 'react';\n\ninterface AskBarContextValue {\n  activeSummaryFile: string | null;\n  activeMeetingName:"
  },
  {
    "path": "app/renderer/src/lib/chat.ts",
    "chars": 2973,
    "preview": "// Shared helpers for the Chat tab + conversation view.\n\n// Sentinel summaryFile that marks a chat session as belonging "
  },
  {
    "path": "app/renderer/src/lib/chatPresets.tsx",
    "chars": 1739,
    "preview": "// Templated preset prompts surfaced two ways:\n//   1. Chip row at the bottom of the /chat entry page (always visible).\n"
  },
  {
    "path": "app/renderer/src/lib/debugLogs.ts",
    "chars": 1745,
    "preview": "// Module-level ring buffer for backend debug log lines.\n//\n// The DeveloperTab in Settings was previously the only list"
  },
  {
    "path": "app/renderer/src/lib/ipc.ts",
    "chars": 16229,
    "preview": "/**\n * Typed wrapper over `window.stenoai` — the contextBridge surface defined in\n * `app/preload.js`. All hooks/compone"
  },
  {
    "path": "app/renderer/src/lib/markdown.tsx",
    "chars": 11396,
    "preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils';\n\n// Lightweight markdown → React renderer for chat bub"
  },
  {
    "path": "app/renderer/src/lib/meetingDetailState.ts",
    "chars": 629,
    "preview": "/**\n * Module-scoped state shared between MeetingDetail (classic) and\n * MeetingDetailV2 (new design). Keeping it here m"
  },
  {
    "path": "app/renderer/src/lib/meetingsListContext.tsx",
    "chars": 1887,
    "preview": "import * as React from 'react';\nimport type { SidebarContextAction } from '@/components/Sidebar';\n\nexport interface Meet"
  },
  {
    "path": "app/renderer/src/lib/queryClient.ts",
    "chars": 226,
    "preview": "import { QueryClient } from '@tanstack/react-query';\n\nexport const queryClient = new QueryClient({\n  defaultOptions: {\n "
  },
  {
    "path": "app/renderer/src/lib/result.ts",
    "chars": 213,
    "preview": "import type { Result } from './ipc';\n\nexport function unwrap<T>(result: Result<T>): T {\n  if (!result.success) throw new"
  },
  {
    "path": "app/renderer/src/lib/router.ts",
    "chars": 1821,
    "preview": "import * as React from 'react';\n\nexport function useHashRoute(): string {\n  const [hash, setHash] = React.useState(() =>"
  },
  {
    "path": "app/renderer/src/lib/utils.ts",
    "chars": 640,
    "preview": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: C"
  },
  {
    "path": "app/renderer/src/main.tsx",
    "chars": 1244,
    "preview": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { QueryClientProvider } from '@tanstack/react"
  },
  {
    "path": "app/renderer/src/routes/Chat.tsx",
    "chars": 15340,
    "preview": "import * as React from 'react';\nimport {\n  ArrowUp,\n  ChevronRight,\n  Sparkles,\n} from 'lucide-react';\nimport { ChatHist"
  },
  {
    "path": "app/renderer/src/routes/ChatConversation.tsx",
    "chars": 19527,
    "preview": "import * as React from 'react';\nimport {\n  ArrowLeft,\n  ArrowUp,\n  ChevronDown,\n  Square,\n} from 'lucide-react';\nimport "
  },
  {
    "path": "app/renderer/src/routes/FolderDetail.tsx",
    "chars": 4852,
    "preview": "import * as React from 'react';\nimport { MeetingsShell } from '@/components/MeetingsShell';\nimport { PreviousRow } from "
  },
  {
    "path": "app/renderer/src/routes/Home.tsx",
    "chars": 12280,
    "preview": "import * as React from 'react';\nimport { PencilLine, RefreshCw, Search, Square, X } from 'lucide-react';\nimport { Meetin"
  },
  {
    "path": "app/renderer/src/routes/MeetingDetail.tsx",
    "chars": 38819,
    "preview": "import * as React from 'react';\nimport {\n  Calendar as CalendarIcon,\n  Check,\n  ChevronLeft,\n  Clock,\n  Copy,\n  Folder a"
  },
  {
    "path": "app/renderer/src/routes/Processing.tsx",
    "chars": 10944,
    "preview": "import * as React from 'react';\nimport {\n  Calendar as CalendarIcon,\n  ChevronLeft,\n  Clock,\n  Loader2,\n  PencilLine,\n} "
  },
  {
    "path": "app/renderer/src/routes/Recording.tsx",
    "chars": 4979,
    "preview": "import * as React from 'react';\nimport {\n  Calendar as CalendarIcon,\n  ChevronLeft,\n  Clock,\n  FolderPlus,\n  PencilLine,"
  },
  {
    "path": "app/renderer/src/routes/Sandbox.tsx",
    "chars": 15776,
    "preview": "import * as React from 'react';\nimport { Search, FolderPlus, Plus, Copy, Check, Mic } from 'lucide-react';\nimport { Butt"
  },
  {
    "path": "app/renderer/src/routes/Settings.tsx",
    "chars": 43028,
    "preview": "import * as React from 'react';\nimport {\n  ArrowLeft,\n  Check,\n  ChevronDown,\n  ChevronRight,\n  Copy,\n  ExternalLink,\n  "
  },
  {
    "path": "app/renderer/src/routes/Setup.tsx",
    "chars": 19030,
    "preview": "import * as React from 'react';\nimport { Check, Cloud, HardDrive, Mic, MessageSquare, Zap, X } from 'lucide-react';\nimpo"
  },
  {
    "path": "app/renderer/tailwind.config.cjs",
    "chars": 4075,
    "preview": "/** @type {import('tailwindcss').Config} */\nconst path = require('node:path');\nconst animate = require('tailwindcss-anim"
  },
  {
    "path": "app/renderer/tsconfig.json",
    "chars": 763,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n "
  },
  {
    "path": "app/vite.config.ts",
    "chars": 670,
    "preview": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport path from 'node:path';\n\nexport def"
  },
  {
    "path": "prompt_tests/PROMPT_TESTING.md",
    "chars": 1794,
    "preview": "# Prompt Testing Framework\n\nTest multiple prompt templates on the same transcript and compare results side-by-side.\n\n## "
  },
  {
    "path": "prompt_tests/test_prompts.py",
    "chars": 15171,
    "preview": "#!/usr/bin/env python3\n\"\"\"\nPrompt Testing Framework\n\nTest multiple prompt templates on the same transcript to compare re"
  },
  {
    "path": "requirements.txt",
    "chars": 156,
    "preview": "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."
  },
  {
    "path": "scripts/build-backend.sh",
    "chars": 2178,
    "preview": "#!/bin/bash\n#\n# Build StenoAI Python backend as standalone executable\n#\n# This script bundles the Python backend using P"
  },
  {
    "path": "scripts/download-ollama.sh",
    "chars": 2089,
    "preview": "#!/bin/bash\n# Download Ollama and ffmpeg binaries for bundling with PyInstaller\n# Supports macOS, Linux, and Windows\n\nse"
  },
  {
    "path": "scripts/test_dmg_fresh_install.sh",
    "chars": 2277,
    "preview": "#!/bin/bash\n\n# Script to simulate a completely fresh DMG install\n# This removes ALL dependencies and data to test true f"
  },
  {
    "path": "scripts/test_first_time_setup.sh",
    "chars": 881,
    "preview": "#!/bin/bash\n\n# Script to simulate first-time user experience by removing dependencies\n# This lets us test the setup wiza"
  },
  {
    "path": "setup.py",
    "chars": 654,
    "preview": "from setuptools import setup, find_packages\n\nwith open(\"requirements.txt\", \"r\") as f:\n    requirements = [line.strip() f"
  },
  {
    "path": "simple_recorder.py",
    "chars": 109578,
    "preview": "#!/usr/bin/env python3\n\"\"\"\nSimple Audio Recorder & Transcriber for Electron App\n\nBackend script that handles:\n1. Recordi"
  },
  {
    "path": "src/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/audio_recorder.py",
    "chars": 8499,
    "preview": "try:\n    import sounddevice as sd\n    import numpy as np\n    AUDIO_AVAILABLE = True\nexcept ImportError:\n    sd = None\n  "
  },
  {
    "path": "src/config.py",
    "chars": 22511,
    "preview": "\"\"\"\nConfiguration management for StenoAI.\n\nHandles storing and loading user preferences like model selection.\n\"\"\"\n\nimpor"
  },
  {
    "path": "src/folders.py",
    "chars": 6514,
    "preview": "\"\"\"\nFolder management for organizing meetings in StenoAI.\n\nStores folder metadata in folders.json alongside the output d"
  },
  {
    "path": "src/models.py",
    "chars": 1322,
    "preview": "from typing import List, Optional\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nimport uuid\n\n\nclas"
  },
  {
    "path": "src/ollama_manager.py",
    "chars": 9427,
    "preview": "\"\"\"\nOllama manager for bundled Ollama binary.\n\nHandles finding and running the bundled Ollama binary that ships with Ste"
  },
  {
    "path": "src/summarizer.py",
    "chars": 45269,
    "preview": "try:\n    import ollama\n    OLLAMA_AVAILABLE = True\nexcept ImportError:\n    ollama = None\n    OLLAMA_AVAILABLE = False\nim"
  },
  {
    "path": "src/transcriber.py",
    "chars": 25211,
    "preview": "\"\"\"\nWhisper transcription module.\n\nSupports two backends:\n1. whisper.cpp (via pywhispercpp) - Lightweight, fast, recomme"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/test_config.py",
    "chars": 4322,
    "preview": "import tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom src.config import Config\n"
  },
  {
    "path": "tests/test_transcriber.py",
    "chars": 2451,
    "preview": "import unittest\nfrom pathlib import Path\nfrom unittest.mock import Mock\n\nfrom src.transcriber import WhisperTranscriber\n"
  },
  {
    "path": "website/.gitignore",
    "chars": 253,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
  },
  {
    "path": "website/README.md",
    "chars": 856,
    "preview": "# React + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCur"
  },
  {
    "path": "website/eslint.config.js",
    "chars": 763,
    "preview": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reac"
  },
  {
    "path": "website/index.html",
    "chars": 1385,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/"
  },
  {
    "path": "website/package.json",
    "chars": 826,
    "preview": "{\n  \"name\": \"website\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n  "
  },
  {
    "path": "website/postcss.config.js",
    "chars": 90,
    "preview": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n    autoprefixer: {},\n  },\n}"
  },
  {
    "path": "website/public/CNAME",
    "chars": 11,
    "preview": "stenoai.co\n"
  },
  {
    "path": "website/public/privacy.html",
    "chars": 11404,
    "preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, "
  },
  {
    "path": "website/public/terms.html",
    "chars": 7132,
    "preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, "
  },
  {
    "path": "website/src/App.jsx",
    "chars": 703,
    "preview": "import { Nav } from \"./sections/Nav\";\nimport { Hero } from \"./sections/Hero\";\nimport { TrustStrip } from \"./sections/Tru"
  },
  {
    "path": "website/src/analytics.js",
    "chars": 235,
    "preview": "import posthog from 'posthog-js'\n\nexport function trackDownload(location, arch) {\n  posthog.capture('download_clicked', "
  },
  {
    "path": "website/src/components/Brand.jsx",
    "chars": 1500,
    "preview": "export function StenoMark({ size = 22, className = \"\" }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n"
  },
  {
    "path": "website/src/components/ThemeToggle.jsx",
    "chars": 966,
    "preview": "import { useState } from \"react\";\nimport { Sun, Moon } from \"lucide-react\";\n\nexport function ThemeToggle() {\n  const [da"
  },
  {
    "path": "website/src/index.css",
    "chars": 8292,
    "preview": "@import \"tailwindcss\";\n\n/* ── Dark mode variant (class-based) ── */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* "
  },
  {
    "path": "website/src/main.jsx",
    "chars": 476,
    "preview": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport posthog from 'posthog-js'\nimport"
  },
  {
    "path": "website/src/sections/CTAFooter.jsx",
    "chars": 2417,
    "preview": "import { Download } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\nimport { trackDownload } from"
  },
  {
    "path": "website/src/sections/FAQ.jsx",
    "chars": 3524,
    "preview": "import { useState } from \"react\";\nimport { Plus, Minus } from \"lucide-react\";\nimport { AnimatePresence, motion as Motion"
  },
  {
    "path": "website/src/sections/Features.jsx",
    "chars": 2890,
    "preview": "import { Cpu, NotebookPen, MessageSquare, ShieldOff, Layers, HardDrive } from \"lucide-react\";\nimport { motion as Motion "
  },
  {
    "path": "website/src/sections/Footer.jsx",
    "chars": 1566,
    "preview": "import { StenoMark, Wordmark } from \"../components/Brand\";\nimport { trackGitHub } from \"../analytics\";\n\nconst GITHUB_URL"
  },
  {
    "path": "website/src/sections/Hero.jsx",
    "chars": 7128,
    "preview": "import { useState, useEffect } from \"react\";\nimport { Download, ArrowRight, ShieldCheck, Lock, Cpu } from \"lucide-react\""
  },
  {
    "path": "website/src/sections/HowItWorks.jsx",
    "chars": 2787,
    "preview": "import { Mic, FileText, Sparkles } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\";\n\nconst steps ="
  },
  {
    "path": "website/src/sections/Industries.jsx",
    "chars": 3215,
    "preview": "import { motion as Motion } from \"framer-motion\";\n\nconst inds = [\n  { title: \"Government\", body: \"Sensitive briefings, p"
  },
  {
    "path": "website/src/sections/Models.jsx",
    "chars": 2967,
    "preview": "import { useState } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { motion as Motion } from \"framer-motion\""
  },
  {
    "path": "website/src/sections/Nav.jsx",
    "chars": 2524,
    "preview": "import { useState, useEffect } from \"react\";\nimport { Github, Download } from \"lucide-react\";\nimport { StenoMark, Wordma"
  },
  {
    "path": "website/src/sections/TrustStrip.jsx",
    "chars": 1016,
    "preview": "const logos = [\n  { name: \"AWS\", src: \"/logos/aws.svg\" },\n  { name: \"HashiCorp\", src: \"/logos/hashicorp.svg\" },\n  { name"
  },
  {
    "path": "website/tailwind.config.js",
    "chars": 181,
    "preview": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{js,ts,jsx"
  },
  {
    "path": "website/vite.config.js",
    "chars": 233,
    "preview": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\n// https://vite.dev/config/\nexport default"
  }
]

// ... and 2 more files (download for full content)

About this extraction

This page contains the full source code of the ruzin/stenoai GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 147 files (1.1 MB), approximately 316.9k tokens, and a symbol index with 767 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!