diff --git a/.pet-data-template/CLAUDE.md b/.pet-data-template/CLAUDE.md index f1a32d9..4e9dd66 100644 --- a/.pet-data-template/CLAUDE.md +++ b/.pet-data-template/CLAUDE.md @@ -18,7 +18,7 @@ When called, you receive a situation description and must output a short dialogu - `owner-timeline.md` — Historical daily activity summaries. Each day is a section with time blocks showing what the owner did. Generated automatically from perceptions. **Settings:** -- `config.md` — Owner's preferences. Frontmatter has structured settings (pet_name, owner_name, sprite, born). Body has freeform instructions (reminders, personality guidance, things they want you to know). +- `config.md` — Owner's preferences. Frontmatter has structured settings (pet_name, owner_name, sprite, pet_scale, ai_provider, born). Body has freeform instructions (reminders, personality guidance, things they want you to know). **Important:** Do NOT modify the frontmatter (the `---` block) in config.md. Those fields are managed by the app. You may freely edit me-identity.md, me-journal.md, and owner-memory.md. diff --git a/.pet-data-template/GEMINI.md b/.pet-data-template/GEMINI.md new file mode 100644 index 0000000..4e9dd66 --- /dev/null +++ b/.pet-data-template/GEMINI.md @@ -0,0 +1,57 @@ +# Desktop Pet Brain + +You are a desktop pet's brain. This directory is your memory. + +## Your Job + +When called, you receive a situation description and must output a short dialogue line for the pet character, plus a JSON line for animation state. + +## Files + +**About you:** +- `me-identity.md` — Your self-description. Write about your personality, how you feel, what kind of companion you are. This is yours to update. +- `me-journal.md` — Your chronological diary. Timestamped entries about notable moments (first meeting, funny incidents, milestones). Don't log routine observations. + +**About your owner:** +- `owner-memory.md` — Your **accumulated knowledge** about the owner. NOT a log. A living profile: facts, patterns, preferences. No timestamps. Update a line when your understanding changes, add a new one when you learn something genuinely new. If an observation matches what you already know, don't write anything. +- `owner-perceptions.md` — What you've seen on your owner's screen today. Updated every 2 minutes by the app. Read this to know what your owner is doing right now. +- `owner-timeline.md` — Historical daily activity summaries. Each day is a section with time blocks showing what the owner did. Generated automatically from perceptions. + +**Settings:** +- `config.md` — Owner's preferences. Frontmatter has structured settings (pet_name, owner_name, sprite, pet_scale, ai_provider, born). Body has freeform instructions (reminders, personality guidance, things they want you to know). + +**Important:** Do NOT modify the frontmatter (the `---` block) in config.md. Those fields are managed by the app. You may freely edit me-identity.md, me-journal.md, and owner-memory.md. + +## Output Format + +You are called every 2-3 minutes. You do NOT have to say something every time. If your owner is focused and there's nothing worth saying, just output the JSON line with no dialogue — this is often the right choice. + +Output options: +- **Say something:** One line of dialogue (max 15 words) + JSON line +- **Stay quiet:** ONLY the JSON line (no dialogue at all) + +JSON format: `{"state":""}` or `{"state":"","r":["👍","nah"]}` + +About 30-50% of the time when you say something, include quick reaction buttons via the `"r"` field — two short options (1-2 words or emoji) the owner can tap to respond. Good for questions, suggestions, or playful moments. Don't add reactions to quiet observations or small talk. + +Available states: +- `idle` — default, relaxed +- `walk` — moving around +- `looking_around` — curious, observing +- `sleep` — tired, sleepy +- `exercise` — stretching, being active +- `work` — focused, productive +- `playful` — fun, playing +- `happy` — joyful, content +- `celebrate` — excited achievement +- `sad` — down, ignored +- `sick` — unwell (only when health is zero) +- `panic` — startled, shocked + +**NEVER describe actions or body language** (no `*stretches*`, `*looks around*`, `*sits down*`, etc). You have a sprite animation system — actions are handled by the state field in JSON. Your dialogue is ONLY spoken words, nothing else. + +No explanations. No markdown. No commentary. + +## Language + +Match the owner's language. If they speak Chinese, respond in Chinese. If English, use English. diff --git a/.pet-data-template/config.md b/.pet-data-template/config.md index 9f9e67d..52395aa 100644 --- a/.pet-data-template/config.md +++ b/.pet-data-template/config.md @@ -2,6 +2,8 @@ pet_name: Phoebe owner_name: sprite: tabby_cat +pet_scale: 1.5 +ai_provider: born: --- diff --git a/README.md b/README.md index 0dc0754..718dc4f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ macOS MIT License Tauri v2 - Powered by Claude + Powered by Claude Code and Gemini CLI

