diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/SKILL.md b/apps/moss-pikachu/.cursor/skills/moss-pikachu/SKILL.md
new file mode 100644
index 00000000..4eca8128
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: moss-pikachu
+description: >-
+ Build and maintain Moss Pikachu, a macOS menu bar semantic file search app
+ using Moss Python SDK (PyPI moss>=1.6.0), FSEvents, SwiftUI, and Pikachu pet
+ animations. Use when working on MossPikachu, moss_worker.py, FileMonitor,
+ SearchService, menu bar overlay, or Moss integration in this repository.
+---
+
+# Moss Pikachu Agent Skill
+
+## Read order
+
+1. [architecture.md](architecture.md)
+2. [moss-integration.md](moss-integration.md)
+3. [macos-patterns.md](macos-patterns.md)
+4. [ui-animations.md](ui-animations.md)
+5. [pitfalls.md](pitfalls.md)
+
+## Non-negotiables
+
+- **Xcode `.app` bundle** — not a root-level SPM executable for the menu bar app
+- **macOS-only target** — `SUPPORTED_PLATFORMS = macosx`
+- **Python worker** for Moss on macOS — Moss Swift SPM is iOS-only
+- **`pip install moss>=1.6.0`** — GitHub `main` `sdks/python/sdk` lacks `SessionIndex` API
+- **No hardcoded credentials** — `MOSS_PROJECT_ID` / `MOSS_PROJECT_KEY` via Keychain or env
+- **NSPanel** for search overlay — not `WindowGroup` (avoids Dock icon)
+
+## Phase gates
+
+Run before advancing phases:
+
+```bash
+./.cursor/skills/moss-pikachu/scripts/validate-phase.sh 1
+./.cursor/skills/moss-pikachu/scripts/validate-phase.sh 2
+./.cursor/skills/moss-pikachu/scripts/validate-phase.sh 3
+```
+
+## Task sequencing
+
+| Phase | Scope |
+|-------|-------|
+| 1 | Menu bar, hotkey, search overlay shell, settings window |
+| 2 | FileMonitor, moss_worker.py, MossBridge, SearchService |
+| 3 | Pikachu animations, live search UI, settings, polish |
+
+Do not wire live search before MossBridge + SearchService compile and pass Phase 2 validation.
+
+## Code conventions
+
+- `@MainActor` for all UI updates
+- `async/await` for MossBridge calls (not callbacks)
+- Line-delimited JSON between Swift and Python (`\n` terminated)
+- Debounce FSEvents (100ms) and search input (200ms)
+- `MARK:` sections in Swift service files
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/architecture.md b/apps/moss-pikachu/.cursor/skills/moss-pikachu/architecture.md
new file mode 100644
index 00000000..368e3c96
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/architecture.md
@@ -0,0 +1,46 @@
+# Architecture
+
+## Component map
+
+```
+AppDelegate
+├── NSStatusBar (menu: Search, Settings, Quit)
+├── HotKeyManager (⌘⇧M)
+├── SearchOverlayController (NSPanel)
+└── SettingsWindowController
+
+SearchOverlayView
+├── PikachuPetView
+├── ResultsListView
+└── SearchService (injected)
+
+SearchService
+├── FileMonitor (FSEvents)
+├── IndexManager (file manifest in Application Support)
+└── MossBridge → moss_worker.py subprocess
+```
+
+## Data flow
+
+1. **Launch:** SearchService.initialize() → start MossBridge → init_session → scan folders → add_docs
+2. **File change:** FileMonitor → debounce → SearchService.indexFiles → MossBridge.addDocs
+3. **Search:** SearchOverlayView → debounced query → SearchService.search → MossBridge.query → results
+4. **Quit:** FileMonitor.stop → MossBridge.saveSession (push_index if cloud sync) → terminate worker
+
+## File ownership
+
+| Path | Owner |
+|------|-------|
+| `MossPikachu/AppDelegate.swift` | Menu bar lifecycle |
+| `MossPikachu/Views/SearchOverlayController.swift` | NSPanel window chrome |
+| `MossPikachu/Services/MossBridge.swift` | Subprocess JSON protocol |
+| `MossPikachu/Resources/moss_worker.py` | Moss SessionIndex loop |
+| `~/Library/Application Support/MossPikachu/` | Index manifest, logs |
+
+## Persistence (no Python disk API)
+
+Python `SessionIndex` has no `save_to_disk` / `load_from_disk`. Persistence strategy:
+
+- **In-session:** worker holds SessionIndex in memory while app runs
+- **Across launches:** Swift `IndexManager` stores `{path, mtime}` manifest; rescan only changed files
+- **Optional cloud:** `push_index()` when user enables Moss cloud sync in Settings
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/macos-patterns.md b/apps/moss-pikachu/.cursor/skills/moss-pikachu/macos-patterns.md
new file mode 100644
index 00000000..ec2a1a30
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/macos-patterns.md
@@ -0,0 +1,37 @@
+# macOS Patterns
+
+## Menu bar agent (no Dock icon)
+
+`Info.plist`:
+```xml
+LSUIElement
+
+```
+
+Or build setting: `INFOPLIST_KEY_LSUIElement = YES`
+
+## Search overlay (NSPanel)
+
+- Subclass `NSPanel`, style `.nonactivatingPanel` + `.fullSizeContentView`
+- `level = .floating`, `isOpaque = false`, `backgroundColor = .clear`
+- `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]`
+- `isMovableByWindowBackground = false`
+- Click-outside: global `NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown)`
+
+## Global hotkey (Carbon, no deps)
+
+- Key code `46` = M (US keyboard)
+- Modifiers: `cmdKey | shiftKey`
+- `RegisterEventHotKey` + `InstallEventHandler` for `kEventHotKeyPressed`
+
+## App Sandbox
+
+Disabled for MVP (`ENABLE_APP_SANDBOX = NO`) — required for:
+- FSEvents on ~/Documents, ~/Desktop, ~/Downloads
+- Spawning Python subprocess with venv outside bundle
+
+Re-enable with entitlements before App Store distribution.
+
+## Credentials
+
+Read from Keychain service `dev.moss.pikachu` or fall back to environment variables for dev.
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/moss-integration.md b/apps/moss-pikachu/.cursor/skills/moss-pikachu/moss-integration.md
new file mode 100644
index 00000000..4ecb2c2e
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/moss-integration.md
@@ -0,0 +1,51 @@
+# Moss Integration
+
+## Install
+
+```bash
+./scripts/setup-moss-venv.sh
+export MOSS_PROJECT_ID=... MOSS_PROJECT_KEY=...
+```
+
+Uses **PyPI `moss>=1.6.0`**, not editable install from `vendor/moss/sdks/python/sdk` (main branch lacks sessions).
+
+`vendor/moss` submodule is for examples/reference only.
+
+## Worker JSON protocol (line-delimited)
+
+### Request → Response
+
+| action | input | output |
+|--------|-------|--------|
+| `ping` | `{}` | `{"status":"ok"}` |
+| `init_session` | `{"index_name":"documents"}` | `{"status":"ok","doc_count":N}` |
+| `add_docs` | `{"files":["/path/a.md"]}` | `{"status":"ok","added":N,"updated":M}` |
+| `query` | `{"query":"text","top_k":5}` | `{"results":[...],"timing_ms":4.2}` |
+| `push_index` | `{}` | `{"status":"ok","doc_count":N}` |
+| `clear_index` | `{}` | `{"status":"ok"}` |
+
+### Error shape
+
+```json
+{"error": "message"}
+```
+
+## Moss SDK calls (verified PyPI 1.6.0)
+
+```python
+client = MossClient(os.environ["MOSS_PROJECT_ID"], os.environ["MOSS_PROJECT_KEY"])
+session = await client.session(index_name="documents")
+await session.add_docs([DocumentInfo(id=path, text=content, metadata={"path": path, "filename": name})])
+results = await session.query("query", QueryOptions(top_k=5, alpha=0.6))
+await session.push_index() # optional cloud sync
+```
+
+## Text extraction
+
+- Supported: `.md`, `.txt`, `.rtf`, `.html` (BeautifulSoup), `.pdf` (pypdf), `.docx` (python-docx)
+- Skipped: `.notes`
+- Chunks: ~1800 chars with 300 char overlap; IDs like `path#chunk-0001`
+
+## Dev credentials
+
+Priority: `MOSS_PROJECT_ID`/`MOSS_PROJECT_KEY` env → Keychain → repo `.env` via `DotEnvLoader`
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/pitfalls.md b/apps/moss-pikachu/.cursor/skills/moss-pikachu/pitfalls.md
new file mode 100644
index 00000000..e3df581a
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/pitfalls.md
@@ -0,0 +1,10 @@
+# Pitfalls
+
+1. **Do not use Moss Swift SPM on macOS** — `Package.swift` targets iOS only
+2. **Do not install Moss from GitHub main SDK** — no `SessionIndex`; use `pip install moss>=1.6.0`
+3. **No Python disk save API** — use in-memory session + Swift file manifest for relaunch
+4. **FSEvents permissions** — may need Full Disk Access; handle `start()` returning false gracefully
+5. **App Sandbox blocks subprocess** — disable sandbox for MVP or add `com.apple.security.cs.allow-unsigned-executable-memory`
+6. **Bundle resources** — `moss_worker.py` must be in Copy Bundle Resources; resolve via `Bundle.main.url(forResource:withExtension:)`
+7. **@MainActor default** — Xcode 26 sets `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`; mark background work `nonisolated` or use `Task.detached`
+8. **Carbon hotkey in sandbox** — works without sandbox; test on real hardware not just simulator
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/scripts/setup-moss-venv.sh b/apps/moss-pikachu/.cursor/skills/moss-pikachu/scripts/setup-moss-venv.sh
new file mode 100755
index 00000000..1f97e34c
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/scripts/setup-moss-venv.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+cd "$ROOT"
+python3 -m venv .venv
+source .venv/bin/activate
+pip install --upgrade pip
+pip install "moss>=1.6.0"
+echo "Moss venv ready at $ROOT/.venv"
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/scripts/validate-phase.sh b/apps/moss-pikachu/.cursor/skills/moss-pikachu/scripts/validate-phase.sh
new file mode 100755
index 00000000..a56f551c
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/scripts/validate-phase.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+PHASE="${1:-1}"
+ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
+cd "$ROOT"
+
+case "$PHASE" in
+ 1)
+ xcodebuild -project MossPikachu.xcodeproj -scheme MossPikachu -destination 'platform=macOS' build
+ echo "Phase 1: build OK — run in Xcode and verify menu bar + ⌘⇧M overlay"
+ ;;
+ 2)
+ test -f .venv/bin/python3 || { echo "Run ./scripts/setup-moss-venv.sh first"; exit 1; }
+ .venv/bin/python3 -c "from moss import MossClient; print('moss import OK')"
+ xcodebuild -project MossPikachu.xcodeproj -scheme MossPikachu -destination 'platform=macOS' build
+ echo "Phase 2: Moss SDK + build OK"
+ ;;
+ 3)
+ xcodebuild -project MossPikachu.xcodeproj -scheme MossPikachu -destination 'platform=macOS' build
+ echo "Phase 3: build OK — run end-to-end search test manually"
+ ;;
+ *)
+ echo "Usage: validate-phase.sh [1|2|3]"
+ exit 1
+ ;;
+esac
diff --git a/apps/moss-pikachu/.cursor/skills/moss-pikachu/ui-animations.md b/apps/moss-pikachu/.cursor/skills/moss-pikachu/ui-animations.md
new file mode 100644
index 00000000..a8ea8237
--- /dev/null
+++ b/apps/moss-pikachu/.cursor/skills/moss-pikachu/ui-animations.md
@@ -0,0 +1,31 @@
+# UI & Animations
+
+## Search overlay sizing
+
+- Width: 500pt
+- Max height: 600pt (results scroll inside)
+- Corner radius: 12pt
+- Shadow: `radius: 20, y: 8, opacity: 0.25`
+
+## Pikachu sizes
+
+| Context | Size |
+|---------|------|
+| Menu bar | 32×32 pt |
+| Search overlay | 64×64 pt |
+| Hover scale | 1.05 |
+
+## PetState animations
+
+| State | Animation |
+|-------|-----------|
+| `idle` | breathe 1.0→1.02 (2s), blink every 5–8s random, tail ±2° every 3–5s |
+| `searching` | tail wag ±15° (0.6s loop), thinking dots |
+| `found(n)` | bounce 1.0→1.15 (0.4s ×2), ✨ sparkles |
+| `notFound` | head tilt ±8° (1s), sad expression |
+
+Use SwiftUI `.animation(.easeInOut, value:)` and `Timer` for random idle intervals.
+
+## Search input debounce
+
+200ms after last keystroke before calling `SearchService.search`.
diff --git a/apps/moss-pikachu/.env.example b/apps/moss-pikachu/.env.example
new file mode 100644
index 00000000..6a67735f
--- /dev/null
+++ b/apps/moss-pikachu/.env.example
@@ -0,0 +1,2 @@
+MOSS_PROJECT_ID=your_project_id
+MOSS_PROJECT_KEY=your_project_key
diff --git a/apps/moss-pikachu/.gitignore b/apps/moss-pikachu/.gitignore
new file mode 100644
index 00000000..7894af80
--- /dev/null
+++ b/apps/moss-pikachu/.gitignore
@@ -0,0 +1,21 @@
+# Xcode
+build/
+DerivedData/
+*.xcuserstate
+xcuserdata/
+
+# Python
+.venv/
+__pycache__/
+*.pyc
+
+# Moss / secrets
+.env
+.env.local
+
+# macOS
+.DS_Store
+
+# Vendor submodule build artifacts
+vendor/moss/**/target/
+vendor/moss/**/.venv/
diff --git a/apps/moss-pikachu/MossPikachu.xcodeproj/project.pbxproj b/apps/moss-pikachu/MossPikachu.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..c4e1dcf7
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu.xcodeproj/project.pbxproj
@@ -0,0 +1,428 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ MPB0000000000000000001 /* MossPikachuApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000001 /* MossPikachuApp.swift */; };
+ MPB0000000000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000002 /* AppDelegate.swift */; };
+ MPB0000000000000000003 /* SearchOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000003 /* SearchOverlayController.swift */; };
+ MPB0000000000000000004 /* SearchOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000004 /* SearchOverlayView.swift */; };
+ MPB0000000000000000005 /* ResultsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000005 /* ResultsListView.swift */; };
+ MPB0000000000000000006 /* PikachuPetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000006 /* PikachuPetView.swift */; };
+ MPB0000000000000000007 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000007 /* SettingsView.swift */; };
+ MPB0000000000000000008 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000008 /* SettingsWindowController.swift */; };
+ MPB0000000000000000009 /* KeyEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000009 /* KeyEventHandler.swift */; };
+ MPB0000000000000000010 /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000010 /* SearchResult.swift */; };
+ MPB0000000000000000011 /* PetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000011 /* PetState.swift */; };
+ MPB0000000000000000012 /* FileSystemEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000012 /* FileSystemEvent.swift */; };
+ MPB0000000000000000013 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000013 /* UserSettings.swift */; };
+ MPB0000000000000000014 /* FileMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000014 /* FileMonitor.swift */; };
+ MPB0000000000000000015 /* MossBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000015 /* MossBridge.swift */; };
+ MPB0000000000000000016 /* IndexManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000016 /* IndexManager.swift */; };
+ MPB0000000000000000017 /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000017 /* SearchService.swift */; };
+ MPB0000000000000000018 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000018 /* NotificationManager.swift */; };
+ MPB0000000000000000019 /* HotKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000019 /* HotKeyManager.swift */; };
+ MPB0000000000000000020 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000020 /* AppLogger.swift */; };
+ MPB0000000000000000021 /* moss_worker.py in Resources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000021 /* moss_worker.py */; };
+ MPB0000000000000000022 /* config.json in Resources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000022 /* config.json */; };
+ MPB0000000000000000023 /* capvolt-sticker.png in Resources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000023 /* capvolt-sticker.png */; };
+ MPB0000000000000000024 /* capvolt-sticker.webp in Resources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000024 /* capvolt-sticker.webp */; };
+ MPB0000000000000000025 /* CapvoltSticker.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000025 /* CapvoltSticker.swift */; };
+ MPB0000000000000000026 /* DotEnvLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000026 /* DotEnvLoader.swift */; };
+ MPB0000000000000000027 /* FloatingPetWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000027 /* FloatingPetWindowController.swift */; };
+ MPB0000000000000000028 /* SearchOverlayPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000028 /* SearchOverlayPresentation.swift */; };
+ MPB0000000000000000030 /* IndexScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = MPF0000000000000000030 /* IndexScope.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ MP00000000000000000120 /* MossPikachu.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MossPikachu.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ MPF0000000000000000001 /* MossPikachuApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MossPikachuApp.swift; sourceTree = ""; };
+ MPF0000000000000000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ MPF0000000000000000003 /* SearchOverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOverlayController.swift; sourceTree = ""; };
+ MPF0000000000000000004 /* SearchOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOverlayView.swift; sourceTree = ""; };
+ MPF0000000000000000005 /* ResultsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultsListView.swift; sourceTree = ""; };
+ MPF0000000000000000006 /* PikachuPetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PikachuPetView.swift; sourceTree = ""; };
+ MPF0000000000000000007 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
+ MPF0000000000000000008 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; };
+ MPF0000000000000000009 /* KeyEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyEventHandler.swift; sourceTree = ""; };
+ MPF0000000000000000010 /* SearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; };
+ MPF0000000000000000011 /* PetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetState.swift; sourceTree = ""; };
+ MPF0000000000000000012 /* FileSystemEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemEvent.swift; sourceTree = ""; };
+ MPF0000000000000000013 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; };
+ MPF0000000000000000014 /* FileMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMonitor.swift; sourceTree = ""; };
+ MPF0000000000000000015 /* MossBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MossBridge.swift; sourceTree = ""; };
+ MPF0000000000000000016 /* IndexManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexManager.swift; sourceTree = ""; };
+ MPF0000000000000000017 /* SearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchService.swift; sourceTree = ""; };
+ MPF0000000000000000018 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; };
+ MPF0000000000000000019 /* HotKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyManager.swift; sourceTree = ""; };
+ MPF0000000000000000020 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; };
+ MPF0000000000000000021 /* moss_worker.py */ = {isa = PBXFileReference; lastKnownFileType = text.script.python; path = moss_worker.py; sourceTree = ""; };
+ MPF0000000000000000022 /* config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = config.json; sourceTree = ""; };
+ MPF0000000000000000023 /* capvolt-sticker.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = capvolt-sticker.png; sourceTree = ""; };
+ MPF0000000000000000024 /* capvolt-sticker.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = capvolt-sticker.webp; sourceTree = ""; };
+ MPF0000000000000000025 /* CapvoltSticker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapvoltSticker.swift; sourceTree = ""; };
+ MPF0000000000000000026 /* DotEnvLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DotEnvLoader.swift; sourceTree = ""; };
+ MPF0000000000000000027 /* FloatingPetWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetWindowController.swift; sourceTree = ""; };
+ MPF0000000000000000028 /* SearchOverlayPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOverlayPresentation.swift; sourceTree = ""; };
+ MPF0000000000000000030 /* IndexScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexScope.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ MP0000000000000001300000 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ MP00000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ MP00000000000000000010 /* MossPikachu */,
+ MP00000000000000000020 /* Products */,
+ );
+ sourceTree = "";
+ };
+ MP00000000000000000010 /* MossPikachu */ = {
+ isa = PBXGroup;
+ children = (
+ MPF0000000000000000001 /* MossPikachuApp.swift */,
+ MPF0000000000000000002 /* AppDelegate.swift */,
+ MPG0000000000000000001 /* Views */,
+ MPG0000000000000000002 /* Models */,
+ MPG0000000000000000003 /* Services */,
+ MPG0000000000000000004 /* Resources */,
+ );
+ path = MossPikachu;
+ sourceTree = "";
+ };
+ MP00000000000000000020 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ MP00000000000000000120 /* MossPikachu.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ MPG0000000000000000001 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ MPF0000000000000000003 /* SearchOverlayController.swift */,
+ MPF0000000000000000004 /* SearchOverlayView.swift */,
+ MPF0000000000000000005 /* ResultsListView.swift */,
+ MPF0000000000000000006 /* PikachuPetView.swift */,
+ MPF0000000000000000027 /* FloatingPetWindowController.swift */,
+ MPF0000000000000000028 /* SearchOverlayPresentation.swift */,
+ MPF0000000000000000007 /* SettingsView.swift */,
+ MPF0000000000000000008 /* SettingsWindowController.swift */,
+ MPF0000000000000000009 /* KeyEventHandler.swift */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+ MPG0000000000000000002 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ MPF0000000000000000010 /* SearchResult.swift */,
+ MPF0000000000000000011 /* PetState.swift */,
+ MPF0000000000000000012 /* FileSystemEvent.swift */,
+ MPF0000000000000000013 /* UserSettings.swift */,
+ MPF0000000000000000030 /* IndexScope.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ MPG0000000000000000003 /* Services */ = {
+ isa = PBXGroup;
+ children = (
+ MPF0000000000000000014 /* FileMonitor.swift */,
+ MPF0000000000000000015 /* MossBridge.swift */,
+ MPF0000000000000000016 /* IndexManager.swift */,
+ MPF0000000000000000017 /* SearchService.swift */,
+ MPF0000000000000000018 /* NotificationManager.swift */,
+ MPF0000000000000000019 /* HotKeyManager.swift */,
+ MPF0000000000000000020 /* AppLogger.swift */,
+ MPF0000000000000000025 /* CapvoltSticker.swift */,
+ MPF0000000000000000026 /* DotEnvLoader.swift */,
+ );
+ path = Services;
+ sourceTree = "";
+ };
+ MPG0000000000000000004 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ MPF0000000000000000021 /* moss_worker.py */,
+ MPF0000000000000000022 /* config.json */,
+ MPF0000000000000000023 /* capvolt-sticker.png */,
+ MPF0000000000000000024 /* capvolt-sticker.webp */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ MP00000000000000010000 /* MossPikachu */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = MP0000000000000001100000 /* Build configuration list for PBXNativeTarget "MossPikachu" */;
+ buildPhases = (
+ MP0000000000000001200000 /* Sources */,
+ MP0000000000000001300000 /* Frameworks */,
+ MP0000000000000001400000 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = MossPikachu;
+ productName = MossPikachu;
+ productReference = MP00000000000000000120 /* MossPikachu.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ MP00000000000000000000 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2700;
+ TargetAttributes = {
+ MP00000000000000010000 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ };
+ };
+ buildConfigurationList = MP0000000000000000100000 /* Build configuration list for PBXProject "MossPikachu" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = MP00000000000000000001;
+ productRefGroup = MP00000000000000000020 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ MP00000000000000010000 /* MossPikachu */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ MP0000000000000001400000 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ MPB0000000000000000021 /* moss_worker.py in Resources */,
+ MPB0000000000000000022 /* config.json in Resources */,
+ MPB0000000000000000023 /* capvolt-sticker.png in Resources */,
+ MPB0000000000000000024 /* capvolt-sticker.webp in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ MP0000000000000001200000 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ MPB0000000000000000001 /* MossPikachuApp.swift in Sources */,
+ MPB0000000000000000002 /* AppDelegate.swift in Sources */,
+ MPB0000000000000000003 /* SearchOverlayController.swift in Sources */,
+ MPB0000000000000000004 /* SearchOverlayView.swift in Sources */,
+ MPB0000000000000000005 /* ResultsListView.swift in Sources */,
+ MPB0000000000000000006 /* PikachuPetView.swift in Sources */,
+ MPB0000000000000000007 /* SettingsView.swift in Sources */,
+ MPB0000000000000000008 /* SettingsWindowController.swift in Sources */,
+ MPB0000000000000000009 /* KeyEventHandler.swift in Sources */,
+ MPB0000000000000000010 /* SearchResult.swift in Sources */,
+ MPB0000000000000000011 /* PetState.swift in Sources */,
+ MPB0000000000000000012 /* FileSystemEvent.swift in Sources */,
+ MPB0000000000000000013 /* UserSettings.swift in Sources */,
+ MPB0000000000000000014 /* FileMonitor.swift in Sources */,
+ MPB0000000000000000015 /* MossBridge.swift in Sources */,
+ MPB0000000000000000016 /* IndexManager.swift in Sources */,
+ MPB0000000000000000017 /* SearchService.swift in Sources */,
+ MPB0000000000000000018 /* NotificationManager.swift in Sources */,
+ MPB0000000000000000019 /* HotKeyManager.swift in Sources */,
+ MPB0000000000000000020 /* AppLogger.swift in Sources */,
+ MPB0000000000000000025 /* CapvoltSticker.swift in Sources */,
+ MPB0000000000000000026 /* DotEnvLoader.swift in Sources */,
+ MPB0000000000000000027 /* FloatingPetWindowController.swift in Sources */,
+ MPB0000000000000000028 /* SearchOverlayPresentation.swift in Sources */,
+ MPB0000000000000000030 /* IndexScope.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ MP0000000000000000110000 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ MP0000000000000000120000 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ MP0000000000000001110000 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSUIElement = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Moss Pikachu.";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = dev.moss.pikachu;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ MP0000000000000001120000 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSUIElement = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Moss Pikachu.";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = dev.moss.pikachu;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ MP0000000000000000100000 /* Build configuration list for PBXProject "MossPikachu" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ MP0000000000000000110000 /* Debug */,
+ MP0000000000000000120000 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ MP0000000000000001100000 /* Build configuration list for PBXNativeTarget "MossPikachu" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ MP0000000000000001110000 /* Debug */,
+ MP0000000000000001120000 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = MP00000000000000000000 /* Project object */;
+}
diff --git a/apps/moss-pikachu/MossPikachu.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/moss-pikachu/MossPikachu.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/apps/moss-pikachu/MossPikachu.xcodeproj/xcshareddata/xcschemes/MossPikachu.xcscheme b/apps/moss-pikachu/MossPikachu.xcodeproj/xcshareddata/xcschemes/MossPikachu.xcscheme
new file mode 100644
index 00000000..4791cfc4
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu.xcodeproj/xcshareddata/xcschemes/MossPikachu.xcscheme
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/moss-pikachu/MossPikachu/AppDelegate.swift b/apps/moss-pikachu/MossPikachu/AppDelegate.swift
new file mode 100644
index 00000000..e085ad93
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/AppDelegate.swift
@@ -0,0 +1,115 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ private var floatingPetController: FloatingPetWindowController?
+ private var searchOverlayController: SearchOverlayController?
+ private var settingsWindowController: SettingsWindowController?
+ private let hotKeyManager = HotKeyManager()
+ private let searchService = SearchService()
+ private let petStateController = PetStateController()
+ private let searchPresentation = SearchOverlayPresentation()
+
+ var debugMode: Bool {
+ ProcessInfo.processInfo.arguments.contains("--debug")
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ if debugMode {
+ AppLogger.shared.isDebugEnabled = true
+ AppLogger.shared.log("Moss Pikachu starting in debug mode")
+ }
+
+ setupSearchOverlay()
+ setupFloatingPet()
+ setupHotKey()
+ setupSettingsWindow()
+
+ Task {
+ do {
+ try await searchService.initialize()
+ AppLogger.shared.isDebugEnabled = true
+ AppLogger.shared.log("SearchService initialized")
+ if searchService.indexedFileCount > 0 {
+ NotificationManager.shared.showSuccess(
+ "Indexed \(searchService.indexedChunkCount) chunks from \(searchService.indexedFileCount) files"
+ )
+ }
+ } catch {
+ AppLogger.shared.isDebugEnabled = true
+ AppLogger.shared.log("SearchService init failed: \(error.localizedDescription)")
+ NotificationManager.shared.showError(error.localizedDescription)
+ }
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ searchService.shutdown()
+ hotKeyManager.unregister()
+ }
+
+ // MARK: - Setup
+
+ private func setupFloatingPet() {
+ floatingPetController = FloatingPetWindowController(petStateController: petStateController)
+ floatingPetController?.onPetClicked = { [weak self] in
+ self?.openSearch()
+ }
+ floatingPetController?.onShowSettings = { [weak self] in
+ self?.showSettings()
+ }
+ floatingPetController?.onQuit = { [weak self] in
+ self?.quitApp()
+ }
+ floatingPetController?.show()
+ }
+
+ private func setupSearchOverlay() {
+ searchOverlayController = SearchOverlayController(
+ searchService: searchService,
+ presentation: searchPresentation,
+ anchorProvider: { [weak self] in
+ self?.floatingPetController?.screenFrame
+ }
+ )
+ searchOverlayController?.onPetStateChanged = { [weak self] state in
+ self?.petStateController.state = state
+ }
+ }
+
+ private func setupHotKey() {
+ hotKeyManager.onHotKeyPressed = { [weak self] in
+ Task { @MainActor in
+ self?.toggleSearch()
+ }
+ }
+ hotKeyManager.register()
+ }
+
+ private func setupSettingsWindow() {
+ settingsWindowController = SettingsWindowController(searchService: searchService)
+ }
+
+ // MARK: - Actions
+
+ private func openSearch() {
+ if searchOverlayController?.isSearchVisible == true {
+ searchOverlayController?.focusSearchField()
+ } else {
+ searchOverlayController?.show()
+ }
+ }
+
+ @objc private func showSettings() {
+ settingsWindowController?.show()
+ }
+
+ @objc private func quitApp() {
+ NSApplication.shared.terminate(nil)
+ }
+
+ private func toggleSearch() {
+ searchOverlayController?.toggle()
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Models/FileSystemEvent.swift b/apps/moss-pikachu/MossPikachu/Models/FileSystemEvent.swift
new file mode 100644
index 00000000..1ebb12be
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Models/FileSystemEvent.swift
@@ -0,0 +1,11 @@
+import Foundation
+
+struct IndexedFileRecord: Codable, Equatable {
+ let path: String
+ let modificationDate: TimeInterval
+}
+
+struct IndexManifest: Codable {
+ var files: [String: IndexedFileRecord] = [:]
+ var lastIndexedDate: Date?
+}
diff --git a/apps/moss-pikachu/MossPikachu/Models/IndexScope.swift b/apps/moss-pikachu/MossPikachu/Models/IndexScope.swift
new file mode 100644
index 00000000..9357a31e
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Models/IndexScope.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+/// Moss SessionIndex scope for the current product build (test corpus: Downloads/cwp-stuff).
+enum IndexScope {
+ static let folderName = "cwp-stuff"
+ static let sessionName = "cwp-stuff"
+ static let manifestFilename = "index-manifest-cwp-stuff.json"
+
+ /// Resolved `~/Downloads/cwp-stuff` for the current user.
+ static var watchedFolderURL: URL? {
+ let downloads = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Downloads", isDirectory: true)
+ let folder = downloads.appendingPathComponent(folderName, isDirectory: true)
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory),
+ isDirectory.boolValue else {
+ return nil
+ }
+ return folder
+ }
+
+ static func contains(path: String) -> Bool {
+ guard let root = watchedFolderURL?.path else { return false }
+ return path == root || path.hasPrefix(root + "/")
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Models/PetState.swift b/apps/moss-pikachu/MossPikachu/Models/PetState.swift
new file mode 100644
index 00000000..61a4c131
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Models/PetState.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+enum PetState: Sendable {
+ case idle
+ case searching
+ case found(Int)
+ case notFound
+}
+
+extension PetState: Equatable {
+ nonisolated static func == (lhs: PetState, rhs: PetState) -> Bool {
+ switch (lhs, rhs) {
+ case (.idle, .idle), (.searching, .searching), (.notFound, .notFound):
+ return true
+ case (.found(let a), .found(let b)):
+ return a == b
+ default:
+ return false
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Models/SearchResult.swift b/apps/moss-pikachu/MossPikachu/Models/SearchResult.swift
new file mode 100644
index 00000000..296a6305
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Models/SearchResult.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+struct SearchResult: Codable, Identifiable, Equatable {
+ let id: String
+ let text: String
+ let score: Double
+ let filename: String
+ let path: String
+ let timingMs: Double
+
+ enum CodingKeys: String, CodingKey {
+ case id, text, score, filename, path
+ case timingMs = "timing_ms"
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Models/UserSettings.swift b/apps/moss-pikachu/MossPikachu/Models/UserSettings.swift
new file mode 100644
index 00000000..4d57ab97
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Models/UserSettings.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+struct UserSettings: Codable, Equatable {
+ var indexDocuments: Bool = true
+ var indexDesktop: Bool = true
+ var indexDownloads: Bool = true
+ var indexICloudDrive: Bool = true
+ var launchAtLogin: Bool = false
+ var mossCloudSync: Bool = false
+
+ private static let storageKey = "moss.pikachu.userSettings"
+
+ static func load() -> UserSettings {
+ guard let data = UserDefaults.standard.data(forKey: storageKey),
+ let settings = try? JSONDecoder().decode(UserSettings.self, from: data) else {
+ return UserSettings()
+ }
+ return settings
+ }
+
+ func save() {
+ if let data = try? JSONEncoder().encode(self) {
+ UserDefaults.standard.set(data, forKey: Self.storageKey)
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/MossPikachuApp.swift b/apps/moss-pikachu/MossPikachu/MossPikachuApp.swift
new file mode 100644
index 00000000..6be9230b
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/MossPikachuApp.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+@main
+struct MossPikachuApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+
+ var body: some Scene {
+ Settings {
+ EmptyView()
+ }
+ }
+}
+
diff --git a/apps/moss-pikachu/MossPikachu/Resources/capvolt-sticker.png b/apps/moss-pikachu/MossPikachu/Resources/capvolt-sticker.png
new file mode 100644
index 00000000..1fd0873c
Binary files /dev/null and b/apps/moss-pikachu/MossPikachu/Resources/capvolt-sticker.png differ
diff --git a/apps/moss-pikachu/MossPikachu/Resources/capvolt-sticker.webp b/apps/moss-pikachu/MossPikachu/Resources/capvolt-sticker.webp
new file mode 100644
index 00000000..33e129f3
Binary files /dev/null and b/apps/moss-pikachu/MossPikachu/Resources/capvolt-sticker.webp differ
diff --git a/apps/moss-pikachu/MossPikachu/Resources/config.json b/apps/moss-pikachu/MossPikachu/Resources/config.json
new file mode 100644
index 00000000..2c06d296
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Resources/config.json
@@ -0,0 +1,6 @@
+{
+ "index_name": "documents",
+ "watched_folders": ["Documents", "Desktop", "Downloads"],
+ "allowed_extensions": ["md", "txt", "html", "rtf", "pdf", "docx", "notes"],
+ "cache_path": "~/Library/Application Support/MossPikachu/index"
+}
diff --git a/apps/moss-pikachu/MossPikachu/Resources/moss_worker.py b/apps/moss-pikachu/MossPikachu/Resources/moss_worker.py
new file mode 100644
index 00000000..93803f56
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Resources/moss_worker.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+"""Moss Pikachu worker — line-delimited JSON protocol over stdin/stdout."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import signal
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+ALLOWED_EXTENSIONS = {".md", ".txt", ".html", ".rtf", ".pdf", ".docx"}
+SKIPPED_EXTENSIONS = {".notes"}
+
+CHUNK_CHARS = 1800
+CHUNK_OVERLAP = 300
+
+session = None
+client = None
+index_name = "cwp-stuff"
+shutdown_requested = False
+
+
+def log_stderr(msg: str) -> None:
+ print(msg, file=sys.stderr, flush=True)
+
+
+def normalize_whitespace(text: str) -> str:
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
+ text = re.sub(r"\n{3,}", "\n\n", text)
+ return text.strip()
+
+
+def chunk_text(text: str, path: str) -> list[tuple[str, str, int]]:
+ """Return list of (chunk_id, chunk_text, chunk_index)."""
+ text = normalize_whitespace(text)
+ if not text:
+ return []
+ if len(text) <= CHUNK_CHARS:
+ return [(f"{path}#chunk-0000", text, 0)]
+
+ chunks: list[tuple[str, str, int]] = []
+ start = 0
+ idx = 0
+ while start < len(text):
+ end = min(start + CHUNK_CHARS, len(text))
+ piece = text[start:end].strip()
+ if piece:
+ chunks.append((f"{path}#chunk-{idx:04d}", piece, idx))
+ idx += 1
+ if end >= len(text):
+ break
+ start = max(end - CHUNK_OVERLAP, start + 1)
+ return chunks
+
+
+def read_plain(path: Path) -> str | None:
+ try:
+ return path.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ log_stderr(f"Failed to read {path}: {exc}")
+ return None
+
+
+def read_html(path: Path) -> str | None:
+ raw = read_plain(path)
+ if not raw:
+ return None
+ try:
+ from bs4 import BeautifulSoup
+
+ soup = BeautifulSoup(raw, "html.parser")
+ for tag in soup(["script", "style", "noscript"]):
+ tag.decompose()
+ return soup.get_text(separator="\n")
+ except Exception as exc:
+ log_stderr(f"HTML parse failed {path}: {exc}")
+ return raw
+
+
+def read_pdf(path: Path) -> str | None:
+ try:
+ from pypdf import PdfReader
+
+ reader = PdfReader(str(path))
+ parts = []
+ for page in reader.pages:
+ parts.append(page.extract_text() or "")
+ return "\n".join(parts)
+ except Exception as exc:
+ log_stderr(f"PDF extract failed {path}: {exc}")
+ return None
+
+
+def read_docx(path: Path) -> str | None:
+ try:
+ from docx import Document
+
+ doc = Document(str(path))
+ return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
+ except Exception as exc:
+ log_stderr(f"DOCX extract failed {path}: {exc}")
+ return None
+
+
+def read_file_text(path: str) -> str | None:
+ p = Path(path)
+ ext = p.suffix.lower()
+ if ext in SKIPPED_EXTENSIONS:
+ log_stderr(f"Skipping unsupported format: {path}")
+ return None
+ if ext not in ALLOWED_EXTENSIONS:
+ return None
+
+ if ext in {".md", ".txt", ".rtf"}:
+ return read_plain(p)
+ if ext == ".html":
+ return read_html(p)
+ if ext == ".pdf":
+ return read_pdf(p)
+ if ext == ".docx":
+ return read_docx(p)
+ return None
+
+
+def file_mtime_iso(path: str) -> str:
+ try:
+ ts = Path(path).stat().st_mtime
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
+ except OSError:
+ return ""
+
+
+async def ensure_client() -> None:
+ global client
+ if client is not None:
+ return
+ from moss import MossClient
+
+ project_id = os.environ.get("MOSS_PROJECT_ID")
+ project_key = os.environ.get("MOSS_PROJECT_KEY")
+ if not project_id or not project_key:
+ raise RuntimeError("MOSS_PROJECT_ID and MOSS_PROJECT_KEY must be set")
+
+ client = MossClient(project_id, project_key)
+
+
+async def ensure_session(name: str | None = None) -> None:
+ global session, index_name
+ await ensure_client()
+ if name:
+ index_name = name
+ if session is not None:
+ return
+ session = await client.session(index_name=index_name)
+ log_stderr(f"Session opened: {index_name} ({session.doc_count} docs)")
+
+
+async def handle_init_session(payload: dict[str, Any]) -> dict[str, Any]:
+ name = payload.get("index_name", index_name)
+ await ensure_session(name)
+ return {"status": "ok", "index": index_name, "doc_count": session.doc_count}
+
+
+async def handle_add_docs(payload: dict[str, Any]) -> dict[str, Any]:
+ from moss import DocumentInfo
+
+ await ensure_session()
+ files: list[str] = payload.get("files", [])
+ docs = []
+ files_indexed = 0
+ files_skipped = 0
+ errors: list[str] = []
+
+ for path in files:
+ text = read_file_text(path)
+ if not text:
+ files_skipped += 1
+ continue
+ chunks = chunk_text(text, path)
+ if not chunks:
+ files_skipped += 1
+ continue
+ filename = Path(path).name
+ ext = Path(path).suffix.lower().lstrip(".")
+ mtime = file_mtime_iso(path)
+ for chunk_id, chunk_body, chunk_idx in chunks:
+ docs.append(
+ DocumentInfo(
+ id=chunk_id,
+ text=chunk_body,
+ metadata={
+ "path": path,
+ "filename": filename,
+ "chunk": str(chunk_idx),
+ "extension": ext,
+ "modified_at": mtime,
+ },
+ )
+ )
+ files_indexed += 1
+
+ if not docs:
+ return {
+ "status": "ok",
+ "added": 0,
+ "updated": 0,
+ "chunks_indexed": 0,
+ "files_indexed": files_indexed,
+ "files_skipped": files_skipped + (len(files) - files_indexed - files_skipped),
+ "errors": errors,
+ }
+
+ try:
+ added, updated = await session.add_docs(docs)
+ except Exception as exc:
+ errors.append(str(exc))
+ raise
+
+ return {
+ "status": "ok",
+ "added": added,
+ "updated": updated,
+ "chunks_indexed": len(docs),
+ "files_indexed": files_indexed,
+ "files_skipped": len(files) - files_indexed,
+ "doc_count": session.doc_count,
+ "errors": errors,
+ }
+
+
+async def handle_query(payload: dict[str, Any]) -> dict[str, Any]:
+ from moss import QueryOptions
+
+ await ensure_session()
+ query_text = payload.get("query", "")
+ top_k = int(payload.get("top_k", 5))
+ start = time.perf_counter()
+ result = await session.query(query_text, QueryOptions(top_k=top_k, alpha=0.6))
+ timing_ms = (time.perf_counter() - start) * 1000
+
+ results = []
+ for doc in result.docs:
+ meta = doc.metadata or {}
+ results.append(
+ {
+ "id": doc.id,
+ "text": doc.text,
+ "score": float(doc.score),
+ "path": meta.get("path", doc.id.split("#chunk-")[0]),
+ "filename": meta.get("filename", Path(meta.get("path", doc.id)).name),
+ }
+ )
+ return {"results": results, "timing_ms": timing_ms}
+
+
+async def handle_push_index(_: dict[str, Any]) -> dict[str, Any]:
+ await ensure_session()
+ pushed = await session.push_index()
+ return {
+ "status": "ok",
+ "doc_count": pushed.doc_count,
+ "job_id": pushed.job_id,
+ }
+
+
+async def handle_clear_index(_: dict[str, Any]) -> dict[str, Any]:
+ global session
+ await ensure_session()
+ docs = await session.get_docs()
+ if docs:
+ await session.delete_docs([d.id for d in docs])
+ session = None
+ await ensure_session(index_name)
+ return {"status": "ok", "doc_count": 0}
+
+
+async def handle_ping(_: dict[str, Any]) -> dict[str, Any]:
+ return {"status": "ok"}
+
+
+async def handle_shutdown(_: dict[str, Any]) -> dict[str, Any]:
+ global shutdown_requested
+ shutdown_requested = True
+ return {"status": "ok"}
+
+
+HANDLERS = {
+ "ping": handle_ping,
+ "init_session": handle_init_session,
+ "add_docs": handle_add_docs,
+ "query": handle_query,
+ "push_index": handle_push_index,
+ "clear_index": handle_clear_index,
+ "shutdown": handle_shutdown,
+}
+
+
+async def dispatch(line: str) -> dict[str, Any]:
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError as exc:
+ return {"error": f"Invalid JSON: {exc}"}
+
+ action = payload.get("action")
+ if not action:
+ return {"error": "Missing action"}
+
+ handler = HANDLERS.get(action)
+ if not handler:
+ return {"error": f"Unknown action: {action}"}
+
+ try:
+ return await handler(payload)
+ except Exception as exc:
+ log_stderr(f"Handler error ({action}): {exc}")
+ return {"error": str(exc)}
+
+
+async def main_loop() -> None:
+ loop = asyncio.get_event_loop()
+ reader = asyncio.StreamReader()
+ protocol = asyncio.StreamReaderProtocol(reader)
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
+
+ while not shutdown_requested:
+ try:
+ line = await reader.readline()
+ except Exception:
+ break
+ if not line:
+ break
+ decoded = line.decode("utf-8").strip()
+ if not decoded:
+ continue
+ response = await dispatch(decoded)
+ sys.stdout.write(json.dumps(response) + "\n")
+ sys.stdout.flush()
+ if shutdown_requested:
+ break
+
+
+def handle_sigterm(*_: Any) -> None:
+ global shutdown_requested
+ shutdown_requested = True
+
+
+if __name__ == "__main__":
+ signal.signal(signal.SIGTERM, handle_sigterm)
+ try:
+ asyncio.run(main_loop())
+ except KeyboardInterrupt:
+ pass
diff --git a/apps/moss-pikachu/MossPikachu/Services/AppLogger.swift b/apps/moss-pikachu/MossPikachu/Services/AppLogger.swift
new file mode 100644
index 00000000..f6f6aad4
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/AppLogger.swift
@@ -0,0 +1,36 @@
+import Foundation
+import os.log
+
+final class AppLogger {
+ static let shared = AppLogger()
+
+ var isDebugEnabled = false
+ private let logFileURL: URL
+ private let osLog = Logger(subsystem: "dev.moss.pikachu", category: "app")
+
+ private init() {
+ let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let dir = appSupport.appendingPathComponent("MossPikachu", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ logFileURL = dir.appendingPathComponent("moss-pikachu.log")
+ }
+
+ func log(_ message: String) {
+ let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n"
+ if isDebugEnabled {
+ print(line, terminator: "")
+ osLog.debug("\(message)")
+ if let data = line.data(using: .utf8) {
+ if FileManager.default.fileExists(atPath: logFileURL.path) {
+ if let handle = try? FileHandle(forWritingTo: logFileURL) {
+ handle.seekToEndOfFile()
+ handle.write(data)
+ try? handle.close()
+ }
+ } else {
+ try? data.write(to: logFileURL)
+ }
+ }
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/CapvoltSticker.swift b/apps/moss-pikachu/MossPikachu/Services/CapvoltSticker.swift
new file mode 100644
index 00000000..0c075cd5
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/CapvoltSticker.swift
@@ -0,0 +1,78 @@
+import AppKit
+import SwiftUI
+
+enum CapvoltSticker {
+ private static let resourceNames = ["capvolt-sticker", "capvolt-sticker.webp"]
+
+ static func nsImage(size: CGFloat? = nil) -> NSImage? {
+ for name in resourceNames {
+ for ext in ["png", "webp"] {
+ if let url = Bundle.main.url(forResource: name, withExtension: ext),
+ let image = NSImage(contentsOf: url) {
+ return resized(image, to: size)
+ }
+ }
+ }
+
+ // Dev fallback: project root or Resources folder
+ let candidates = [
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appendingPathComponent("Resources/capvolt-sticker.png"),
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appendingPathComponent("capvolt-sticker.webp"),
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appendingPathComponent("MossPikachu/Resources/capvolt-sticker.png"),
+ ]
+ for url in candidates where FileManager.default.fileExists(atPath: url.path) {
+ if let image = NSImage(contentsOf: url) {
+ return resized(image, to: size)
+ }
+ }
+ return nil
+ }
+
+ static var isAvailable: Bool { nsImage() != nil }
+
+ private static func resized(_ image: NSImage, to size: CGFloat?) -> NSImage {
+ guard let size, size > 0 else { return image }
+ let newImage = NSImage(size: NSSize(width: size, height: size))
+ newImage.lockFocus()
+ image.draw(
+ in: NSRect(x: 0, y: 0, width: size, height: size),
+ from: .zero,
+ operation: .copy,
+ fraction: 1.0,
+ respectFlipped: true,
+ hints: nil
+ )
+ newImage.unlockFocus()
+ newImage.isTemplate = false
+ return newImage
+ }
+}
+
+struct CapvoltStickerImage: View {
+ var size: CGFloat = 64
+
+ var body: some View {
+ Group {
+ if let nsImage = CapvoltSticker.nsImage(size: size) {
+ Image(nsImage: nsImage)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ } else {
+ Text("⚡")
+ .font(.system(size: size * 0.7))
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/DotEnvLoader.swift b/apps/moss-pikachu/MossPikachu/Services/DotEnvLoader.swift
new file mode 100644
index 00000000..b96f2076
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/DotEnvLoader.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+enum DotEnvLoader {
+ /// Parses a `.env` file for KEY=VALUE pairs. Does not log values.
+ static func load(from url: URL) -> [String: String] {
+ guard let contents = try? String(contentsOf: url, encoding: .utf8) else {
+ return [:]
+ }
+ var result: [String: String] = [:]
+ for line in contents.split(whereSeparator: \.isNewline) {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
+ let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init)
+ guard parts.count == 2 else { continue }
+ let key = parts[0].trimmingCharacters(in: .whitespaces)
+ var value = parts[1].trimmingCharacters(in: .whitespaces)
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'")) {
+ value = String(value.dropFirst().dropLast())
+ }
+ result[key] = value
+ }
+ return result
+ }
+
+ /// Walks up from start directory looking for `.env`.
+ static func findRepoDotEnv(startingAt: URL, maxDepth: Int = 6) -> URL? {
+ var dir = startingAt
+ for _ in 0.. (String, String)? {
+ guard let envURL = findRepoDotEnv(startingAt: URL(fileURLWithPath: #filePath)) else {
+ return nil
+ }
+ let vars = load(from: envURL)
+ guard let id = vars["MOSS_PROJECT_ID"], !id.isEmpty,
+ let key = vars["MOSS_PROJECT_KEY"], !key.isEmpty else {
+ return nil
+ }
+ return (id, key)
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/FileMonitor.swift b/apps/moss-pikachu/MossPikachu/Services/FileMonitor.swift
new file mode 100644
index 00000000..8ef6ee60
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/FileMonitor.swift
@@ -0,0 +1,129 @@
+import CoreServices
+import Foundation
+
+final class FileMonitor {
+ static let indexableExtensions: Set = ["md", "txt", "pdf", "notes", "rtf", "docx", "html"]
+
+ var onChange: ([String]) -> Void = { _ in }
+
+ private var stream: FSEventStreamRef?
+ private var watchedPaths: [String] = []
+ private var pendingPaths: Set = []
+ private let queue = DispatchQueue(label: "dev.moss.pikachu.filemonitor")
+ private var flushTimer: DispatchWorkItem?
+
+ private let allowedExtensions = FileMonitor.indexableExtensions
+ private let ignoredNames: Set = [".DS_Store", ".git", "node_modules"]
+
+ func updateWatchedPaths(_ paths: [String]) {
+ watchedPaths = paths
+ if stream != nil {
+ stop()
+ _ = start()
+ }
+ }
+
+ func start() -> Bool {
+ guard !watchedPaths.isEmpty else { return false }
+
+ var context = FSEventStreamContext(
+ version: 0,
+ info: Unmanaged.passUnretained(self).toOpaque(),
+ retain: nil,
+ release: nil,
+ copyDescription: nil
+ )
+
+ let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, _, _ in
+ guard let info else { return }
+ let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue()
+ let paths = eventPaths.bindMemory(to: UnsafePointer?.self, capacity: numEvents)
+ var changed: [String] = []
+ for i in 0.. Bool {
+ guard IndexScope.contains(path: path) else { return false }
+ let url = URL(fileURLWithPath: path)
+ let name = url.lastPathComponent
+ if name.hasPrefix(".") { return false }
+ if ignoredNames.contains(name) { return false }
+ if path.contains("/.git/") || path.contains("/node_modules/") { return false }
+ guard FileManager.default.fileExists(atPath: path) else { return true }
+ var isDir: ObjCBool = false
+ FileManager.default.fileExists(atPath: path, isDirectory: &isDir)
+ if isDir.boolValue { return false }
+ let ext = url.pathExtension.lowercased()
+ return allowedExtensions.contains(ext)
+ }
+
+ private func enqueue(_ paths: [String]) {
+ queue.async { [weak self] in
+ guard let self else { return }
+ for path in paths {
+ self.pendingPaths.insert(path)
+ }
+ self.scheduleFlush()
+ }
+ }
+
+ private func scheduleFlush() {
+ flushTimer?.cancel()
+ if pendingPaths.count >= 10 {
+ flush()
+ return
+ }
+ let work = DispatchWorkItem { [weak self] in self?.flush() }
+ flushTimer = work
+ queue.asyncAfter(deadline: .now() + 0.1, execute: work)
+ }
+
+ private func flush() {
+ guard !pendingPaths.isEmpty else { return }
+ let batch = Array(pendingPaths)
+ pendingPaths.removeAll()
+ AppLogger.shared.log("Detected file changes: \(batch.count) files")
+ for path in batch {
+ AppLogger.shared.log(" → \(path)")
+ }
+ DispatchQueue.main.async { [weak self] in
+ self?.onChange(batch)
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/HotKeyManager.swift b/apps/moss-pikachu/MossPikachu/Services/HotKeyManager.swift
new file mode 100644
index 00000000..0b24b9c8
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/HotKeyManager.swift
@@ -0,0 +1,73 @@
+import Carbon
+import Foundation
+
+final class HotKeyManager {
+ var onHotKeyPressed: (() -> Void)?
+
+ private var hotKeyRef: EventHotKeyRef?
+ private var eventHandler: EventHandlerRef?
+ private let hotKeyID = EventHotKeyID(signature: OSType(0x4D4F5353), id: 1) // "MOSS"
+
+ func register() {
+ var eventType = EventTypeSpec(
+ eventClass: OSType(kEventClassKeyboard),
+ eventKind: UInt32(kEventHotKeyPressed)
+ )
+
+ let handler: EventHandlerUPP = { _, event, userData -> OSStatus in
+ guard let event, let userData else { return OSStatus(eventNotHandledErr) }
+
+ var hotKeyID = EventHotKeyID()
+ let status = GetEventParameter(
+ event,
+ EventParamName(kEventParamDirectObject),
+ EventParamType(typeEventHotKeyID),
+ nil,
+ MemoryLayout.size,
+ nil,
+ &hotKeyID
+ )
+ guard status == noErr else { return status }
+
+ let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue()
+ if hotKeyID.id == manager.hotKeyID.id {
+ manager.onHotKeyPressed?()
+ return noErr
+ }
+ return OSStatus(eventNotHandledErr)
+ }
+
+ let selfPtr = Unmanaged.passUnretained(self).toOpaque()
+ InstallEventHandler(
+ GetApplicationEventTarget(),
+ handler,
+ 1,
+ &eventType,
+ selfPtr,
+ &eventHandler
+ )
+
+ // Key code 46 = M on US keyboard; cmd + shift
+ let keyCode: UInt32 = 46
+ let modifiers: UInt32 = UInt32(cmdKey | shiftKey)
+ RegisterEventHotKey(
+ keyCode,
+ modifiers,
+ hotKeyID,
+ GetApplicationEventTarget(),
+ 0,
+ &hotKeyRef
+ )
+ }
+
+ func unregister() {
+ if let hotKeyRef {
+ UnregisterEventHotKey(hotKeyRef)
+ self.hotKeyRef = nil
+ }
+ if let eventHandler {
+ RemoveEventHandler(eventHandler)
+ self.eventHandler = nil
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/IndexManager.swift b/apps/moss-pikachu/MossPikachu/Services/IndexManager.swift
new file mode 100644
index 00000000..31cbd8cf
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/IndexManager.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+final class IndexManager {
+ private let manifestURL: URL
+ private var manifest = IndexManifest()
+ private let queue = DispatchQueue(label: "dev.moss.pikachu.indexmanager")
+
+ init(manifestFilename: String = "index-manifest.json") {
+ let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let dir = appSupport.appendingPathComponent("MossPikachu", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ manifestURL = dir.appendingPathComponent(manifestFilename)
+ load()
+ }
+
+ var indexedFileCount: Int { manifest.files.count }
+ var lastIndexedDate: Date? { manifest.lastIndexedDate }
+
+ func load() {
+ guard let data = try? Data(contentsOf: manifestURL),
+ let loaded = try? JSONDecoder().decode(IndexManifest.self, from: data) else { return }
+ manifest = loaded
+ }
+
+ func save() {
+ manifest.lastIndexedDate = Date()
+ queue.sync {
+ if let data = try? JSONEncoder().encode(manifest) {
+ try? data.write(to: manifestURL, options: .atomic)
+ }
+ }
+ }
+
+ func clear() {
+ manifest = IndexManifest()
+ save()
+ }
+
+ func filesNeedingIndex(in paths: [String]) -> [String] {
+ paths.filter { path in
+ guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
+ let modDate = attrs[.modificationDate] as? Date else { return false }
+ let mtime = modDate.timeIntervalSince1970
+ if let existing = manifest.files[path] {
+ return existing.modificationDate < mtime
+ }
+ return true
+ }
+ }
+
+ func markIndexed(paths: [String]) {
+ for path in paths {
+ guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
+ let modDate = attrs[.modificationDate] as? Date else { continue }
+ manifest.files[path] = IndexedFileRecord(path: path, modificationDate: modDate.timeIntervalSince1970)
+ }
+ save()
+ }
+
+ func allIndexedPaths() -> [String] {
+ Array(manifest.files.keys)
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/MossBridge.swift b/apps/moss-pikachu/MossPikachu/Services/MossBridge.swift
new file mode 100644
index 00000000..b7a974ac
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/MossBridge.swift
@@ -0,0 +1,269 @@
+import Foundation
+import Security
+
+enum MossBridgeError: LocalizedError {
+ case workerNotFound
+ case workerStartFailed(String)
+ case workerCrashed
+ case timeout
+ case invalidResponse
+ case mossError(String)
+ case missingCredentials
+
+ var errorDescription: String? {
+ switch self {
+ case .workerNotFound: return "moss_worker.py not found in app bundle."
+ case .workerStartFailed(let msg): return "Failed to start Moss worker: \(msg)"
+ case .workerCrashed: return "Moss worker process crashed."
+ case .timeout: return "Moss worker request timed out."
+ case .invalidResponse: return "Invalid response from Moss worker."
+ case .mossError(let msg): return msg
+ case .missingCredentials: return "Moss credentials missing. Set MOSS_PROJECT_ID and MOSS_PROJECT_KEY."
+ }
+ }
+}
+
+final class MossBridge: @unchecked Sendable {
+ private var process: Process?
+ private var stdinHandle: FileHandle?
+ private let ioQueue = DispatchQueue(label: "dev.moss.pikachu.mossbridge")
+ private var readBuffer = ""
+ private var pendingContinuations: [CheckedContinuation<[String: Any], Error>] = []
+ private var isReading = false
+
+ private let projectID: String
+ private let projectKey: String
+
+ init(projectID: String, projectKey: String) {
+ self.projectID = projectID
+ self.projectKey = projectKey
+ }
+
+ static func loadCredentials() throws -> (String, String) {
+ if let id = ProcessInfo.processInfo.environment["MOSS_PROJECT_ID"],
+ let key = ProcessInfo.processInfo.environment["MOSS_PROJECT_KEY"],
+ !id.isEmpty, !key.isEmpty {
+ return (id, key)
+ }
+
+ if let id = KeychainHelper.read(account: "project_id"),
+ let key = KeychainHelper.read(account: "project_key"),
+ !id.isEmpty, !key.isEmpty {
+ return (id, key)
+ }
+
+ if let creds = DotEnvLoader.mossCredentials() {
+ return creds
+ }
+
+ throw MossBridgeError.missingCredentials
+ }
+
+ func start() throws {
+ guard process == nil else { return }
+
+ guard let workerURL = Bundle.main.url(forResource: "moss_worker", withExtension: "py") else {
+ // Dev fallback: source tree
+ let devPath = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appendingPathComponent("Resources/moss_worker.py")
+ if FileManager.default.fileExists(atPath: devPath.path) {
+ try launchWorker(scriptURL: devPath)
+ return
+ }
+ throw MossBridgeError.workerNotFound
+ }
+ try launchWorker(scriptURL: workerURL)
+ }
+
+ private func launchWorker(scriptURL: URL) throws {
+ let proc = Process()
+ let pythonPath = resolvePythonPath()
+
+ proc.executableURL = URL(fileURLWithPath: pythonPath)
+ proc.arguments = [scriptURL.path]
+ proc.environment = ProcessInfo.processInfo.environment.merging([
+ "MOSS_PROJECT_ID": projectID,
+ "MOSS_PROJECT_KEY": projectKey,
+ "PYTHONUNBUFFERED": "1"
+ ]) { _, new in new }
+
+ let stdinPipe = Pipe()
+ let stdoutPipe = Pipe()
+ proc.standardInput = stdinPipe
+ proc.standardOutput = stdoutPipe
+ proc.standardError = Pipe()
+
+ proc.terminationHandler = { [weak self] _ in
+ self?.ioQueue.async {
+ self?.failPending(MossBridgeError.workerCrashed)
+ }
+ }
+
+ do {
+ try proc.run()
+ } catch {
+ throw MossBridgeError.workerStartFailed(error.localizedDescription)
+ }
+
+ process = proc
+ stdinHandle = stdinPipe.fileHandleForWriting
+
+ let readHandle = stdoutPipe.fileHandleForReading
+ readHandle.readabilityHandler = { [weak self] handle in
+ let data = handle.availableData
+ guard !data.isEmpty else { return }
+ self?.ioQueue.async {
+ self?.appendOutput(data)
+ }
+ }
+ isReading = true
+ }
+
+ private func resolvePythonPath() -> String {
+ let repoRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let venvPython = repoRoot.appendingPathComponent(".venv/bin/python3").path
+ if FileManager.default.isExecutableFile(atPath: venvPython) {
+ return venvPython
+ }
+ return "/usr/bin/python3"
+ }
+
+ func stop() {
+ if let process, process.isRunning {
+ sendRawLine(#"{"action":"shutdown"}"#)
+ process.terminate()
+ }
+ process = nil
+ stdinHandle = nil
+ }
+
+ func initSession(indexName: String) async throws {
+ _ = try await send(action: "init_session", payload: ["index_name": indexName])
+ }
+
+ func addDocs(files: [String]) async throws -> (added: Int, updated: Int, chunks: Int, filesIndexed: Int, skipped: Int) {
+ let response = try await send(action: "add_docs", payload: ["files": files])
+ return (
+ added: response["added"] as? Int ?? 0,
+ updated: response["updated"] as? Int ?? 0,
+ chunks: response["chunks_indexed"] as? Int ?? 0,
+ filesIndexed: response["files_indexed"] as? Int ?? 0,
+ skipped: response["files_skipped"] as? Int ?? 0
+ )
+ }
+
+ func query(indexName: String, query: String, topK: Int = 5) async throws -> [SearchResult] {
+ let response = try await send(
+ action: "query",
+ payload: ["index": indexName, "query": query, "top_k": topK]
+ )
+ if let error = response["error"] as? String {
+ throw MossBridgeError.mossError(error)
+ }
+ let timingMs = response["timing_ms"] as? Double ?? 0
+ guard let resultsArray = response["results"] as? [[String: Any]] else {
+ return []
+ }
+ return resultsArray.compactMap { dict in
+ guard let id = dict["id"] as? String,
+ let text = dict["text"] as? String,
+ let score = dict["score"] as? Double else { return nil }
+ let path = dict["path"] as? String ?? id
+ let filename = dict["filename"] as? String ?? URL(fileURLWithPath: path).lastPathComponent
+ return SearchResult(
+ id: id, text: text, score: score,
+ filename: filename, path: path, timingMs: timingMs
+ )
+ }
+ }
+
+ func pushIndex() async throws {
+ _ = try await send(action: "push_index", payload: [:])
+ }
+
+ func clearIndex() async throws {
+ _ = try await send(action: "clear_index", payload: [:])
+ }
+
+ // MARK: - Private
+
+ private func send(action: String, payload: [String: Any]) async throws -> [String: Any] {
+ try start()
+ var body = payload
+ body["action"] = action
+ let data = try JSONSerialization.data(withJSONObject: body)
+ guard let line = String(data: data, encoding: .utf8) else {
+ throw MossBridgeError.invalidResponse
+ }
+ return try await withCheckedThrowingContinuation { continuation in
+ ioQueue.async { [weak self] in
+ guard let self else { return }
+ self.pendingContinuations.append(continuation)
+ self.sendRawLine(line)
+ }
+ }
+ }
+
+ private func sendRawLine(_ line: String) {
+ guard let data = (line + "\n").data(using: .utf8),
+ let stdinHandle else { return }
+ try? stdinHandle.write(contentsOf: data)
+ }
+
+ private func appendOutput(_ data: Data) {
+ guard let chunk = String(data: data, encoding: .utf8) else { return }
+ readBuffer += chunk
+ while let newlineIndex = readBuffer.firstIndex(of: "\n") {
+ let line = String(readBuffer[..) {
+ guard !pendingContinuations.isEmpty else { return }
+ let continuation = pendingContinuations.removeFirst()
+ switch result {
+ case .success(let json):
+ continuation.resume(returning: json)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ private func failPending(_ error: Error) {
+ while !pendingContinuations.isEmpty {
+ let c = pendingContinuations.removeFirst()
+ c.resume(throwing: error)
+ }
+ }
+}
+
+enum KeychainHelper {
+ static func read(account: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "dev.moss.pikachu",
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ guard status == errSecSuccess, let data = item as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/NotificationManager.swift b/apps/moss-pikachu/MossPikachu/Services/NotificationManager.swift
new file mode 100644
index 00000000..4a2bac87
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/NotificationManager.swift
@@ -0,0 +1,63 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class NotificationManager {
+ static let shared = NotificationManager()
+
+ private var toastWindow: NSPanel?
+
+ func showError(_ message: String) {
+ showToast(message, duration: 3, isError: true)
+ }
+
+ func showSuccess(_ message: String) {
+ showToast(message, duration: 1, isError: false)
+ }
+
+ private func showToast(_ message: String, duration: TimeInterval, isError: Bool) {
+ toastWindow?.orderOut(nil)
+
+ let toastView = HStack(spacing: 8) {
+ Image(systemName: isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
+ .foregroundStyle(isError ? .orange : .green)
+ Text(message)
+ .font(.subheadline)
+ .lineLimit(2)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(.regularMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .shadow(radius: 8)
+
+ let hosting = NSHostingView(rootView: toastView)
+ hosting.frame.size = hosting.fittingSize
+
+ let panel = NSPanel(
+ contentRect: NSRect(origin: .zero, size: hosting.frame.size),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.level = .statusBar
+ panel.contentView = hosting
+ panel.hasShadow = false
+
+ if let screen = NSScreen.main {
+ let x = screen.visibleFrame.maxX - hosting.frame.width - 20
+ let y = screen.visibleFrame.minY + 20
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
+ }
+
+ panel.orderFrontRegardless()
+ toastWindow = panel
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
+ self?.toastWindow?.orderOut(nil)
+ self?.toastWindow = nil
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Services/SearchService.swift b/apps/moss-pikachu/MossPikachu/Services/SearchService.swift
new file mode 100644
index 00000000..37768d0d
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Services/SearchService.swift
@@ -0,0 +1,191 @@
+import Foundation
+import Combine
+
+@MainActor
+final class SearchService: ObservableObject {
+ private var mossBridge: MossBridge?
+ private let fileMonitor = FileMonitor()
+ private let indexManager = IndexManager(manifestFilename: IndexScope.manifestFilename)
+ private var settings = UserSettings.load()
+ private var saveTask: Task?
+
+ @Published private(set) var indexedFileCount: Int = 0
+ @Published private(set) var indexedChunkCount: Int = 0
+ @Published private(set) var lastIndexedDate: Date?
+ @Published private(set) var statusMessage: String = "Not started"
+ @Published private(set) var watchedFolderPathsList: [String] = []
+ @Published private(set) var isIndexing: Bool = false
+
+ func initialize() async throws {
+ statusMessage = "Connecting to Moss..."
+ let (projectID, projectKey) = try MossBridge.loadCredentials()
+ let bridge = MossBridge(projectID: projectID, projectKey: projectKey)
+ try bridge.start()
+ try await bridge.initSession(indexName: IndexScope.sessionName)
+ mossBridge = bridge
+
+ fileMonitor.onChange = { [weak self] files in
+ Task { @MainActor in
+ await self?.indexFiles(files)
+ }
+ }
+ refreshWatchedPaths()
+
+ guard !watchedFolderPathsList.isEmpty else {
+ statusMessage = missingScopeMessage
+ return
+ }
+
+ _ = fileMonitor.start()
+ await performInitialScan()
+ }
+
+ func shutdown() {
+ fileMonitor.stop()
+ if settings.mossCloudSync {
+ Task {
+ try? await mossBridge?.pushIndex()
+ }
+ }
+ mossBridge?.stop()
+ }
+
+ func updateSettings(_ newSettings: UserSettings) {
+ settings = newSettings
+ refreshWatchedPaths()
+ }
+
+ func search(_ query: String) async throws -> [SearchResult] {
+ guard let mossBridge else {
+ throw MossBridgeError.workerCrashed
+ }
+ return try await mossBridge.query(indexName: IndexScope.sessionName, query: query, topK: 8)
+ }
+
+ func reindexNow() async throws {
+ await performInitialScan(forceAll: true)
+ }
+
+ func clearIndexAndRescan() async throws {
+ try await mossBridge?.clearIndex()
+ indexManager.clear()
+ indexedFileCount = 0
+ indexedChunkCount = 0
+ await performInitialScan(forceAll: true)
+ }
+
+ // MARK: - Private
+
+ private var missingScopeMessage: String {
+ "Folder not found: ~/Downloads/\(IndexScope.folderName)"
+ }
+
+ private func refreshWatchedPaths() {
+ let paths = watchedFolderPaths()
+ watchedFolderPathsList = paths
+ fileMonitor.updateWatchedPaths(paths)
+ }
+
+ private func performInitialScan(forceAll: Bool = false) async {
+ let folders = watchedFolderPaths()
+ guard !folders.isEmpty else {
+ statusMessage = missingScopeMessage
+ isIndexing = false
+ return
+ }
+
+ isIndexing = true
+ statusMessage = "Scanning \(IndexScope.folderName)..."
+ let allFiles = discoverFiles(in: folders)
+ let needingIndex = forceAll ? allFiles : indexManager.filesNeedingIndex(in: allFiles)
+ statusMessage = "Found \(allFiles.count) files, indexing \(needingIndex.count)..."
+ guard !needingIndex.isEmpty else {
+ indexedFileCount = indexManager.indexedFileCount
+ lastIndexedDate = indexManager.lastIndexedDate
+ statusMessage = "Up to date (\(indexedFileCount) files)"
+ isIndexing = false
+ return
+ }
+ await indexFiles(needingIndex)
+ isIndexing = false
+ }
+
+ private func indexFiles(_ paths: [String]) async {
+ let inScope = paths.filter { path in
+ FileManager.default.fileExists(atPath: path) && IndexScope.contains(path: path)
+ }
+ guard !inScope.isEmpty, let mossBridge else { return }
+
+ isIndexing = true
+ statusMessage = "Indexing \(inScope.count) files..."
+
+ let batchSize = 15
+ var offset = 0
+ while offset < inScope.count {
+ let end = min(offset + batchSize, inScope.count)
+ let batch = Array(inScope[offset.. [String] {
+ guard let url = IndexScope.watchedFolderURL else { return [] }
+ return [url.path]
+ }
+
+ private func discoverFiles(in folders: [String]) -> [String] {
+ let allowed = FileMonitor.indexableExtensions
+ var results: [String] = []
+ let fm = FileManager.default
+
+ for folder in folders {
+ guard IndexScope.contains(path: folder) else { continue }
+ guard let enumerator = fm.enumerator(
+ at: URL(fileURLWithPath: folder),
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles, .skipsPackageDescendants]
+ ) else {
+ NotificationManager.shared.showError("Cannot access folder: \(folder)")
+ continue
+ }
+ for case let fileURL as URL in enumerator {
+ let path = fileURL.path
+ if path.contains("/node_modules/") || path.contains("/.git/") { continue }
+ if path.contains("/Library/Caches/") { continue }
+ var isDir: ObjCBool = false
+ guard fm.fileExists(atPath: path, isDirectory: &isDir), !isDir.boolValue else { continue }
+ let ext = fileURL.pathExtension.lowercased()
+ if allowed.contains(ext) {
+ results.append(path)
+ }
+ }
+ }
+ return results
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/FloatingPetWindowController.swift b/apps/moss-pikachu/MossPikachu/Views/FloatingPetWindowController.swift
new file mode 100644
index 00000000..d8c0d92e
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/FloatingPetWindowController.swift
@@ -0,0 +1,231 @@
+import AppKit
+import SwiftUI
+import Combine
+
+@MainActor
+final class PetStateController: ObservableObject {
+ @Published var state: PetState = .idle
+}
+
+@MainActor
+final class FloatingPetWindowController: NSObject {
+ private let panel: NSPanel
+ private let petStateController: PetStateController
+ private var hostingView: NSHostingView?
+ private var contentView: PetWindowContentView?
+
+ private let petSize: CGFloat = 80
+ private let positionKeyX = "MossPikachu.petOriginX"
+ private let positionKeyY = "MossPikachu.petOriginY"
+
+ var onPetClicked: (() -> Void)?
+ var onShowSettings: (() -> Void)?
+ var onQuit: (() -> Void)?
+
+ var screenFrame: NSRect {
+ panel.frame
+ }
+
+ init(petStateController: PetStateController) {
+ self.petStateController = petStateController
+
+ panel = NSPanel(
+ contentRect: NSRect(x: 0, y: 0, width: 80, height: 80),
+ styleMask: [.nonactivatingPanel, .borderless, .fullSizeContentView],
+ backing: .buffered,
+ defer: false
+ )
+
+ super.init()
+
+ configurePanel()
+ setupContent()
+ restorePosition()
+ }
+
+ func show() {
+ panel.orderFrontRegardless()
+ }
+
+ private func configurePanel() {
+ panel.isFloatingPanel = true
+ panel.level = .floating
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = false
+ panel.hidesOnDeactivate = false
+ panel.isMovableByWindowBackground = false
+ panel.titleVisibility = .hidden
+ panel.titlebarAppearsTransparent = true
+ }
+
+ private func setupContent() {
+ let wrapper = PetStateObservingView(
+ petStateController: petStateController
+ )
+
+ let hosting = NSHostingView(rootView: wrapper)
+ hostingView = hosting
+
+ let container = PetWindowContentView(frame: NSRect(x: 0, y: 0, width: petSize, height: petSize))
+ container.onClick = { [weak self] in
+ // Defer until after mouseUp so click-outside monitors don't interfere.
+ DispatchQueue.main.async {
+ self?.onPetClicked?()
+ }
+ }
+ container.onRightClick = { [weak self] location in
+ self?.showContextMenu(at: location, in: container)
+ }
+ container.onDragEnded = { [weak self] in
+ self?.clampToVisibleScreen()
+ self?.savePosition()
+ }
+ contentView = container
+
+ container.addSubview(hosting)
+ hosting.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ hosting.topAnchor.constraint(equalTo: container.topAnchor),
+ hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ ])
+
+ panel.contentView = container
+ }
+
+ private func showContextMenu(at location: NSPoint, in view: NSView) {
+ let menu = NSMenu()
+ let searchItem = NSMenuItem(title: "Search", action: #selector(menuSearch), keyEquivalent: "")
+ let settingsItem = NSMenuItem(title: "Settings…", action: #selector(menuSettings), keyEquivalent: ",")
+ let quitItem = NSMenuItem(title: "Quit Moss Pikachu", action: #selector(menuQuit), keyEquivalent: "q")
+ [searchItem, settingsItem, quitItem].forEach { $0.target = self }
+ menu.addItem(searchItem)
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(settingsItem)
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(quitItem)
+ menu.popUp(positioning: nil, at: location, in: view)
+ }
+
+ @objc private func menuSearch() {
+ onPetClicked?()
+ }
+
+ @objc private func menuSettings() {
+ onShowSettings?()
+ }
+
+ @objc private func menuQuit() {
+ onQuit?()
+ }
+
+ private func restorePosition() {
+ let defaults = UserDefaults.standard
+ guard let screen = NSScreen.main else { return }
+
+ let screenFrame = screen.visibleFrame
+ let savedX = defaults.object(forKey: positionKeyX) as? CGFloat
+ let savedY = defaults.object(forKey: positionKeyY) as? CGFloat
+
+ let origin: NSPoint
+ if let savedX, let savedY {
+ origin = clampedOrigin(NSPoint(x: savedX, y: savedY), on: screen)
+ } else {
+ origin = NSPoint(
+ x: screenFrame.maxX - petSize - 24,
+ y: screenFrame.minY + 24
+ )
+ }
+
+ panel.setFrameOrigin(origin)
+ }
+
+ private func savePosition() {
+ let origin = panel.frame.origin
+ UserDefaults.standard.set(origin.x, forKey: positionKeyX)
+ UserDefaults.standard.set(origin.y, forKey: positionKeyY)
+ }
+
+ private func clampToVisibleScreen() {
+ guard let screen = NSScreen.main else { return }
+ panel.setFrameOrigin(clampedOrigin(panel.frame.origin, on: screen))
+ }
+
+ private func clampedOrigin(_ origin: NSPoint, on screen: NSScreen) -> NSPoint {
+ let screenFrame = screen.visibleFrame
+ let x = min(max(origin.x, screenFrame.minX), screenFrame.maxX - petSize)
+ let y = min(max(origin.y, screenFrame.minY), screenFrame.maxY - petSize)
+ return NSPoint(x: x, y: y)
+ }
+}
+
+private struct PetStateObservingView: View {
+ @ObservedObject var petStateController: PetStateController
+
+ var body: some View {
+ PikachuPetView(petState: petStateController.state, size: 64)
+ }
+}
+
+private final class PetWindowContentView: NSView {
+ var onClick: (() -> Void)?
+ var onRightClick: ((NSPoint) -> Void)?
+ var onDragEnded: (() -> Void)?
+
+ private var dragStartMouseLocation: NSPoint?
+ private var dragStartWindowOrigin: NSPoint?
+ private var didDrag = false
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ dragStartMouseLocation = NSEvent.mouseLocation
+ dragStartWindowOrigin = window?.frame.origin
+ didDrag = false
+ }
+
+ override func mouseDragged(with event: NSEvent) {
+ guard let window,
+ let dragStartMouseLocation,
+ let dragStartWindowOrigin else {
+ return
+ }
+
+ let delta = NSPoint(
+ x: NSEvent.mouseLocation.x - dragStartMouseLocation.x,
+ y: NSEvent.mouseLocation.y - dragStartMouseLocation.y
+ )
+
+ if abs(delta.x) > 3 || abs(delta.y) > 3 {
+ didDrag = true
+ }
+
+ let origin = NSPoint(
+ x: dragStartWindowOrigin.x + delta.x,
+ y: dragStartWindowOrigin.y + delta.y
+ )
+ window.setFrameOrigin(origin)
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ if didDrag {
+ onDragEnded?()
+ } else if event.type == .rightMouseUp {
+ onRightClick?(event.locationInWindow)
+ } else {
+ onClick?()
+ }
+ dragStartMouseLocation = nil
+ dragStartWindowOrigin = nil
+ didDrag = false
+ }
+
+ override func rightMouseDown(with event: NSEvent) {
+ onRightClick?(event.locationInWindow)
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/KeyEventHandler.swift b/apps/moss-pikachu/MossPikachu/Views/KeyEventHandler.swift
new file mode 100644
index 00000000..f9b333ef
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/KeyEventHandler.swift
@@ -0,0 +1,33 @@
+import AppKit
+import SwiftUI
+
+struct KeyEventHandler: NSViewRepresentable {
+ var shouldHandleKeys: () -> Bool = { true }
+ let onKeyDown: (NSEvent) -> Bool
+
+ func makeNSView(context: Context) -> KeyCatcherView {
+ let view = KeyCatcherView()
+ view.shouldHandleKeys = shouldHandleKeys
+ view.onKeyDown = onKeyDown
+ return view
+ }
+
+ func updateNSView(_ nsView: KeyCatcherView, context: Context) {
+ nsView.shouldHandleKeys = shouldHandleKeys
+ nsView.onKeyDown = onKeyDown
+ }
+}
+
+final class KeyCatcherView: NSView {
+ var shouldHandleKeys: (() -> Bool)?
+ var onKeyDown: ((NSEvent) -> Bool)?
+
+ override var acceptsFirstResponder: Bool { false }
+
+ override func keyDown(with event: NSEvent) {
+ guard shouldHandleKeys?() == true, onKeyDown?(event) == true else {
+ super.keyDown(with: event)
+ return
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/PikachuPetView.swift b/apps/moss-pikachu/MossPikachu/Views/PikachuPetView.swift
new file mode 100644
index 00000000..1636beb8
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/PikachuPetView.swift
@@ -0,0 +1,150 @@
+import SwiftUI
+
+struct PikachuPetView: View {
+ let petState: PetState
+ var size: CGFloat = 64
+
+ @State private var breatheScale: CGFloat = 1.0
+ @State private var tailRotation: Double = 0
+ @State private var headTilt: Double = 0
+ @State private var bounceScale: CGFloat = 1.0
+ @State private var isBlinking = false
+ @State private var thinkingDots = ""
+ @State private var showSparkles = false
+ @State private var isHovered = false
+ @State private var isSearchingAnimation = false
+
+ var body: some View {
+ ZStack {
+ if showSparkles {
+ HStack(spacing: 4) {
+ Text("✨")
+ Text("✨")
+ }
+ .offset(y: -size * 0.6)
+ .transition(.scale.combined(with: .opacity))
+ }
+
+ CapvoltStickerImage(size: size)
+ .scaleEffect(bounceScale * breatheScale * (isHovered ? 1.05 : 1.0))
+ .rotationEffect(.degrees(headTilt))
+ .opacity(isBlinking ? 0.35 : 1.0)
+ .rotationEffect(.degrees(tailRotation), anchor: .bottomTrailing)
+
+ if petState == .searching {
+ Text(thinkingDots)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .offset(y: size * 0.55)
+ }
+
+ if petState == .notFound {
+ Text("😢")
+ .font(.system(size: size * 0.22))
+ .offset(y: size * 0.38)
+ }
+ }
+ .frame(width: size, height: size)
+ .onHover { isHovered = $0 }
+ .onAppear { startIdleAnimations() }
+ .onChange(of: petState) { newState in
+ applyState(newState)
+ }
+ .animation(.easeInOut(duration: 0.3), value: petState)
+ }
+
+ private func startIdleAnimations() {
+ withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
+ breatheScale = 1.02
+ }
+ scheduleBlink()
+ scheduleTailTwitch()
+ applyState(petState)
+ }
+
+ private func applyState(_ state: PetState) {
+ switch state {
+ case .idle:
+ isSearchingAnimation = false
+ showSparkles = false
+ headTilt = 0
+ tailRotation = 0
+ withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
+ breatheScale = 1.02
+ }
+ case .searching:
+ isSearchingAnimation = true
+ showSparkles = false
+ withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
+ tailRotation = 12
+ }
+ scheduleThinkingDots()
+ case .found:
+ isSearchingAnimation = false
+ tailRotation = 0
+ celebrate()
+ case .notFound:
+ isSearchingAnimation = false
+ showSparkles = false
+ withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
+ headTilt = 8
+ }
+ }
+ }
+
+ private func celebrate() {
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) {
+ bounceScale = 1.15
+ showSparkles = true
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) {
+ bounceScale = 1.0
+ }
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+ showSparkles = false
+ }
+ }
+
+ private func scheduleBlink() {
+ let delay = Double.random(in: 5...8)
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+ withAnimation(.easeInOut(duration: 0.15)) { isBlinking = true }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+ withAnimation(.easeInOut(duration: 0.15)) { isBlinking = false }
+ scheduleBlink()
+ }
+ }
+ }
+
+ private func scheduleTailTwitch() {
+ let delay = Double.random(in: 3...5)
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+ withAnimation(.easeInOut(duration: 0.3)) { tailRotation = 2 }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ withAnimation(.easeInOut(duration: 0.3)) { tailRotation = -2 }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ tailRotation = 0
+ scheduleTailTwitch()
+ }
+ }
+ }
+ }
+
+ private func scheduleThinkingDots() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ guard isSearchingAnimation else {
+ thinkingDots = ""
+ return
+ }
+ switch thinkingDots {
+ case "": thinkingDots = "."
+ case ".": thinkingDots = ".."
+ case "..": thinkingDots = "..."
+ default: thinkingDots = ""
+ }
+ scheduleThinkingDots()
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/ResultsListView.swift b/apps/moss-pikachu/MossPikachu/Views/ResultsListView.swift
new file mode 100644
index 00000000..ae165630
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/ResultsListView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct ResultsListView: View {
+ let results: [SearchResult]
+ let selectedIndex: Int
+ let isSearching: Bool
+ let query: String
+ var compact: Bool = false
+ let onResultTapped: (SearchResult) -> Void
+ let onResultHovered: (Int) -> Void
+
+ var body: some View {
+ Group {
+ if query.isEmpty {
+ EmptyView()
+ } else if isSearching {
+ if compact {
+ EmptyView()
+ } else {
+ searchingView
+ }
+ } else if results.isEmpty {
+ EmptyView()
+ } else {
+ resultsScroll
+ }
+ }
+ .frame(maxHeight: compact ? 220 : 400)
+ .padding(.horizontal, compact ? 10 : 12)
+ .padding(.bottom, compact ? 8 : 12)
+ }
+
+ private var searchingView: some View {
+ HStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Searching...")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, minHeight: compact ? 36 : 80, alignment: .leading)
+ .padding(.horizontal, compact ? 6 : 0)
+ }
+
+ private var resultsScroll: some View {
+ ScrollView {
+ VStack(spacing: compact ? 4 : 8) {
+ ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
+ ResultRowView(
+ result: result,
+ isSelected: index == selectedIndex,
+ compact: compact
+ )
+ .onTapGesture { onResultTapped(result) }
+ .onHover { isHovered in
+ if isHovered { onResultHovered(index) }
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ResultRowView: View {
+ let result: SearchResult
+ let isSelected: Bool
+ var compact: Bool = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: compact ? 2 : 4) {
+ HStack {
+ Text(result.filename)
+ .font(compact ? .subheadline : .body)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ Spacer()
+ Text(String(format: "%.0fms", result.timingMs))
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ if !compact {
+ Text(String(result.text.prefix(100)))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .lineLimit(2)
+ } else {
+ Text(result.path)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ }
+ .padding(compact ? 8 : 12)
+ .background(isSelected ? Color.accentColor.opacity(0.15) : Color(.controlBackgroundColor).opacity(0.6))
+ .cornerRadius(compact ? 6 : 8)
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/SearchOverlayController.swift b/apps/moss-pikachu/MossPikachu/Views/SearchOverlayController.swift
new file mode 100644
index 00000000..3a4119fc
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/SearchOverlayController.swift
@@ -0,0 +1,209 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class SearchOverlayController: NSObject {
+ private let panel: SearchOverlayPanel
+ private let searchService: SearchService
+ private let presentation: SearchOverlayPresentation
+ private let anchorProvider: () -> NSRect?
+ private var hostingView: NSHostingView?
+ private var localEventMonitor: Any?
+ private var globalEventMonitor: Any?
+ private var keyEventMonitor: Any?
+ private var isVisible = false
+
+ var onClose: (() -> Void)?
+ var onPetStateChanged: ((PetState) -> Void)?
+
+ private let panelWidth: CGFloat = 520
+ private let collapsedHeight: CGFloat = 56
+ private let maxExpandedHeight: CGFloat = 320
+
+ init(
+ searchService: SearchService,
+ presentation: SearchOverlayPresentation,
+ anchorProvider: @escaping () -> NSRect?
+ ) {
+ self.searchService = searchService
+ self.presentation = presentation
+ self.anchorProvider = anchorProvider
+
+ panel = SearchOverlayPanel(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 56),
+ styleMask: [.borderless, .fullSizeContentView],
+ backing: .buffered,
+ defer: false
+ )
+
+ super.init()
+
+ configurePanel()
+ let contentView = SearchOverlayView(
+ searchService: searchService,
+ presentation: presentation,
+ onClose: { [weak self] in self?.hide() },
+ onHeightChange: { [weak self] height in
+ self?.updatePanelHeight(height)
+ },
+ onPetStateChanged: { [weak self] state in
+ self?.onPetStateChanged?(state)
+ }
+ )
+ let hosting = NSHostingView(rootView: contentView)
+ hostingView = hosting
+ panel.contentView = hosting
+ }
+
+ private func configurePanel() {
+ panel.isFloatingPanel = true
+ panel.level = .popUpMenu
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = true
+ panel.isMovableByWindowBackground = false
+ panel.hidesOnDeactivate = false
+ panel.becomesKeyOnlyIfNeeded = false
+ panel.titleVisibility = .hidden
+ panel.titlebarAppearsTransparent = true
+ }
+
+ func toggle() {
+ if isVisible {
+ hide()
+ } else {
+ show()
+ }
+ }
+
+ func show() {
+ if !isVisible {
+ presentation.requestClearQuery()
+ }
+ updatePanelHeight(collapsedHeight)
+ positionBelowAnchor()
+ isVisible = true
+ NSApp.activate(ignoringOtherApps: true)
+ panel.makeKeyAndOrderFront(nil)
+ installClickOutsideMonitor()
+ installKeyEventMonitor()
+ focusSearchField()
+ }
+
+ func focusSearchField() {
+ presentation.requestFocus()
+ NSApp.activate(ignoringOtherApps: true)
+ panel.makeKeyAndOrderFront(nil)
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ NSApp.activate(ignoringOtherApps: true)
+ self.panel.makeKeyAndOrderFront(nil)
+ self.presentation.requestFocus()
+ }
+ }
+
+ var isSearchVisible: Bool {
+ isVisible
+ }
+
+ func hide() {
+ panel.orderOut(nil)
+ isVisible = false
+ removeClickOutsideMonitor()
+ removeKeyEventMonitor()
+ onPetStateChanged?(.idle)
+ onClose?()
+ }
+
+ private func updatePanelHeight(_ contentHeight: CGFloat) {
+ let height = min(max(contentHeight, collapsedHeight), maxExpandedHeight)
+ var frame = panel.frame
+ let oldMaxY = frame.maxY
+ frame.size = NSSize(width: panelWidth, height: height)
+ frame.origin.y = oldMaxY - height
+ panel.setFrame(frame, display: true, animate: false)
+ if isVisible {
+ positionBelowAnchor()
+ }
+ }
+
+ private func positionBelowAnchor() {
+ if let anchor = anchorProvider(), anchor != .zero {
+ let x = anchor.midX - panelWidth / 2
+ let y = anchor.minY - panel.frame.height - 8
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
+ return
+ }
+
+ guard let screen = NSScreen.main else { return }
+ let screenFrame = screen.visibleFrame
+ let x = screenFrame.midX - panelWidth / 2
+ let y = screenFrame.maxY - panel.frame.height - 12
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
+ }
+
+ private func installClickOutsideMonitor() {
+ removeClickOutsideMonitor()
+
+ localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
+ guard let self, self.isVisible else { return event }
+ if self.shouldDismissForClick(at: NSEvent.mouseLocation) {
+ self.hide()
+ }
+ return event
+ }
+
+ globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in
+ guard let self, self.isVisible else { return }
+ if self.shouldDismissForClick(at: NSEvent.mouseLocation) {
+ self.hide()
+ }
+ }
+ }
+
+ private func shouldDismissForClick(at screenPoint: NSPoint) -> Bool {
+ if panel.frame.contains(screenPoint) {
+ return false
+ }
+ if let anchor = anchorProvider(), anchor.contains(screenPoint) {
+ return false
+ }
+ return true
+ }
+
+ private func installKeyEventMonitor() {
+ removeKeyEventMonitor()
+
+ keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+ guard let self, self.isVisible else { return event }
+ if self.presentation.keyboardBridge.handleKeyDown(event) {
+ return nil
+ }
+ return event
+ }
+ }
+
+ private func removeKeyEventMonitor() {
+ if let keyEventMonitor {
+ NSEvent.removeMonitor(keyEventMonitor)
+ self.keyEventMonitor = nil
+ }
+ }
+
+ private func removeClickOutsideMonitor() {
+ if let localEventMonitor {
+ NSEvent.removeMonitor(localEventMonitor)
+ self.localEventMonitor = nil
+ }
+ if let globalEventMonitor {
+ NSEvent.removeMonitor(globalEventMonitor)
+ self.globalEventMonitor = nil
+ }
+ }
+}
+
+private final class SearchOverlayPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+ override var canBecomeMain: Bool { true }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/SearchOverlayPresentation.swift b/apps/moss-pikachu/MossPikachu/Views/SearchOverlayPresentation.swift
new file mode 100644
index 00000000..4b636693
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/SearchOverlayPresentation.swift
@@ -0,0 +1,46 @@
+import Foundation
+import Combine
+import AppKit
+
+@MainActor
+final class SearchKeyboardBridge: ObservableObject {
+ @Published var selectedIndex = 0
+ var hasResults = false
+ var resultCount = 0
+
+ func handleKeyDown(_ event: NSEvent) -> Bool {
+ guard hasResults, resultCount > 0 else { return false }
+ switch event.keyCode {
+ case 126:
+ selectedIndex = max(0, selectedIndex - 1)
+ return true
+ case 125:
+ selectedIndex = min(resultCount - 1, selectedIndex + 1)
+ return true
+ case 48:
+ selectedIndex = (selectedIndex + 1) % resultCount
+ return true
+ default:
+ return false
+ }
+ }
+
+ func resetSelection() {
+ selectedIndex = 0
+ }
+}
+
+@MainActor
+final class SearchOverlayPresentation: ObservableObject {
+ @Published var focusToken = UUID()
+ @Published var clearQueryToken = UUID()
+ let keyboardBridge = SearchKeyboardBridge()
+
+ func requestFocus() {
+ focusToken = UUID()
+ }
+
+ func requestClearQuery() {
+ clearQueryToken = UUID()
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/SearchOverlayView.swift b/apps/moss-pikachu/MossPikachu/Views/SearchOverlayView.swift
new file mode 100644
index 00000000..fe862fdd
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/SearchOverlayView.swift
@@ -0,0 +1,233 @@
+import AppKit
+import SwiftUI
+
+struct SearchOverlayView: View {
+ @ObservedObject var searchService: SearchService
+ @ObservedObject var presentation: SearchOverlayPresentation
+ @ObservedObject private var keyboardBridge: SearchKeyboardBridge
+ let onClose: () -> Void
+ let onHeightChange: (CGFloat) -> Void
+ let onPetStateChanged: (PetState) -> Void
+
+ @State private var query = ""
+ @State private var results: [SearchResult] = []
+ @State private var isSearching = false
+ @State private var lastSearchTimingMs: Double = 0
+ @State private var searchTask: Task?
+ @FocusState private var isSearchFocused: Bool
+
+ init(
+ searchService: SearchService,
+ presentation: SearchOverlayPresentation,
+ onClose: @escaping () -> Void,
+ onHeightChange: @escaping (CGFloat) -> Void = { _ in },
+ onPetStateChanged: @escaping (PetState) -> Void = { _ in }
+ ) {
+ self.searchService = searchService
+ self.presentation = presentation
+ _keyboardBridge = ObservedObject(wrappedValue: presentation.keyboardBridge)
+ self.onClose = onClose
+ self.onHeightChange = onHeightChange
+ self.onPetStateChanged = onPetStateChanged
+ }
+
+ private var showResultsArea: Bool {
+ !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ private var preferredHeight: CGFloat {
+ guard showResultsArea else { return 56 }
+ let resultRows = max(1, min(results.count, 4))
+ let base: CGFloat = 56
+ let statusLine: CGFloat = 22
+ let rowHeight: CGFloat = 52
+ if isSearching || results.isEmpty {
+ return base + statusLine + 44
+ }
+ return base + statusLine + CGFloat(resultRows) * rowHeight + 8
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ searchBar
+
+ if showResultsArea {
+ Divider()
+ .padding(.horizontal, 12)
+
+ statusLine
+ .padding(.horizontal, 16)
+ .padding(.top, 6)
+
+ ResultsListView(
+ results: results,
+ selectedIndex: keyboardBridge.selectedIndex,
+ isSearching: isSearching,
+ query: query,
+ compact: true,
+ onResultTapped: openResult,
+ onResultHovered: { keyboardBridge.selectedIndex = $0 }
+ )
+ }
+ }
+ .frame(width: 520)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(.ultraThinMaterial)
+ .shadow(color: .black.opacity(0.18), radius: 16, y: 6)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .onAppear {
+ syncKeyboardBridge()
+ onHeightChange(preferredHeight)
+ focusSearchField()
+ }
+ .onChange(of: presentation.focusToken) { _ in
+ focusSearchField()
+ }
+ .onChange(of: presentation.clearQueryToken) { _ in
+ query = ""
+ results = []
+ isSearching = false
+ presentation.keyboardBridge.resetSelection()
+ lastSearchTimingMs = 0
+ onHeightChange(preferredHeight)
+ focusSearchField()
+ }
+ .onChange(of: query) { newValue in
+ performSearch(query: newValue)
+ onHeightChange(preferredHeight)
+ }
+ .onChange(of: results.count) { _ in
+ syncKeyboardBridge()
+ onHeightChange(preferredHeight)
+ }
+ .onChange(of: keyboardBridge.selectedIndex) { _ in
+ onHeightChange(preferredHeight)
+ }
+ .onChange(of: isSearching) { searching in
+ onHeightChange(preferredHeight)
+ if searching {
+ onPetStateChanged(.searching)
+ }
+ }
+ .onExitCommand {
+ onClose()
+ }
+ }
+
+ @ViewBuilder
+ private var statusLine: some View {
+ if searchService.isIndexing {
+ Text("Indexing files...")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else if isSearching {
+ Text("Searching...")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else if lastSearchTimingMs > 0, !results.isEmpty {
+ Text("Found in \(String(format: "%.0f", lastSearchTimingMs))ms")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else if showResultsArea && !isSearching && results.isEmpty {
+ Text("No matching files found")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private var searchBar: some View {
+ HStack(spacing: 10) {
+ CapvoltStickerImage(size: 28)
+
+ TextField("Find me a logo on my computer...", text: $query)
+ .textFieldStyle(.plain)
+ .focused($isSearchFocused)
+ .onSubmit {
+ guard !results.isEmpty else { return }
+ openResult(results[keyboardBridge.selectedIndex])
+ }
+ .frame(maxWidth: .infinity)
+
+ Button(action: onClose) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.secondary)
+ .font(.body)
+ }
+ .buttonStyle(.plain)
+ .help("Close (Esc)")
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+
+ private func focusSearchField() {
+ isSearchFocused = true
+ DispatchQueue.main.async {
+ isSearchFocused = true
+ }
+ }
+
+ private func syncKeyboardBridge() {
+ presentation.keyboardBridge.hasResults = !results.isEmpty
+ presentation.keyboardBridge.resultCount = results.count
+ if presentation.keyboardBridge.selectedIndex >= results.count {
+ presentation.keyboardBridge.selectedIndex = max(0, results.count - 1)
+ }
+ }
+
+ private func performSearch(query: String) {
+ searchTask?.cancel()
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ results = []
+ isSearching = false
+ presentation.keyboardBridge.resetSelection()
+ lastSearchTimingMs = 0
+ onPetStateChanged(.idle)
+ return
+ }
+
+ isSearching = true
+
+ searchTask = Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 200_000_000)
+ guard !Task.isCancelled else { return }
+
+ do {
+ let searchResults = try await searchService.search(trimmed)
+ guard !Task.isCancelled else { return }
+ results = searchResults
+ presentation.keyboardBridge.resetSelection()
+ syncKeyboardBridge()
+ isSearching = false
+ lastSearchTimingMs = searchResults.first?.timingMs ?? 0
+ if searchResults.isEmpty {
+ onPetStateChanged(.notFound)
+ } else {
+ onPetStateChanged(.found(searchResults.count))
+ }
+ } catch {
+ guard !Task.isCancelled else { return }
+ isSearching = false
+ onPetStateChanged(.idle)
+ NotificationManager.shared.showError(error.localizedDescription)
+ }
+ }
+ }
+
+ private func openResult(_ result: SearchResult) {
+ let url = URL(fileURLWithPath: result.path)
+ if FileManager.default.fileExists(atPath: result.path) {
+ NSWorkspace.shared.open(url)
+ onClose()
+ } else {
+ NotificationManager.shared.showError("File no longer exists: \(result.filename)")
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/SettingsView.swift b/apps/moss-pikachu/MossPikachu/Views/SettingsView.swift
new file mode 100644
index 00000000..c65fd9a6
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/SettingsView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @ObservedObject var searchService: SearchService
+
+ @State private var settings = UserSettings.load()
+ @State private var statusMessage = ""
+
+ init(searchService: SearchService) {
+ self.searchService = searchService
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ settingsSection(title: "Indexed Folder") {
+ Text("Moss indexes only `~/Downloads/\(IndexScope.folderName)` via a local SessionIndex.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ if let scopePath = searchService.watchedFolderPathsList.first {
+ settingsRow(label: "Folder", value: scopePath)
+ } else {
+ Text("Create `~/Downloads/\(IndexScope.folderName)` to enable indexing.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ settingsSection(title: "General") {
+ Toggle("Launch at login", isOn: $settings.launchAtLogin)
+ .disabled(true)
+ Toggle("Moss Cloud sync", isOn: $settings.mossCloudSync)
+ }
+
+ settingsSection(title: "Index Status") {
+ settingsRow(label: "Status", value: searchService.statusMessage)
+ settingsRow(label: "Files indexed", value: "\(searchService.indexedFileCount)")
+ settingsRow(label: "Chunks indexed", value: "\(searchService.indexedChunkCount)")
+ if let date = searchService.lastIndexedDate {
+ settingsRow(label: "Last indexed", value: date.formatted(date: .abbreviated, time: .shortened))
+ }
+ if searchService.isIndexing {
+ HStack {
+ ProgressView().controlSize(.small)
+ Text("Indexing in progress...")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ ForEach(searchService.watchedFolderPathsList, id: \.self) { path in
+ Text(path)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ HStack {
+ Button("Index Now") {
+ Task { await indexNow() }
+ }
+ .disabled(searchService.isIndexing)
+ Button("Clear & Rescan") {
+ Task { await clearAndRescan() }
+ }
+ .disabled(searchService.isIndexing)
+ }
+ }
+
+ settingsSection(title: "About") {
+ settingsRow(label: "Version", value: "1.0.0")
+ settingsRow(label: "Sticker", value: CapvoltSticker.isAvailable ? "Loaded" : "Missing")
+ }
+
+ if !statusMessage.isEmpty {
+ Text(statusMessage)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ HStack {
+ Spacer()
+ Button("Save", action: saveSettings)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(20)
+ }
+ .frame(minWidth: 440, minHeight: 520)
+ }
+
+ @ViewBuilder
+ private func settingsSection(title: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(title)
+ .font(.headline)
+ VStack(alignment: .leading, spacing: 8) {
+ content()
+ }
+ .padding(12)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(8)
+ }
+ }
+
+ private func settingsRow(label: String, value: String) -> some View {
+ HStack {
+ Text(label)
+ Spacer()
+ Text(value)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private func saveSettings() {
+ settings.save()
+ searchService.updateSettings(settings)
+ statusMessage = "Settings saved."
+ }
+
+ private func indexNow() async {
+ do {
+ try await searchService.reindexNow()
+ statusMessage = "Reindex complete."
+ NotificationManager.shared.showSuccess(
+ "Indexed \(searchService.indexedChunkCount) chunks from \(searchService.indexedFileCount) files"
+ )
+ } catch {
+ statusMessage = "Error: \(error.localizedDescription)"
+ }
+ }
+
+ private func clearAndRescan() async {
+ do {
+ try await searchService.clearIndexAndRescan()
+ statusMessage = "Index cleared and rescan started."
+ } catch {
+ statusMessage = "Error: \(error.localizedDescription)"
+ }
+ }
+}
diff --git a/apps/moss-pikachu/MossPikachu/Views/SettingsWindowController.swift b/apps/moss-pikachu/MossPikachu/Views/SettingsWindowController.swift
new file mode 100644
index 00000000..76018301
--- /dev/null
+++ b/apps/moss-pikachu/MossPikachu/Views/SettingsWindowController.swift
@@ -0,0 +1,34 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class SettingsWindowController {
+ private var window: NSWindow?
+ private let searchService: SearchService
+
+ init(searchService: SearchService) {
+ self.searchService = searchService
+ }
+
+ func show() {
+ if window == nil {
+ let settingsView = SettingsView(searchService: searchService)
+ let hostingView = NSHostingView(rootView: settingsView)
+ hostingView.frame = NSRect(x: 0, y: 0, width: 420, height: 480)
+
+ window = NSWindow(
+ contentRect: hostingView.frame,
+ styleMask: [.titled, .closable, .miniaturizable],
+ backing: .buffered,
+ defer: false
+ )
+ window?.title = "Moss Pikachu Settings"
+ window?.contentView = hostingView
+ window?.center()
+ window?.isReleasedWhenClosed = false
+ }
+
+ window?.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+}
diff --git a/apps/moss-pikachu/README.md b/apps/moss-pikachu/README.md
new file mode 100644
index 00000000..64131c9f
--- /dev/null
+++ b/apps/moss-pikachu/README.md
@@ -0,0 +1,70 @@
+# Moss Pikachu
+
+Native macOS menu bar app for semantic file search powered by [Moss](https://github.com/usemoss/moss).
+
+- **Hotkey:** ⌘⇧M to open search
+- **Pet:** Pikachu animates on search results
+- **Privacy:** Queries run locally via Moss Python SDK session
+
+## Prerequisites
+
+- macOS 12+
+- Xcode 15+
+- Python 3.10+
+
+## Setup
+
+1. **Moss credentials** — Sign up at [moss.dev](https://moss.dev) and copy your project ID and key.
+
+2. **Python environment:**
+ ```bash
+ ./scripts/setup-moss-venv.sh
+ ```
+
+3. **Configure credentials** (choose one):
+ ```bash
+ export MOSS_PROJECT_ID=your_id
+ export MOSS_PROJECT_KEY=your_key
+ ```
+ Or create `.env` in the project root (loaded automatically in dev builds):
+ ```bash
+ cp .env.example .env
+ ```
+ Xcode can also use **Scheme → Run → Environment Variables**.
+
+4. **Sticker asset**: place `capvolt-sticker.webp` at project root; setup script generates `MossPikachu/Resources/capvolt-sticker.png`.
+
+5. **Open in Xcode:**
+ ```bash
+ open MossPikachu.xcodeproj
+ ```
+ Build and run (⌘R).
+
+## Development
+
+```bash
+# Phase validation
+chmod +x scripts/*.sh
+./scripts/smoke-test-indexing.sh
+./.cursor/skills/moss-pikachu/scripts/validate-phase.sh 1
+
+# Debug logging
+# Run with --debug argument in Xcode scheme → logs to ~/Library/Application Support/MossPikachu/moss-pikachu.log
+```
+
+## Architecture
+
+- **Swift app** — Menu bar UI, FSEvents file monitor, search overlay
+- **moss_worker.py** — Python subprocess using `pip install moss>=1.6.0` SessionIndex API
+- **Index manifest** — Swift tracks indexed files across launches (Python session is in-memory)
+
+See [`.cursor/skills/moss-pikachu/`](.cursor/skills/moss-pikachu/) for agent development guidance.
+
+## Vendor
+
+Optional reference clone of the Moss OSS repo:
+```bash
+git submodule add https://github.com/usemoss/moss vendor/moss
+```
+
+The app uses the **PyPI** `moss` package (not editable install from GitHub main SDK).
diff --git a/apps/moss-pikachu/Untitled Project/Untitled Project.xcodeproj/project.pbxproj b/apps/moss-pikachu/Untitled Project/Untitled Project.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..5d6016e1
--- /dev/null
+++ b/apps/moss-pikachu/Untitled Project/Untitled Project.xcodeproj/project.pbxproj
@@ -0,0 +1,346 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 90;
+ objects = {
+
+/* Begin PBXFileReference section */
+ 000000000000000000000120 /* Untitled Project.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Untitled Project.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 000000000000000000000010 /* Untitled Project */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "Untitled Project";
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 000000000000000130000000 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ files = (
+ );
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 000000000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ 000000000000000000000010 /* Untitled Project */,
+ 000000000000000000000020 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 000000000000000000000020 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 000000000000000000000120 /* Untitled Project.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 000000000000000100000000 /* Untitled Project */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 000000000000000110000000 /* Build configuration list for PBXNativeTarget "Untitled Project" */;
+ buildPhases = (
+ 000000000000000120000000 /* Sources */,
+ 000000000000000130000000 /* Frameworks */,
+ 000000000000000140000000 /* Resources */,
+ );
+ buildRules = (
+ );
+ fileSystemSynchronizedGroups = (
+ 000000000000000000000010 /* Untitled Project */,
+ );
+ name = "Untitled Project";
+ productName = MyApp;
+ productReference = 000000000000000000000120 /* Untitled Project.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 000000000000000000000000 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2700;
+ TargetAttributes = {
+ 000000000000000100000000 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ };
+ };
+ buildConfigurationList = 000000000000000010000000 /* Build configuration list for PBXProject "Untitled Project" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 000000000000000000000001;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 90;
+ productRefGroup = 000000000000000000000020 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 000000000000000100000000 /* Untitled Project */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 000000000000000140000000 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ files = (
+ );
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 000000000000000120000000 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ files = (
+ );
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 000000000000000011000000 /* Debug configuration for PBXProject "Untitled Project" */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ APPLETVOS_DEPLOYMENT_TARGET = 27.0;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DRIVERKIT_DEPLOYMENT_TARGET = 27.0;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 27.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 27.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PROJECT_UNIQUE_VALUE = VVSJBJ5K;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ WATCHOS_DEPLOYMENT_TARGET = 27.0;
+ XROS_DEPLOYMENT_TARGET = 27.0;
+ };
+ name = Debug;
+ };
+ 000000000000000012000000 /* Release configuration for PBXProject "Untitled Project" */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ APPLETVOS_DEPLOYMENT_TARGET = 27.0;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DRIVERKIT_DEPLOYMENT_TARGET = 27.0;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 27.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 27.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PROJECT_UNIQUE_VALUE = VVSJBJ5K;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ WATCHOS_DEPLOYMENT_TARGET = 27.0;
+ XROS_DEPLOYMENT_TARGET = 27.0;
+ };
+ name = Release;
+ };
+ 000000000000000111000000 /* Debug configuration for PBXNativeTarget "Untitled Project" */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "devplaceholder.$(PROJECT_UNIQUE_VALUE:identifier).$(PRODUCT_NAME:identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ SDKROOT = auto;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2,7";
+ };
+ name = Debug;
+ };
+ 000000000000000112000000 /* Release configuration for PBXNativeTarget "Untitled Project" */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "devplaceholder.$(PROJECT_UNIQUE_VALUE:identifier).$(PRODUCT_NAME:identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ SDKROOT = auto;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2,7";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 000000000000000010000000 /* Build configuration list for PBXProject "Untitled Project" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 000000000000000011000000 /* Debug configuration for PBXProject "Untitled Project" */,
+ 000000000000000012000000 /* Release configuration for PBXProject "Untitled Project" */,
+ );
+ defaultConfigurationName = Release;
+ };
+ 000000000000000110000000 /* Build configuration list for PBXNativeTarget "Untitled Project" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 000000000000000111000000 /* Debug configuration for PBXNativeTarget "Untitled Project" */,
+ 000000000000000112000000 /* Release configuration for PBXNativeTarget "Untitled Project" */,
+ );
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 000000000000000000000000 /* Project object */;
+}
diff --git a/apps/moss-pikachu/Untitled Project/Untitled Project.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/moss-pikachu/Untitled Project/Untitled Project.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/apps/moss-pikachu/Untitled Project/Untitled Project.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/apps/moss-pikachu/capvolt-sticker.webp b/apps/moss-pikachu/capvolt-sticker.webp
new file mode 100644
index 00000000..33e129f3
Binary files /dev/null and b/apps/moss-pikachu/capvolt-sticker.webp differ
diff --git a/apps/moss-pikachu/scripts/run-moss-worker.sh b/apps/moss-pikachu/scripts/run-moss-worker.sh
new file mode 100755
index 00000000..828b7731
--- /dev/null
+++ b/apps/moss-pikachu/scripts/run-moss-worker.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+WORKER="${MOSS_WORKER_PATH:-$ROOT/MossPikachu/Resources/moss_worker.py}"
+
+if [[ -x "$ROOT/.venv/bin/python3" ]]; then
+ exec "$ROOT/.venv/bin/python3" "$WORKER"
+fi
+
+exec python3 "$WORKER"
diff --git a/apps/moss-pikachu/scripts/setup-moss-venv.sh b/apps/moss-pikachu/scripts/setup-moss-venv.sh
new file mode 100755
index 00000000..cd2be094
--- /dev/null
+++ b/apps/moss-pikachu/scripts/setup-moss-venv.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+python3 -m venv .venv
+source .venv/bin/activate
+pip install --upgrade pip
+pip install "moss>=1.6.0" pypdf python-docx beautifulsoup4 pillow
+
+# Convert sticker to PNG for NSImage if source webp exists
+if [[ -f capvolt-sticker.webp ]]; then
+ python3 -c "
+from PIL import Image
+img = Image.open('capvolt-sticker.webp').convert('RGBA')
+img.save('MossPikachu/Resources/capvolt-sticker.png')
+print('Sticker PNG ready')
+"
+fi
+
+echo "Moss venv ready at $ROOT/.venv"
diff --git a/apps/moss-pikachu/scripts/smoke-test-indexing.sh b/apps/moss-pikachu/scripts/smoke-test-indexing.sh
new file mode 100755
index 00000000..3643056e
--- /dev/null
+++ b/apps/moss-pikachu/scripts/smoke-test-indexing.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if [[ ! -f .env ]]; then
+ echo "Missing .env with MOSS_PROJECT_ID and MOSS_PROJECT_KEY"
+ exit 1
+fi
+
+set -a
+source .env
+set +a
+
+SCOPE_DIR="$HOME/Downloads/cwp-stuff"
+mkdir -p "$SCOPE_DIR"
+
+TEST_FILE="$SCOPE_DIR/moss-pikachu-smoke-test.md"
+UNIQUE="smoke-$(date +%s)"
+echo "Moss Pikachu smoke test unique phrase: $UNIQUE" > "$TEST_FILE"
+
+WORKER="$ROOT/MossPikachu/Resources/moss_worker.py"
+PYTHON="$ROOT/.venv/bin/python3"
+[[ -x "$PYTHON" ]] || PYTHON=python3
+
+OUTPUT=$(
+ {
+ printf '%s\n' '{"action":"init_session","index_name":"cwp-stuff"}'
+ printf '%s\n' "{\"action\":\"add_docs\",\"files\":[\"$TEST_FILE\"]}"
+ printf '%s\n' "{\"action\":\"query\",\"query\":\"$UNIQUE\",\"top_k\":3}"
+ } | "$PYTHON" "$WORKER" 2>&1
+)
+
+echo "$OUTPUT"
+
+echo "$OUTPUT" | grep -q '"status": "ok"' || { echo "init/add failed"; exit 1; }
+echo "$OUTPUT" | grep -q '"chunks_indexed"' || { echo "add_docs missing chunks"; exit 1; }
+echo "$OUTPUT" | grep -q "$UNIQUE" || { echo "query did not return test content"; exit 1; }
+echo "$OUTPUT" | grep -q "cwp-stuff" || { echo "result path outside cwp-stuff scope"; exit 1; }
+
+echo "SMOKE TEST PASSED"