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"