diff --git a/scripts/build.ts b/scripts/build.ts index d9c15594..b79c8f77 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -3,7 +3,7 @@ * distributable JS file using Bun's bundler. * * Handles: - * - bun:bundle feature() flags → all false (disables internal-only features) + * - bun:bundle feature() flags for the open build * - MACRO.* globals → inlined version/build-time constants * - src/ path aliases */ @@ -14,8 +14,9 @@ import { noTelemetryPlugin } from './no-telemetry-plugin' const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) const version = pkg.version -// Feature flags — all disabled for the open build. -// These gate Anthropic-internal features (voice, proactive, kairos, etc.) +// Feature flags for the open build. +// Most Anthropic-internal features stay off; open-build features can be +// selectively enabled here when their full source exists in the mirror. const featureFlags: Record = { VOICE_MODE: false, PROACTIVE: false, @@ -37,7 +38,7 @@ const featureFlags: Record = { TRANSCRIPT_CLASSIFIER: false, WEB_BROWSER_TOOL: false, MESSAGE_ACTIONS: false, - BUDDY: false, + BUDDY: true, CHICAGO_MCP: false, COWORKER_TYPE_TELEMETRY: false, } @@ -110,7 +111,7 @@ export async function handleBgFlag() { throw new Error("Background sessions are build.onLoad( { filter: /.*/, namespace: 'bun-bundle-shim' }, () => ({ - contents: `export function feature(name) { return false; }`, + contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`, loader: 'js', }), ) diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 7cd4385f..cfd09514 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -1,5 +1,4 @@ import { c as _c } from "react-compiler-runtime"; -import { feature } from 'bun:bundle'; import figures from 'figures'; import React, { useEffect, useRef, useState } from 'react'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; @@ -11,6 +10,7 @@ import { getGlobalConfig } from '../utils/config.js'; import { isFullscreenActive } from '../utils/fullscreen.js'; import type { Theme } from '../utils/theme.js'; import { getCompanion } from './companion.js'; +import { isBuddyEnabled } from './feature.js'; import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; import { RARITY_COLORS } from './types.js'; const TICK_MS = 500; @@ -165,7 +165,7 @@ function spriteColWidth(nameWidth: number): number { // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row // (above input in fullscreen, below in scrollback), so no reservation. export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { - if (!feature('BUDDY')) return 0; + if (!isBuddyEnabled()) return 0; const companion = getCompanion(); if (!companion || getGlobalConfig().companionMuted) return 0; if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; @@ -212,7 +212,7 @@ export function CompanionSprite(): React.ReactNode { return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked }, [reaction, setAppState]); - if (!feature('BUDDY')) return null; + if (!isBuddyEnabled()) return null; const companion = getCompanion(); if (!companion || getGlobalConfig().companionMuted) return null; const color = RARITY_COLORS[companion.rarity]; @@ -337,7 +337,7 @@ export function CompanionFloatingBubble() { t3 = $[4]; } useEffect(t2, t3); - if (!feature("BUDDY") || !reaction) { + if (!isBuddyEnabled() || !reaction) { return null; } const companion = getCompanion(); diff --git a/src/buddy/feature.ts b/src/buddy/feature.ts new file mode 100644 index 00000000..ce409b7a --- /dev/null +++ b/src/buddy/feature.ts @@ -0,0 +1,3 @@ +export function isBuddyEnabled(): boolean { + return true +} diff --git a/src/buddy/observer.ts b/src/buddy/observer.ts new file mode 100644 index 00000000..2bad6457 --- /dev/null +++ b/src/buddy/observer.ts @@ -0,0 +1,65 @@ +import type { Message } from '../types/message.js' +import { getGlobalConfig } from '../utils/config.js' +import { getUserMessageText } from '../utils/messages.js' +import { getCompanion } from './companion.js' + +const DIRECT_REPLIES = [ + 'I am observing.', + 'I am helping from the corner.', + 'I saw that.', + 'Still here.', + 'Watching closely.', +] as const + +const PET_REPLIES = [ + 'happy chirp', + 'tiny victory dance', + 'quietly approves', + 'wiggles with joy', + 'looks pleased', +] as const + +function hashString(s: string): number { + let h = 2166136261 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 16777619) + } + return h >>> 0 +} + +function pickDeterministic(items: readonly T[], seed: string): T { + return items[hashString(seed) % items.length]! +} + +export async function fireCompanionObserver( + messages: Message[], + onReaction: (reaction: string | undefined) => void, +): Promise { + const companion = getCompanion() + if (!companion || getGlobalConfig().companionMuted) return + + const lastUser = [...messages].reverse().find(msg => msg.type === 'user') + if (!lastUser) return + + const text = getUserMessageText(lastUser)?.trim() + if (!text) return + + const lower = text.toLowerCase() + const companionName = companion.name.toLowerCase() + + if (lower.includes('/buddy')) { + onReaction(pickDeterministic(PET_REPLIES, text + companion.name)) + return + } + + if ( + lower.includes(companionName) || + lower.includes('buddy') || + lower.includes('companion') + ) { + onReaction( + `${companion.name}: ${pickDeterministic(DIRECT_REPLIES, text + companion.personality)}`, + ) + } +} diff --git a/src/buddy/prompt.ts b/src/buddy/prompt.ts index c5782c0c..a6efdf38 100644 --- a/src/buddy/prompt.ts +++ b/src/buddy/prompt.ts @@ -1,8 +1,8 @@ -import { feature } from 'bun:bundle' import type { Message } from '../types/message.js' import type { Attachment } from '../utils/attachments.js' import { getGlobalConfig } from '../utils/config.js' import { getCompanion } from './companion.js' +import { isBuddyEnabled } from './feature.js' export function companionIntroText(name: string, species: string): string { return `# Companion @@ -15,7 +15,7 @@ When the user addresses ${name} directly (by name), its bubble will answer. Your export function getCompanionIntroAttachment( messages: Message[] | undefined, ): Attachment[] { - if (!feature('BUDDY')) return [] + if (!isBuddyEnabled()) return [] const companion = getCompanion() if (!companion || getGlobalConfig().companionMuted) return [] diff --git a/src/buddy/useBuddyNotification.tsx b/src/buddy/useBuddyNotification.tsx index 861d9487..c742c646 100644 --- a/src/buddy/useBuddyNotification.tsx +++ b/src/buddy/useBuddyNotification.tsx @@ -1,10 +1,10 @@ import { c as _c } from "react-compiler-runtime"; -import { feature } from 'bun:bundle'; import React, { useEffect } from 'react'; import { useNotifications } from '../context/notifications.js'; import { Text } from '../ink.js'; import { getGlobalConfig } from '../utils/config.js'; import { getRainbowColor } from '../utils/thinking.js'; +import { isBuddyEnabled } from './feature.js'; // Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter // buzz instead of a single UTC-midnight spike, gentler on soul-gen load. @@ -50,7 +50,7 @@ export function useBuddyNotification() { let t1; if ($[0] !== addNotification || $[1] !== removeNotification) { t0 = () => { - if (!feature("BUDDY")) { + if (!isBuddyEnabled()) { return; } const config = getGlobalConfig(); @@ -80,7 +80,7 @@ export function findBuddyTriggerPositions(text: string): Array<{ start: number; end: number; }> { - if (!feature('BUDDY')) return []; + if (!isBuddyEnabled()) return []; const triggers: Array<{ start: number; end: number; diff --git a/src/commands.ts b/src/commands.ts index e38fcdbc..cba5bc2e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -59,6 +59,7 @@ import usage from './commands/usage/index.js' import theme from './commands/theme/index.js' import vim from './commands/vim/index.js' import { feature } from 'bun:bundle' +import { isBuddyEnabled } from './buddy/feature.js' // Dead code elimination: conditional imports /* eslint-disable @typescript-eslint/no-require-imports */ const proactive = @@ -117,7 +118,7 @@ const forkCmd = feature('FORK_SUBAGENT') require('./commands/fork/index.js') as typeof import('./commands/fork/index.js') ).default : null -const buddy = feature('BUDDY') +const buddy = isBuddyEnabled() ? ( require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js') ).default diff --git a/src/commands/buddy/buddy.tsx b/src/commands/buddy/buddy.tsx new file mode 100644 index 00000000..7fe106c3 --- /dev/null +++ b/src/commands/buddy/buddy.tsx @@ -0,0 +1,185 @@ +import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js' +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import { companionUserId, getCompanion, rollWithSeed } from '../../buddy/companion.js' +import type { StoredCompanion } from '../../buddy/types.js' +import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js' + +const NAME_PREFIXES = [ + 'Byte', + 'Echo', + 'Glint', + 'Miso', + 'Nova', + 'Pixel', + 'Rune', + 'Static', + 'Vector', + 'Whisk', +] as const + +const NAME_SUFFIXES = [ + 'bean', + 'bit', + 'bud', + 'dot', + 'ling', + 'loop', + 'moss', + 'patch', + 'puff', + 'spark', +] as const + +const PERSONALITIES = [ + 'Curious and quietly encouraging', + 'A patient little watcher with strong debugging instincts', + 'Playful, observant, and suspicious of flaky tests', + 'Calm under pressure and fond of clean diffs', + 'A tiny terminal gremlin who likes successful builds', +] as const + +const PET_REACTIONS = [ + 'leans into the headpat', + 'does a proud little bounce', + 'emits a content beep', + 'looks delighted', + 'wiggles happily', +] as const + +function hashString(s: string): number { + let h = 2166136261 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 16777619) + } + return h >>> 0 +} + +function pickDeterministic(items: readonly T[], seed: string): T { + return items[hashString(seed) % items.length]! +} + +function titleCase(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1) +} + +function createStoredCompanion(): StoredCompanion { + const userId = companionUserId() + const { bones } = rollWithSeed(`${userId}:buddy`) + const prefix = pickDeterministic(NAME_PREFIXES, `${userId}:prefix`) + const suffix = pickDeterministic(NAME_SUFFIXES, `${userId}:suffix`) + const personality = pickDeterministic(PERSONALITIES, `${userId}:personality`) + + return { + name: `${prefix}${suffix}`, + personality: `${personality}.`, + hatchedAt: Date.now(), + } +} + +function setCompanionReaction( + context: LocalJSXCommandContext, + reaction: string | undefined, + pet = false, +): void { + context.setAppState(prev => ({ + ...prev, + companionReaction: reaction, + companionPetAt: pet ? Date.now() : prev.companionPetAt, + })) +} + +function showHelp(onDone: LocalJSXCommandOnDone): void { + onDone( + 'Usage: /buddy [status|mute|unmute]\n\nRun /buddy with no args to hatch your companion the first time, then pet it on later runs.', + { display: 'system' }, + ) +} + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, + args?: string, +): Promise { + const arg = args?.trim().toLowerCase() ?? '' + + if (COMMON_HELP_ARGS.includes(arg) || arg === '') { + const existing = getCompanion() + if (arg !== '' || existing) { + if (arg !== '') { + showHelp(onDone) + return null + } + } + } + + if (COMMON_HELP_ARGS.includes(arg)) { + showHelp(onDone) + return null + } + + if (COMMON_INFO_ARGS.includes(arg) || arg === 'status') { + const companion = getCompanion() + if (!companion) { + onDone('No buddy hatched yet. Run /buddy to hatch one.', { + display: 'system', + }) + return null + } + onDone( + `${companion.name} is your ${titleCase(companion.rarity)} ${companion.species}. ${companion.personality}`, + { display: 'system' }, + ) + return null + } + + if (arg === 'mute' || arg === 'unmute') { + const muted = arg === 'mute' + saveGlobalConfig(current => ({ + ...current, + companionMuted: muted, + })) + if (muted) { + setCompanionReaction(context, undefined) + } + onDone(`Buddy ${muted ? 'muted' : 'unmuted'}.`, { display: 'system' }) + return null + } + + if (arg !== '') { + showHelp(onDone) + return null + } + + let companion = getCompanion() + if (!companion) { + const stored = createStoredCompanion() + saveGlobalConfig(current => ({ + ...current, + companion: stored, + companionMuted: false, + })) + companion = { + ...rollWithSeed(`${companionUserId()}:buddy`).bones, + ...stored, + } + setCompanionReaction( + context, + `${companion.name} the ${companion.species} has hatched.`, + true, + ) + onDone( + `${companion.name} the ${companion.species} is now your buddy. Run /buddy again to pet them.`, + { display: 'system' }, + ) + return null + } + + const reaction = `${companion.name} ${pickDeterministic( + PET_REACTIONS, + `${Date.now()}:${companion.name}`, + )}` + setCompanionReaction(context, reaction, true) + onDone(undefined, { display: 'skip' }) + return null +} diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts new file mode 100644 index 00000000..84b14dd3 --- /dev/null +++ b/src/commands/buddy/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const buddy = { + type: 'local-jsx', + name: 'buddy', + description: 'Hatch, pet, and manage your Open Claude companion', + immediate: true, + argumentHint: '[status|mute|unmute|help]', + load: () => import('./buddy.js'), +} satisfies Command + +export default buddy diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index bd3d7ec2..27e6ff4d 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -13,6 +13,7 @@ import { getCwd } from 'src/utils/cwd.js'; import { isQueuedCommandEditable, popAllEditable } from 'src/utils/messageQueueManager.js'; import stripAnsi from 'strip-ansi'; import { companionReservedColumns } from '../../buddy/CompanionSprite.js'; +import { isBuddyEnabled } from '../../buddy/feature.js'; import { findBuddyTriggerPositions, useBuddyNotification } from '../../buddy/useBuddyNotification.js'; import { FastModePicker } from '../../commands/fast/fast.js'; import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js'; @@ -309,7 +310,7 @@ function PromptInput({ const { companion: _companion, companionMuted - } = feature('BUDDY') ? getGlobalConfig() : { + } = isBuddyEnabled() ? getGlobalConfig() : { companion: undefined, companionMuted: undefined }; @@ -1786,7 +1787,7 @@ function PromptInput({ } switch (footerItemSelected) { case 'companion': - if (feature('BUDDY')) { + if (isBuddyEnabled()) { selectFooterItem(null); void onSubmit('/buddy'); } @@ -1981,8 +1982,7 @@ function PromptInput({ }); }, [effortNotificationText, addNotification, removeNotification]); useBuddyNotification(); - const companionSpeaking = feature('BUDDY') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + const companionSpeaking = isBuddyEnabled() ? useAppState(s => s.companionReaction !== undefined) : false; const { columns, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index aa0e0672..b091990b 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -275,6 +275,8 @@ const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/We import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'; import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'; import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js'; +import { isBuddyEnabled } from '../buddy/feature.js'; +import { fireCompanionObserver } from '../buddy/observer.js'; import { DevBar } from '../components/DevBar.js'; // Session manager removed - using AppState now import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; @@ -1302,7 +1304,7 @@ export function REPL({ // Dismiss the companion bubble on scroll — it's absolute-positioned // at bottom-right and covers transcript content. Scrolling = user is // trying to read something under it. - if (feature('BUDDY')) { + if (isBuddyEnabled()) { setAppState(prev => prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined @@ -2806,7 +2808,7 @@ export function REPL({ })) { onQueryEvent(event); } - if (feature('BUDDY')) { + if (isBuddyEnabled()) { void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : { ...prev, companionReaction: reaction @@ -4567,7 +4569,7 @@ export function REPL({ {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? : null} - : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { + : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { setCursor(null); jumpToNew(scrollRef.current); }} scrollable={<> @@ -4592,8 +4594,8 @@ export function REPL({ {showSpinner && 0} leaderIsIdle={!isLoading} />} {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } {isFullscreenEnvEnabled() && } - } bottom={ - {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? : null} + } bottom={ + {isBuddyEnabled() && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? : null} {permissionStickyFooter} {/* Immediate local-jsx commands (/btw, /sandbox, /assistant, @@ -4997,7 +4999,7 @@ export function REPL({ }} />} {"external" === 'ant' && } - {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} + {isBuddyEnabled() && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} } /> ; diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index 52f56368..36f5e57c 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -250,6 +250,7 @@ import { isInProcessTeammate } from './teammateContext.js' import { removeTeammateFromTeamFile } from './swarm/teamHelpers.js' import { unassignTeammateTasks } from './tasks.js' import { getCompanionIntroAttachment } from '../buddy/prompt.js' +import { isBuddyEnabled } from '../buddy/feature.js' export const TODO_REMINDER_CONFIG = { TURNS_SINCE_WRITE: 10, @@ -861,10 +862,10 @@ export async function getAttachments( ), ), ), - ...(feature('BUDDY') - ? [ - maybe('companion_intro', () => - Promise.resolve(getCompanionIntroAttachment(messages)), + ...(isBuddyEnabled() + ? [ + maybe('companion_intro', () => + Promise.resolve(getCompanionIntroAttachment(messages)), ), ] : []),