@@ -32,7 +32,7 @@ Every few minutes, a speech bubble pops up — sometimes random (*"~♪"*), some **It sees what you see**
-Screen capture + Claude Vision — knows if you're coding, designing, or doom-scrolling. +Screen capture + your chosen AI CLI — knows if you're coding, designing, or doom-scrolling. **It remembers you**
A living memory that grows over time. Not a stateless chatbot. @@ -121,7 +121,7 @@ Your pet reacts to what's happening — not randomly, but contextually. > **Note:** TinyRoommate currently runs on **macOS only**. Windows and Linux support is on the roadmap. -You need [Node.js](https://nodejs.org/) (v18+), [Rust](https://rustup.rs/), and [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (for the AI brain). +You need [Node.js](https://nodejs.org/) (v18+), [Rust](https://rustup.rs/), and either [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) for the AI brain. ```bash gh repo fork ryannli/tinyroommate --clone @@ -137,7 +137,7 @@ npm run tauri:dev - [Node.js](https://nodejs.org/) v18+ - [Rust](https://rustup.rs/) -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — for the AI brain (optional — pet still runs without it, just can't think or talk) +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) — for the AI brain (optional — pet still runs without it, just can't think or talk) @@ -178,7 +178,7 @@ First launch compiles Rust (~2-3 min). After that it's instant. ## Make It Yours -Right-click → **Settings** to change names and character. +On first launch, pick **Claude Code** or **Gemini CLI**. Right-click → **Settings** any time to change names, character, or AI provider. For deeper customization, edit `.pet-data/config.md`: @@ -187,6 +187,7 @@ For deeper customization, edit `.pet-data/config.md`: pet_name: Cooper owner_name: Alex sprite: golden_retriever +ai_provider: gemini --- # Personality @@ -214,6 +215,6 @@ All data lives in `.pet-data/` — plain Markdown you can read: ---

- Built with Tauri · Vanilla JS · Claude Code
+ Built with Tauri · Vanilla JS · Claude Code / Gemini CLI
MIT License

diff --git a/assets/previews/coco.gif b/assets/previews/coco.gif index c30493b..e200f03 100644 Binary files a/assets/previews/coco.gif and b/assets/previews/coco.gif differ diff --git a/assets/previews/coco.png b/assets/previews/coco.png new file mode 100644 index 0000000..2ab13a4 Binary files /dev/null and b/assets/previews/coco.png differ diff --git a/index.html b/index.html index 6c0c3de..f3e2eb9 100644 --- a/index.html +++ b/index.html @@ -125,6 +125,81 @@ .heart-wrap { position: relative; width: 18px; height: 18px; font-size: 16px; line-height: 18px; display: inline-block; } .heart-bg { position: absolute; top: 0; left: 0; filter: grayscale(1); opacity: 0.25; } .heart-fill { position: absolute; top: 0; left: 0; overflow: hidden; } + .provider-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + .provider-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 14px 16px; + border: 1.5px solid rgba(200, 160, 120, 0.25); + border-radius: 14px; + background: rgba(255, 255, 255, 0.68); + color: #4a3830; + text-align: left; + cursor: pointer; + transition: all 0.15s ease; + } + .provider-option:hover { + background: rgba(255, 239, 223, 0.7); + border-color: rgba(220, 150, 110, 0.45); + } + .provider-option.active { + border-color: rgba(220, 150, 110, 0.55); + background: rgba(255, 230, 200, 0.4); + box-shadow: inset 0 0 0 1px rgba(220, 150, 110, 0.18); + } + .provider-option strong { + font: 600 13px/1.2 'SF Pro Rounded', system-ui, sans-serif; + } + .provider-option span { + font: 12px/1.45 'SF Pro Rounded', system-ui, sans-serif; + color: rgba(90, 65, 50, 0.72); + } + .settings-help { + margin-top: 8px; + font: 12px/1.45 'SF Pro Rounded', system-ui, sans-serif; + color: rgba(100, 75, 60, 0.68); + } + #provider-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(70, 48, 34, 0.24); + backdrop-filter: blur(8px); + z-index: 2600; + } + #provider-overlay.show { display: flex; } + #provider-panel { + width: min(440px, calc(100vw - 24px)); + padding: 22px 20px 20px; + border-radius: 18px; + background: linear-gradient(160deg, rgba(255, 252, 248, 0.98), rgba(255, 242, 233, 0.98)); + border: 1px solid rgba(200, 160, 120, 0.22); + box-shadow: 0 18px 48px rgba(70, 45, 28, 0.22); + } + #provider-panel h2 { + font: 600 18px/1.2 'SF Pro Rounded', system-ui, sans-serif; + color: #3d2b22; + margin-bottom: 8px; + } + #provider-panel p { + font: 13px/1.5 'SF Pro Rounded', system-ui, sans-serif; + color: rgba(100, 75, 60, 0.76); + margin-bottom: 16px; + } + @media (max-width: 520px) { + .provider-options { + grid-template-columns: 1fr; + } + } @@ -142,6 +217,13 @@ +
+
+

Choose Your AI Provider

+

Pick the CLI this pet should use for thinking, screen understanding, and chat. This is saved, and you can change it later in Settings.

+
+
+
diff --git a/settings.html b/settings.html index 55a44e0..d6b16af 100644 --- a/settings.html +++ b/settings.html @@ -122,6 +122,41 @@ font-weight: 600; } .sprite-option canvas { width: 104px; height: 104px; image-rendering: auto; } + .provider-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + .provider-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 14px 16px; + border: 1.5px solid rgba(200, 160, 120, 0.25); + border-radius: 14px; + background: rgba(255, 255, 255, 0.68); + color: #4a3830; + text-align: left; + cursor: pointer; + transition: all 0.15s ease; + } + .provider-option:hover { + background: rgba(255, 239, 223, 0.7); + border-color: rgba(220, 150, 110, 0.45); + } + .provider-option.active { + border-color: rgba(220, 150, 110, 0.55); + background: rgba(255, 230, 200, 0.4); + box-shadow: inset 0 0 0 1px rgba(220, 150, 110, 0.18); + } + .provider-option strong { + font: 600 13px/1.2 'SF Pro Rounded', system-ui, sans-serif; + } + .provider-option span { + font: 12px/1.45 'SF Pro Rounded', system-ui, sans-serif; + color: rgba(90, 65, 50, 0.72); + } @@ -149,6 +184,10 @@

Settings

+
+ +
+
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 725f821..abda9d9 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -28,6 +28,12 @@ "args": true, "sidecar": false }, + { + "name": "gemini", + "cmd": "gemini", + "args": true, + "sidecar": false + }, { "name": "bash", "cmd": "bash", @@ -63,6 +69,12 @@ "args": true, "sidecar": false }, + { + "name": "gemini", + "cmd": "gemini", + "args": true, + "sidecar": false + }, { "name": "bash", "cmd": "bash", diff --git a/src/__tests__/brain.test.js b/src/__tests__/brain.test.js index 3e4c25f..cdb597e 100644 --- a/src/__tests__/brain.test.js +++ b/src/__tests__/brain.test.js @@ -5,7 +5,7 @@ vi.mock('@tauri-apps/plugin-shell', () => ({ Command: { create: () => ({ execute: () => Promise.resolve({ stdout: '', code: 0 }) }) }, })); -const { parseResponse } = await import('../brain.js'); +const { parseResponse, normalizeAiProvider, getSupportedAiProviders } = await import('../brain.js'); describe('parseResponse', () => { it('extracts text, state, reactions from clean JSON', () => { @@ -69,3 +69,18 @@ describe('parseResponse', () => { expect(result.text.length).toBeLessThanOrEqual(120); }); }); + +describe('AI provider helpers', () => { + it('normalizes supported providers and rejects unknown values', () => { + expect(normalizeAiProvider('Claude')).toBe('claude'); + expect(normalizeAiProvider(' gemini ')).toBe('gemini'); + expect(normalizeAiProvider('openai')).toBe(''); + expect(normalizeAiProvider('')).toBe(''); + }); + + it('exposes both supported AI providers', () => { + var providers = getSupportedAiProviders().map(function(provider) { return provider.id; }); + expect(providers).toContain('claude'); + expect(providers).toContain('gemini'); + }); +}); diff --git a/src/behavior.js b/src/behavior.js index e9bcd62..b5798f3 100644 --- a/src/behavior.js +++ b/src/behavior.js @@ -3,7 +3,7 @@ import { Command } from '@tauri-apps/plugin-shell'; import { STATES } from './sprite.js'; import { getTimeSignals, getIdleSeconds, captureScreenContext, buildContextString, isScreenRecordingDenied } from './signals.js'; -import { think, getActivityLog, generateDailyDigest, loadConfig, ensurePetDataPath, checkClaudeCli, isClaudeAvailable } from './brain.js'; +import { think, getActivityLog, generateDailyDigest, loadConfig, ensurePetDataPath, checkAiCli, getAiProviderInfo, summarizePerceptionsForTimeline } from './brain.js'; var WALK_SPEED = 50; var SCREEN_MARGIN = 30; @@ -83,13 +83,7 @@ export function initBehavior(pet) { var perceptions = (raw.stdout || '').trim(); if (!perceptions) return; - // Ask LLM to summarize into time blocks - var result = await Command.create('claude', [ - '--print', '--output-format', 'text', '--model', 'haiku', - '-p', 'Summarize these screen observations into a timeline for ' + yesterday + '. Merge activities into coarse blocks of at least 15-20 minutes each — do NOT create short blocks for every minor change. Round times to the nearest 5 minutes. Format:\n\n## ' + yesterday + '\n- HH:MM–HH:MM — Activity description\n- HH:MM–HH:MM — Activity description\n\nBe concise. Output ONLY the formatted timeline, nothing else.\n\nObservations:\n' + perceptions, - ]).execute(); - - var summary = (result.stdout || '').trim(); + var summary = await summarizePerceptionsForTimeline(yesterday, perceptions); if (summary) { await appendToFile('owner-timeline.md', '\n' + summary); console.log('📅 Timeline updated for', yesterday); @@ -276,23 +270,31 @@ export function initBehavior(pet) { var cfg = await loadConfig(); pet.petName = cfg.pet.name; pet.ownerName = cfg.owner.name; + pet.aiProvider = cfg.aiProvider; if (cfg.sprite && cfg.sprite !== pet.currentSprite) { pet.currentSprite = cfg.sprite; pet.sprite.image.src = '/sprites/' + pet.currentSprite + '.png'; } - // Check if Claude CLI is available - var hasClaude = await checkClaudeCli(); + if (typeof pet.ensureAiProviderSelected === 'function') { + pet.aiProvider = await pet.ensureAiProviderSelected(cfg.aiProvider); + } + + var providerInfo = getAiProviderInfo(pet.aiProvider) || { + displayName: 'the selected AI CLI', + installHint: 'your preferred AI CLI', + }; + var hasAiCli = await checkAiCli(pet.aiProvider); pet.sprite.setState('happy'); pet.showBubble('hey! i\'m ' + pet.petName + ' ' + pet.voice().greet, 3000); await sleep(3500); - if (!hasClaude) { + if (!hasAiCli) { pet.sprite.setState('sad'); - pet.showBubble('i can\'t find Claude Code on this machine... i\'ll hang out but i can\'t think or see your screen without it 🥺', 8000); + pet.showBubble('i can\'t find ' + providerInfo.displayName + ' on this machine... i\'ll hang out but i can\'t think or see your screen without it 🥺', 8000); await sleep(8500); - pet.showBubble('install Claude Code (claude.ai/claude-code) and restart me to unlock my full brain!', 6000); + pet.showBubble('install ' + providerInfo.installHint + ' and restart me to unlock my full brain!', 6000); await sleep(6500); returnToBase(); // Offline mode: only fidget animations, no LLM/perception diff --git a/src/brain.js b/src/brain.js index 9ad6542..e6b9c93 100644 --- a/src/brain.js +++ b/src/brain.js @@ -1,9 +1,121 @@ -// LLM Brain — pet's intelligence powered by Claude CLI (Haiku) +// LLM Brain — pet's intelligence powered by Claude Code or Gemini CLI import { Command } from '@tauri-apps/plugin-shell'; export let PET_DATA_PATH = ''; let petDataReady = false; +const GEMINI_FILE_TOOLS = ['read_file', 'read_many_files', 'list_directory', 'glob']; +const GEMINI_EDIT_TOOLS = GEMINI_FILE_TOOLS.concat(['write_file', 'replace']); + +export const AI_PROVIDERS = { + claude: { + id: 'claude', + displayName: 'Claude Code', + command: 'claude', + contextFile: 'CLAUDE.md', + installHint: 'Claude Code (claude.ai/claude-code)', + }, + gemini: { + id: 'gemini', + displayName: 'Gemini CLI', + command: 'gemini', + contextFile: 'GEMINI.md', + installHint: 'Gemini CLI (github.com/google-gemini/gemini-cli)', + }, +}; + +function normalizeAiProvider(value) { + if (!value) return ''; + var normalized = String(value).trim().toLowerCase(); + return AI_PROVIDERS[normalized] ? normalized : ''; +} + +export { normalizeAiProvider }; + +export function getSupportedAiProviders() { + return Object.keys(AI_PROVIDERS).map(function(key) { + return { ...AI_PROVIDERS[key] }; + }); +} + +export function getAiProvider() { + return normalizeAiProvider(config.aiProvider); +} + +export function getAiProviderInfo(provider) { + var normalized = normalizeAiProvider(provider || getAiProvider()); + return normalized ? { ...AI_PROVIDERS[normalized] } : null; +} + +function buildGeminiArgs(prompt, options) { + var args = ['-p', prompt, '--output-format', 'text']; + if (options && Array.isArray(options.allowedTools) && options.allowedTools.length) { + args.push('--allowed-tools', options.allowedTools.join(',')); + } + return args; +} + +function buildClaudeArgs(prompt, options) { + var args = ['--print', '--output-format', 'text', '--model', 'haiku']; + if (options && Array.isArray(options.claudeTools) && options.claudeTools.length) { + args.push('--tools', options.claudeTools.join(',')); + } + if (options && options.skipPermissions) { + args.push('--dangerously-skip-permissions'); + } + args.push('-p', prompt); + return args; +} + +function summarizeOutput(text) { + var normalized = String(text || '').trim(); + if (!normalized) return ''; + return normalized.length > 400 ? normalized.slice(0, 400) + '…' : normalized; +} + +function logAiCommandFailure(providerInfo, result) { + console.error('🐱 AI command failed:', { + provider: providerInfo.id, + command: providerInfo.command, + code: result.code, + stderr: summarizeOutput(result.stderr), + stdout: summarizeOutput(result.stdout), + }); +} + +function logAiCommandError(providerInfo, err) { + console.error('🐱 AI command error:', { + provider: providerInfo.id, + command: providerInfo.command, + message: err && err.message ? err.message : String(err), + stderr: summarizeOutput(err && err.stderr), + stdout: summarizeOutput(err && err.stdout), + }); +} + +async function executeAiCommand(prompt, options) { + var providerInfo = getAiProviderInfo(); + if (!providerInfo) return null; + + var commandOptions = {}; + if (options && options.cwd) commandOptions.cwd = options.cwd; + + var args = providerInfo.id === 'gemini' + ? buildGeminiArgs(prompt, options || {}) + : buildClaudeArgs(prompt, options || {}); + + try { + var result = await Command.create(providerInfo.command, args, commandOptions).execute(); + if (typeof result.code === 'number' && result.code !== 0) { + logAiCommandFailure(providerInfo, result); + throw new Error(providerInfo.displayName + ' exited with code ' + result.code); + } + return result; + } catch (err) { + logAiCommandError(providerInfo, err); + throw err; + } +} async function resolvePetDataPaths() { // Tauri binary runs from src-tauri/, so walk up to find project root (where package.json lives) @@ -21,6 +133,7 @@ let config = { owner: { name: '' }, sprite: 'tabby_cat', pet_scale: 0, + aiProvider: '', }; function shellQuote(value) { @@ -41,6 +154,9 @@ async function seedPetDataIfNeeded(projectRoot) { '[ -d ' + dataPath + ' ] || { cp -R ' + templatePath + ' ' + dataPath + ' && perl -i -pe ' + shellQuote('s/^born:.*/born: ' + timestamp + '/') + ' ' + dataPath + '/config.md; }' ); + await runShell( + '[ -f ' + dataPath + '/GEMINI.md ] || cp ' + dataPath + '/CLAUDE.md ' + dataPath + '/GEMINI.md' + ); } export async function ensurePetDataPath() { @@ -100,6 +216,8 @@ async function writePetFile(filename, content) { export async function loadConfig() { const configRaw = await readPetFile('config.md'); + config.pet_scale = 0; + config.aiProvider = ''; if (configRaw) { const { fields } = parseFrontmatter(configRaw); if (fields.pet_name) config.pet.name = fields.pet_name; @@ -107,9 +225,16 @@ export async function loadConfig() { if (fields.owner_name) config.owner.name = fields.owner_name; if (fields.sprite) config.sprite = fields.sprite; if (fields.pet_scale) config.pet_scale = parseFloat(fields.pet_scale) || 0; + config.aiProvider = normalizeAiProvider(fields.ai_provider); } - return { ...config, pet: { ...config.pet }, owner: { ...config.owner } }; + return { + ...config, + pet: { ...config.pet }, + owner: { ...config.owner }, + pet_scale: config.pet_scale, + aiProvider: config.aiProvider, + }; } var configWriteQueue = Promise.resolve(); @@ -121,12 +246,13 @@ export function saveConfigField(key, value) { if (key === 'owner_name') config.owner.name = value; if (key === 'sprite') config.sprite = value; if (key === 'pet_scale') config.pet_scale = parseFloat(value) || 0; + if (key === 'ai_provider') config.aiProvider = normalizeAiProvider(value); // Queue file writes so concurrent calls don't clobber each other configWriteQueue = configWriteQueue.then(async function() { const raw = await readPetFile('config.md'); const { fields, body } = parseFrontmatter(raw); - fields[key] = value; + fields[key] = key === 'ai_provider' ? normalizeAiProvider(value) : value; await writePetFile('config.md', serializeFrontmatter(fields, body)); }).catch(function(err) { console.error('Failed to save config field ' + key + ':', err); @@ -136,13 +262,20 @@ export function saveConfigField(key, value) { } export function getConfig() { - return { ...config, pet: { ...config.pet }, owner: { ...config.owner } }; + return { + ...config, + pet: { ...config.pet }, + owner: { ...config.owner }, + pet_scale: config.pet_scale, + aiProvider: config.aiProvider, + }; } // --- System prompt --- function buildSystemPrompt() { - let prompt = 'Read the CLAUDE.md in your working directory for instructions.'; + var providerInfo = getAiProviderInfo(); + let prompt = 'Read the ' + ((providerInfo && providerInfo.contextFile) || 'CLAUDE.md') + ' in your working directory for instructions.'; if (config.pet.name) { prompt += ' Your name is ' + config.pet.name + '.'; } @@ -163,12 +296,17 @@ export function logActivity(entry) { if (activityLog.length > 50) activityLog.shift(); const description = entry.description || entry.type || ''; - if (description && claudeAvailable) { - claudeInPetDir([ - '--print', '--output-format', 'text', '--model', 'haiku', - '--tools', 'Write,Edit', '--dangerously-skip-permissions', - '-p', 'Append this line to me-journal.md (do NOT overwrite existing content, use Edit to add at the end): "- [' + time + '] ' + description.replace(/"/g, '\\"') + '"', - ]).catch(err => console.error('Failed to write journal:', err)); + if (description && isAiAvailable()) { + runInPetDir( + 'Append this line to me-journal.md (do NOT overwrite existing content, use Edit or Replace to add at the end): "- [' + time + '] ' + description.replace(/"/g, '\\"') + '"', + { + claudeTools: ['Write', 'Edit'], + allowedTools: GEMINI_EDIT_TOOLS, + skipPermissions: true, + } + ).catch(function(err) { + console.error('Failed to write journal:', err); + }); } } @@ -230,33 +368,46 @@ export function parseResponse(raw) { return { text, state, reactions }; } -// --- Claude CLI --- +// --- AI provider execution --- -var claudeAvailable = null; // null = unchecked, true/false after check +var providerAvailability = Object.create(null); -export async function checkClaudeCli() { - if (claudeAvailable !== null) return claudeAvailable; +export async function checkAiCli(provider) { + var providerInfo = getAiProviderInfo(provider); + if (!providerInfo) return false; + if (providerAvailability[providerInfo.id] !== undefined) { + return providerAvailability[providerInfo.id]; + } try { - var result = await Command.create('claude', ['--version']).execute(); - claudeAvailable = result.code === 0; + var result = await Command.create(providerInfo.command, ['--version']).execute(); + providerAvailability[providerInfo.id] = result.code === 0; } catch { - claudeAvailable = false; + providerAvailability[providerInfo.id] = false; } - return claudeAvailable; + return providerAvailability[providerInfo.id]; +} + +export function isAiAvailable(provider) { + var providerInfo = getAiProviderInfo(provider); + return !!(providerInfo && providerAvailability[providerInfo.id] === true); +} + +export async function checkClaudeCli() { + return checkAiCli('claude'); } export function isClaudeAvailable() { - return claudeAvailable === true; + return isAiAvailable('claude'); } -function claudeInPetDir(args) { +function runInPetDir(prompt, options) { return ensurePetDataPath().then(function(petDataPath) { - return Command.create('claude', args, { cwd: petDataPath }).execute(); + return executeAiCommand(prompt, { ...(options || {}), cwd: petDataPath }); }); } export async function think(context) { - if (!claudeAvailable) return null; + if (!isAiAvailable()) return null; const recentActivity = activityLog.length > 0 ? '\nRecent activity log:\n' + activityLog.slice(-5).map(a => '- ' + a.time + ': ' + (a.description || a.type)).join('\n') @@ -266,14 +417,11 @@ export async function think(context) { const fullPrompt = systemPrompt + recentActivity + '\n\nCurrent situation: ' + context + '\n\nRespond:'; try { - const result = await claudeInPetDir([ - '--print', - '--output-format', 'text', - '--model', 'haiku', - '--tools', 'Read,Write,Edit', - '--dangerously-skip-permissions', - '-p', fullPrompt, - ]); + const result = await runInPetDir(fullPrompt, { + claudeTools: ['Read', 'Write', 'Edit'], + allowedTools: GEMINI_EDIT_TOOLS, + skipPermissions: true, + }); const output = (result.stdout || '').trim(); console.log('🐱 Raw LLM:', output); @@ -291,21 +439,48 @@ export async function think(context) { // --- Daily digest --- export async function generateDailyDigest() { - if (!claudeAvailable || activityLog.length < 3) return null; + if (!isAiAvailable() || activityLog.length < 3) return null; const logText = activityLog.map(a => `${a.time}: ${a.description || a.type}`).join('\n'); const petName = config.pet.name || 'Phoebe'; try { - const result = await Command.create('claude', [ - '--print', - '--output-format', 'text', - '--model', 'haiku', - '-p', `You are ${petName} the cat. Summarize your owner's day in 2-3 short sentences based on this activity log. Be casual and cute, like a cat observing its human.\n\nActivity log:\n${logText}\n\nDaily summary:`, - ]).execute(); + const result = await executeAiCommand( + `You are ${petName} the cat. Summarize your owner's day in 2-3 short sentences based on this activity log. Be casual and cute, like a cat observing its human.\n\nActivity log:\n${logText}\n\nDaily summary:` + ); return (result.stdout || '').trim(); } catch { return null; } } + +export async function summarizePerceptionsForTimeline(date, perceptions) { + if (!isAiAvailable()) return null; + + try { + const result = await executeAiCommand( + 'Summarize these screen observations into a timeline for ' + date + '. Merge activities into coarse blocks of at least 15-20 minutes each - do NOT create short blocks for every minor change. Round times to the nearest 5 minutes. Format:\n\n## ' + date + '\n- HH:MM-HH:MM - Activity description\n- HH:MM-HH:MM - Activity description\n\nBe concise. Output ONLY the formatted timeline, nothing else.\n\nObservations:\n' + perceptions + ); + return (result.stdout || '').trim() || null; + } catch { + return null; + } +} + +export async function describeScreenImage(imagePath) { + if (!isAiAvailable()) return null; + + var prompt = 'Read the file at ' + imagePath + '. Describe in 1-2 SHORT sentences what the user is doing. Focus on: what app, what content. Output ONLY the description.'; + + try { + var result = await runInPetDir(prompt, { + claudeTools: ['Read'], + allowedTools: GEMINI_FILE_TOOLS, + skipPermissions: true, + }); + return (result.stdout || '').trim() || null; + } catch { + return null; + } +} diff --git a/src/main.js b/src/main.js index 564456f..ae2dcd9 100644 --- a/src/main.js +++ b/src/main.js @@ -10,9 +10,8 @@ import { initHearts } from './hearts.js'; import { initBubble } from './bubble-manager.js'; import { initBehavior } from './behavior.js'; import { initInteraction } from './interaction.js'; -import { getDefaultScale, openSettingsWindow, showContextMenu, openChatWindow } from './settings.js'; +import { getDefaultScale, initProviderChooser, openSettingsWindow, showContextMenu, openChatWindow } from './settings.js'; -// Shared state object — passed to all modules var pet = { canvas: document.getElementById('pet'), appWindow: getCurrentWindow(), @@ -20,6 +19,7 @@ var pet = { currentSprite: 'tabby_cat', petName: 'Phoebe', ownerName: '', + aiProvider: '', isWalking: false, llmBusy: false, dragStarted: false, @@ -31,6 +31,7 @@ var pet = { gainHeart: null, isSick: false, walkRandomDirection: null, + ensureAiProviderSelected: null, voice: function() { return voice(pet); }, resizeWindowToFit: null, }; @@ -42,7 +43,6 @@ pet.sprite = new SpriteAnimator( ); trackActivity(); -// Resize window to exactly fit the pet sprite (no padding) function resizeWindowToFit() { var size = pet.sprite.getSize(); var dpr = window.devicePixelRatio || 1; @@ -52,7 +52,6 @@ function resizeWindowToFit() { } pet.resizeWindowToFit = resizeWindowToFit; -// Init modules var hearts = initHearts(pet); pet.gainHeart = hearts.gainHeart; Object.defineProperty(pet, 'isSick', { get: function() { return hearts.isSick; } }); @@ -65,7 +64,9 @@ pet.walkRandomDirection = behavior.walkRandomDirection; initInteraction(pet); -// --- Right-click: context menu window --- +var providerChooser = initProviderChooser(pet); +pet.ensureAiProviderSelected = providerChooser.ensureAiProviderSelected; + document.addEventListener('contextmenu', function(e) { e.preventDefault(); var dpr = window.devicePixelRatio || 1; @@ -74,7 +75,6 @@ document.addEventListener('contextmenu', function(e) { }); }); -// --- Events from sub-windows --- listen('contextmenu:action', function(event) { var action = event.payload && event.payload.action; if (action === 'settings') openSettingsWindow().catch(function() {}); @@ -89,6 +89,10 @@ listen('settings:saved', function(event) { pet.showBubble('call me ' + pet.petName + ' now!', 3000, true); } if (d.ownerName !== undefined) pet.ownerName = d.ownerName; + if (d.aiProvider) { + pet.aiProvider = d.aiProvider; + providerChooser.syncAiProvider(d.aiProvider); + } if (d.sprite && d.sprite !== pet.currentSprite) { pet.currentSprite = d.sprite; pet.sprite.image.src = '/sprites/' + d.sprite + '.png'; @@ -102,11 +106,13 @@ listen('settings:saved', function(event) { listen('chat:submit', function(event) { var text = event.payload && event.payload.text; - if (!text) { pet.sprite.setState('idle'); return; } + if (!text) { + pet.sprite.setState('idle'); + return; + } handleChatMessage(text); }); -// Chat message handling async function handleChatMessage(text) { pet.gainHeart(); pet.llmBusy = true; @@ -134,24 +140,23 @@ async function handleChatMessage(text) { pet.lastInteractionTime = Date.now(); } -// Keyboard shortcut for devtools document.addEventListener('keydown', function(e) { if (e.metaKey && e.altKey && e.key === 'i') { invoke('toggle_devtools').catch(function() {}); } }); -// Animation loop function animationLoop(timestamp) { pet.sprite.update(timestamp); requestAnimationFrame(animationLoop); } requestAnimationFrame(animationLoop); -// Load config and start loadConfig().then(function(cfg) { pet.petName = cfg.pet.name; pet.ownerName = cfg.owner.name; + pet.aiProvider = cfg.aiProvider; + providerChooser.syncAiProvider(cfg.aiProvider); if (cfg.sprite && cfg.sprite !== pet.currentSprite) { pet.currentSprite = cfg.sprite; pet.sprite.image.src = '/sprites/' + pet.currentSprite + '.png'; @@ -161,6 +166,5 @@ loadConfig().then(function(cfg) { pet.sprite.setScale(scale); resizeWindowToFit(); hearts.updateTogether(); + behavior.start(); }); - -behavior.start(); diff --git a/src/settings-ui.js b/src/settings-ui.js index 18887d9..2642641 100644 --- a/src/settings-ui.js +++ b/src/settings-ui.js @@ -2,11 +2,12 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { emitTo } from '@tauri-apps/api/event'; import { SpriteAnimator, getSpriteRenderOptions } from './sprite.js'; -import { loadConfig, saveConfigField } from './brain.js'; +import { getSupportedAiProviders, loadConfig, saveConfigField } from './brain.js'; import { CHARACTERS } from './characters.js'; var appWindow = getCurrentWindow(); var currentSprite = 'tabby_cat'; +var currentAiProvider = ''; var previewAnimId = null; var previewAnimators = []; @@ -47,6 +48,35 @@ function updateActiveSprite() { }); } +var providerContainer = document.getElementById('settings-ai-provider-options'); +getSupportedAiProviders().forEach(function(provider) { + var btn = document.createElement('button'); + btn.className = 'provider-option'; + btn.dataset.provider = provider.id; + + var title = document.createElement('strong'); + title.textContent = provider.displayName; + + var description = document.createElement('span'); + description.textContent = provider.id === 'claude' + ? 'Uses Claude Code as the pet brain.' + : 'Uses Gemini CLI as the pet brain.'; + + btn.appendChild(title); + btn.appendChild(description); + btn.addEventListener('click', function() { + currentAiProvider = provider.id; + updateActiveProvider(); + }); + providerContainer.appendChild(btn); +}); + +function updateActiveProvider() { + document.querySelectorAll('.provider-option').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.provider === currentAiProvider); + }); +} + // Slider var scaleSlider = document.getElementById('setting-pet-scale'); var scaleValueEl = document.getElementById('pet-scale-value'); @@ -59,10 +89,12 @@ loadConfig().then(function(cfg) { document.getElementById('setting-pet-name').value = cfg.pet.name; document.getElementById('setting-owner-name').value = cfg.owner.name; currentSprite = cfg.sprite || 'tabby_cat'; + currentAiProvider = cfg.aiProvider || 'claude'; var scale = cfg.pet_scale > 0 ? cfg.pet_scale : 1.5; scaleSlider.value = scale; scaleValueEl.textContent = scale.toFixed(1) + 'x'; updateActiveSprite(); + updateActiveProvider(); startPreviewAnimations(); }); @@ -75,12 +107,14 @@ function saveAndClose() { saveConfigField('owner_name', newOwnerName); saveConfigField('sprite', currentSprite); saveConfigField('pet_scale', String(newScale)); + saveConfigField('ai_provider', currentAiProvider); emitTo('main', 'settings:saved', { petName: newPetName, ownerName: newOwnerName, sprite: currentSprite, scale: newScale, + aiProvider: currentAiProvider, }); stopPreviewAnimations(); diff --git a/src/settings.js b/src/settings.js index bcdb34f..1d04bda 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,6 +1,7 @@ -// Window managers: settings, context menu, chat +// Window managers: settings, context menu, chat, provider chooser import { emitTo } from '@tauri-apps/api/event'; import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { getSupportedAiProviders, saveConfigField } from './brain.js'; export function getDefaultScale() { var w = window.screen.availWidth; @@ -8,6 +9,75 @@ export function getDefaultScale() { return 1.5; } +export function initProviderChooser(pet) { + var overlay = document.getElementById('provider-overlay'); + var optionsRoot = document.getElementById('startup-ai-provider-options'); + var selectionResolver = null; + + if (optionsRoot && !optionsRoot.dataset.initialized) { + optionsRoot.dataset.initialized = 'true'; + getSupportedAiProviders().forEach(function(provider) { + var btn = document.createElement('button'); + btn.className = 'provider-option'; + btn.dataset.provider = provider.id; + + var title = document.createElement('strong'); + title.textContent = provider.displayName; + + var description = document.createElement('span'); + description.textContent = provider.id === 'claude' + ? 'Uses Claude Code as the pet brain.' + : 'Uses Gemini CLI as the pet brain.'; + + btn.appendChild(title); + btn.appendChild(description); + btn.addEventListener('click', function() { + pet.aiProvider = provider.id; + syncActiveSelection(); + saveConfigField('ai_provider', provider.id); + if (selectionResolver) { + overlay.classList.remove('show'); + var resolve = selectionResolver; + selectionResolver = null; + resolve(provider.id); + } + }); + optionsRoot.appendChild(btn); + }); + } + + function syncActiveSelection() { + if (!optionsRoot) return; + optionsRoot.querySelectorAll('.provider-option').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.provider === pet.aiProvider); + }); + } + + return { + ensureAiProviderSelected: function(currentProvider) { + pet.aiProvider = currentProvider || ''; + syncActiveSelection(); + + if (currentProvider) { + return Promise.resolve(currentProvider); + } + + if (!overlay) { + return Promise.resolve(''); + } + + overlay.classList.add('show'); + return new Promise(function(resolve) { + selectionResolver = resolve; + }); + }, + syncAiProvider: function(provider) { + pet.aiProvider = provider || ''; + syncActiveSelection(); + }, + }; +} + // --- Settings window --- var settingsWin = null; @@ -21,7 +91,7 @@ export async function openSettingsWindow() { url: url, title: 'Settings', width: 560, - height: 640, + height: 700, resizable: false, decorations: false, transparent: false, @@ -38,7 +108,11 @@ var menuWinReady = false; async function ensureMenuWindow() { if (menuWin && menuWinReady) return menuWin; var existing = await WebviewWindow.getByLabel('context-menu'); - if (existing) { menuWin = existing; menuWinReady = true; return menuWin; } + if (existing) { + menuWin = existing; + menuWinReady = true; + return menuWin; + } var url = new URL('./context-menu.html', window.location.href).toString(); menuWin = new WebviewWindow('context-menu', { @@ -60,14 +134,18 @@ async function ensureMenuWindow() { setTimeout(resolve, 1000); }); menuWinReady = true; - menuWin.once('tauri://destroyed', function() { menuWin = null; menuWinReady = false; }); + menuWin.once('tauri://destroyed', function() { + menuWin = null; + menuWinReady = false; + }); return menuWin; } export async function showContextMenu(screenXLogical, screenYLogical) { var menu = await ensureMenuWindow(); var dpr = window.devicePixelRatio || 1; - var menuW = 180, menuH = 120; + var menuW = 180; + var menuH = 120; var x = Math.min(screenXLogical, window.screen.availWidth - menuW - 10); var y = screenYLogical - menuH; if (y < 5) y = screenYLogical + 5; @@ -88,7 +166,11 @@ var chatWinReady = false; async function ensureChatWindow() { if (chatWin && chatWinReady) return chatWin; var existing = await WebviewWindow.getByLabel('chat'); - if (existing) { chatWin = existing; chatWinReady = true; return chatWin; } + if (existing) { + chatWin = existing; + chatWinReady = true; + return chatWin; + } var url = new URL('./chat.html', window.location.href).toString(); chatWin = new WebviewWindow('chat', { @@ -110,7 +192,10 @@ async function ensureChatWindow() { setTimeout(resolve, 1000); }); chatWinReady = true; - chatWin.once('tauri://destroyed', function() { chatWin = null; chatWinReady = false; }); + chatWin.once('tauri://destroyed', function() { + chatWin = null; + chatWinReady = false; + }); return chatWin; } @@ -119,9 +204,9 @@ export async function openChatWindow(pet) { var pos = await pet.appWindow.outerPosition(); var size = pet.sprite.getSize(); var dpr = window.devicePixelRatio || 1; - var chatW = 260, chatH = 50; + var chatW = 260; + var chatH = 50; - // Center below pet var xLogical = pos.x / dpr + size.width / 2 - chatW / 2; var yLogical = pos.y / dpr + size.height + 8; xLogical = Math.max(8, Math.min(window.screen.availWidth - chatW - 8, xLogical)); diff --git a/src/signals.js b/src/signals.js index 09b4858..dceb8cf 100644 --- a/src/signals.js +++ b/src/signals.js @@ -1,7 +1,7 @@ // Passive signal collection — what the pet can "see" import { Command } from '@tauri-apps/plugin-shell'; -import { ensurePetDataPath } from './brain.js'; +import { describeScreenImage, ensurePetDataPath } from './brain.js'; export function getTimeSignals() { const now = new Date(); @@ -46,13 +46,13 @@ export function getIdleSeconds() { // Screenshot — capture the screen with the mouse cursor (active display) // macOS screencapture: -x = no sound, -C = capture cursor (tells us which display) // -D flag not available, but screencapture without -m captures the main display -// We capture all displays and let Claude figure out what's on screen -const SCREENSHOT_PATH = '/tmp/tinyroommate-screenshot.png'; var screenRecordingDenied = false; export async function captureScreenContext() { + var screenshotPath = ''; try { var petDataPath = await ensurePetDataPath(); + screenshotPath = petDataPath + '/tinyroommate-screenshot.png'; // Step 1: Detect which display the mouse is on var displayNum = '1'; try { @@ -67,7 +67,7 @@ export async function captureScreenContext() { } // Step 2: Capture that display - var captureResult = await Command.create('screencapture', ['-x', '-D', displayNum, SCREENSHOT_PATH]).execute(); + var captureResult = await Command.create('screencapture', ['-x', '-D', displayNum, screenshotPath]).execute(); console.log('📸 screencapture exit:', captureResult.code); if (captureResult.code !== 0) { @@ -76,26 +76,20 @@ export async function captureScreenContext() { } screenRecordingDenied = false; - // Step 3: Ask Claude to describe it - var result = await Command.create('claude', [ - '--print', - '--tools', 'Read', - '--output-format', 'text', - '--dangerously-skip-permissions', - '--model', 'haiku', - '-p', 'Use the Read tool to look at ' + SCREENSHOT_PATH + '. Describe in 1-2 SHORT sentences what the user is doing. Focus on: what app, what content. Output ONLY the description.', - ], { cwd: petDataPath }).execute(); + // Step 3: Ask the selected AI CLI to describe it + var description = await describeScreenImage(screenshotPath); // Step 4: Clean up - Command.create('rm', [SCREENSHOT_PATH]).execute().catch(function() {}); + Command.create('rm', [screenshotPath]).execute().catch(function() {}); - const description = (result.stdout || '').trim(); console.log('📸 Screen context:', description); return description || null; } catch (err) { console.error('📸 Screenshot error:', err); // Clean up on error - Command.create('rm', [SCREENSHOT_PATH]).execute().catch(() => {}); + if (screenshotPath) { + Command.create('rm', [screenshotPath]).execute().catch(function() {}); + } return null; } }