-
Notifications
You must be signed in to change notification settings - Fork 15.9k
refactor(buddy): align companion system with official CLI #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /** | ||
| * Companion display card — shown by /buddy (no args). | ||
| * Mirrors official vc8 component: bordered box with sprite, stats, last reaction. | ||
| */ | ||
| import React from 'react'; | ||
| import { Box, Text } from '../ink.js'; | ||
| import { useInput } from '../ink.js'; | ||
| import { renderSprite } from './sprites.js'; | ||
| import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from './types.js'; | ||
|
|
||
| const CARD_WIDTH = 40; | ||
| const CARD_PADDING_X = 2; | ||
|
|
||
| function StatBar({ name, value }: { name: string; value: number }) { | ||
| const clamped = Math.max(0, Math.min(100, value)); | ||
| const filled = Math.round(clamped / 10); | ||
| const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled); | ||
| return ( | ||
| <Text> | ||
| {name.padEnd(10)} {bar} {String(value).padStart(3)} | ||
| </Text> | ||
| ); | ||
| } | ||
|
|
||
| export function CompanionCard({ | ||
| companion, | ||
| lastReaction, | ||
| onDone, | ||
| }: { | ||
| companion: Companion; | ||
| lastReaction?: string; | ||
| onDone?: (result?: string, options?: { display?: string }) => void; | ||
| }) { | ||
| const color = RARITY_COLORS[companion.rarity]; | ||
| const stars = RARITY_STARS[companion.rarity]; | ||
| const sprite = renderSprite(companion, 0); | ||
|
|
||
| // Press any key to dismiss | ||
| useInput( | ||
| () => { | ||
| onDone?.(undefined, { display: 'skip' }); | ||
| }, | ||
| { isActive: onDone !== undefined }, | ||
| ); | ||
|
|
||
| return ( | ||
| <Box | ||
| flexDirection="column" | ||
| borderStyle="round" | ||
| borderColor={color} | ||
| paddingX={CARD_PADDING_X} | ||
| paddingY={1} | ||
| width={CARD_WIDTH} | ||
| flexShrink={0} | ||
| > | ||
| {/* Header: rarity + species */} | ||
| <Box justifyContent="space-between"> | ||
| <Text bold color={color}> | ||
| {stars} {companion.rarity.toUpperCase()} | ||
| </Text> | ||
| <Text color={color}>{companion.species.toUpperCase()}</Text> | ||
| </Box> | ||
|
|
||
| {/* Shiny indicator */} | ||
| {companion.shiny && ( | ||
| <Text color="warning" bold> | ||
| {'\u2728'} SHINY {'\u2728'} | ||
| </Text> | ||
| )} | ||
|
|
||
| {/* Sprite */} | ||
| <Box flexDirection="column" marginY={1}> | ||
| {sprite.map((line, i) => ( | ||
| <Text key={i} color={color}> | ||
| {line} | ||
| </Text> | ||
| ))} | ||
| </Box> | ||
|
|
||
| {/* Name */} | ||
| <Text bold>{companion.name}</Text> | ||
|
|
||
| {/* Personality */} | ||
| <Box marginY={1}> | ||
| <Text dimColor italic> | ||
| "{companion.personality}" | ||
| </Text> | ||
| </Box> | ||
|
|
||
| {/* Stats */} | ||
| <Box flexDirection="column"> | ||
| {STAT_NAMES.map(name => ( | ||
| <StatBar key={name} name={name} value={companion.stats[name] ?? 0} /> | ||
| ))} | ||
| </Box> | ||
|
|
||
| {/* Last reaction */} | ||
| {lastReaction && ( | ||
| <Box flexDirection="column" marginTop={1}> | ||
| <Text dimColor>last said</Text> | ||
| <Box borderStyle="round" borderColor="inactive" paddingX={1}> | ||
| <Text dimColor italic> | ||
| {lastReaction} | ||
| </Text> | ||
| </Box> | ||
| </Box> | ||
| )} | ||
| </Box> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,160 @@ | ||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Companion reaction system — aligns with official ZUK + Dc8 pattern. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * Called from REPL.tsx after each query turn. Checks mute state, frequency | ||||||||||||||||||||||||||||||||||||||||||
| * limits, and @-mention detection, then calls the buddy_react API to | ||||||||||||||||||||||||||||||||||||||||||
| * generate a reaction shown in the CompanionSprite speech bubble. | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| import { getCompanion } from './companion.js' | ||||||||||||||||||||||||||||||||||||||||||
| import { getGlobalConfig } from '../utils/config.js' | ||||||||||||||||||||||||||||||||||||||||||
| import { getClaudeAIOAuthTokens } from '../utils/auth.js' | ||||||||||||||||||||||||||||||||||||||||||
| import { getOauthConfig } from '../constants/oauth.js' | ||||||||||||||||||||||||||||||||||||||||||
| import { getUserAgent } from '../utils/http.js' | ||||||||||||||||||||||||||||||||||||||||||
| import type { Message } from '../types/message.js' | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use These imports currently violate the repository import-path convention. Proposed fix-import { getCompanion } from './companion.js'
-import { getGlobalConfig } from '../utils/config.js'
-import { getClaudeAIOAuthTokens } from '../utils/auth.js'
-import { getOauthConfig } from '../constants/oauth.js'
-import { getUserAgent } from '../utils/http.js'
-import type { Message } from '../types/message.js'
+import { getCompanion } from 'src/buddy/companion.js'
+import { getGlobalConfig } from 'src/utils/config.js'
+import { getClaudeAIOAuthTokens } from 'src/utils/auth.js'
+import { getOauthConfig } from 'src/constants/oauth.js'
+import { getUserAgent } from 'src/utils/http.js'
+import type { Message } from 'src/types/message.js'As per coding guidelines, 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // ─── Rate limiting ────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| let lastReactTime = 0 | ||||||||||||||||||||||||||||||||||||||||||
| const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // ─── Recent reactions (avoid repetition) ──────────── | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const recentReactions: string[] = [] | ||||||||||||||||||||||||||||||||||||||||||
| const MAX_RECENT = 8 | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // ─── Public API ───────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Trigger a companion reaction after a query turn. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * Mirrors official `ZUK()`: | ||||||||||||||||||||||||||||||||||||||||||
| * 1. Check companion exists and is not muted | ||||||||||||||||||||||||||||||||||||||||||
| * 2. Detect if user @-mentioned companion by name | ||||||||||||||||||||||||||||||||||||||||||
| * 3. Apply rate limiting (skip if not addressed and too soon) | ||||||||||||||||||||||||||||||||||||||||||
| * 4. Build conversation transcript | ||||||||||||||||||||||||||||||||||||||||||
| * 5. Call buddy_react API | ||||||||||||||||||||||||||||||||||||||||||
| * 6. Pass reaction text to setReaction callback | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| export function triggerCompanionReaction( | ||||||||||||||||||||||||||||||||||||||||||
| messages: Message[], | ||||||||||||||||||||||||||||||||||||||||||
| setReaction: (text: string | undefined) => void, | ||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||
| const companion = getCompanion() | ||||||||||||||||||||||||||||||||||||||||||
| if (!companion || getGlobalConfig().companionMuted) return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const addressed = isAddressed(messages, companion.name) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const now = Date.now() | ||||||||||||||||||||||||||||||||||||||||||
| if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const transcript = buildTranscript(messages) | ||||||||||||||||||||||||||||||||||||||||||
| if (!transcript.trim()) return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| lastReactTime = now | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| void callBuddyReactAPI(companion, transcript, addressed) | ||||||||||||||||||||||||||||||||||||||||||
| .then(reaction => { | ||||||||||||||||||||||||||||||||||||||||||
| if (!reaction) return | ||||||||||||||||||||||||||||||||||||||||||
| recentReactions.push(reaction) | ||||||||||||||||||||||||||||||||||||||||||
| if (recentReactions.length > MAX_RECENT) recentReactions.shift() | ||||||||||||||||||||||||||||||||||||||||||
| setReaction(reaction) | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| .catch(() => {}) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // ─── Helpers ──────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function isAddressed(messages: Message[], name: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||
| const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i') | ||||||||||||||||||||||||||||||||||||||||||
| for ( | ||||||||||||||||||||||||||||||||||||||||||
| let i = messages.length - 1; | ||||||||||||||||||||||||||||||||||||||||||
| i >= Math.max(0, messages.length - 3); | ||||||||||||||||||||||||||||||||||||||||||
| i-- | ||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||
| const m = messages[i] | ||||||||||||||||||||||||||||||||||||||||||
| if (m?.type !== 'user') continue | ||||||||||||||||||||||||||||||||||||||||||
| const content = (m as any).message?.content | ||||||||||||||||||||||||||||||||||||||||||
| if (typeof content === 'string' && pattern.test(content)) return true | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+68
to
+77
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This regex matches bare names ( Proposed fix function isAddressed(messages: Message[], name: string): boolean {
- const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')
+ const pattern = new RegExp(`(?:^|\\s)@${escapeRegex(name)}(?=\\b|\\s|$)`, 'i')
for (
let i = messages.length - 1;
i >= Math.max(0, messages.length - 3);
i--
) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| return false | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function escapeRegex(s: string): string { | ||||||||||||||||||||||||||||||||||||||||||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function buildTranscript(messages: Message[]): string { | ||||||||||||||||||||||||||||||||||||||||||
| return messages | ||||||||||||||||||||||||||||||||||||||||||
| .slice(-12) | ||||||||||||||||||||||||||||||||||||||||||
| .filter(m => m.type === 'user' || m.type === 'assistant') | ||||||||||||||||||||||||||||||||||||||||||
| .map(m => { | ||||||||||||||||||||||||||||||||||||||||||
| const role = m.type === 'user' ? 'user' : 'claude' | ||||||||||||||||||||||||||||||||||||||||||
| const content = (m as any).message?.content | ||||||||||||||||||||||||||||||||||||||||||
| const text = | ||||||||||||||||||||||||||||||||||||||||||
| typeof content === 'string' | ||||||||||||||||||||||||||||||||||||||||||
| ? content.slice(0, 300) | ||||||||||||||||||||||||||||||||||||||||||
| : Array.isArray(content) | ||||||||||||||||||||||||||||||||||||||||||
| ? content | ||||||||||||||||||||||||||||||||||||||||||
| .filter((b: any) => b?.type === 'text') | ||||||||||||||||||||||||||||||||||||||||||
| .map((b: any) => b.text) | ||||||||||||||||||||||||||||||||||||||||||
| .join(' ') | ||||||||||||||||||||||||||||||||||||||||||
| .slice(0, 300) | ||||||||||||||||||||||||||||||||||||||||||
| : '' | ||||||||||||||||||||||||||||||||||||||||||
| return `${role}: ${text}` | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| .join('\n') | ||||||||||||||||||||||||||||||||||||||||||
| .slice(0, 5000) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // ─── API call ─────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async function callBuddyReactAPI( | ||||||||||||||||||||||||||||||||||||||||||
| companion: { | ||||||||||||||||||||||||||||||||||||||||||
| name: string | ||||||||||||||||||||||||||||||||||||||||||
| personality: string | ||||||||||||||||||||||||||||||||||||||||||
| species: string | ||||||||||||||||||||||||||||||||||||||||||
| rarity: string | ||||||||||||||||||||||||||||||||||||||||||
| stats: Record<string, number> | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| transcript: string, | ||||||||||||||||||||||||||||||||||||||||||
| addressed: boolean, | ||||||||||||||||||||||||||||||||||||||||||
| ): Promise<string | null> { | ||||||||||||||||||||||||||||||||||||||||||
| const tokens = getClaudeAIOAuthTokens() | ||||||||||||||||||||||||||||||||||||||||||
| if (!tokens?.accessToken) return null | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const orgId = getGlobalConfig().oauthAccount?.organizationUuid | ||||||||||||||||||||||||||||||||||||||||||
| if (!orgId) return null | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const baseUrl = getOauthConfig().BASE_API_URL | ||||||||||||||||||||||||||||||||||||||||||
| const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react` | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const resp = await fetch(url, { | ||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||
| Authorization: `Bearer ${tokens.accessToken}`, | ||||||||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||||
| 'User-Agent': getUserAgent(), | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||||
| name: companion.name.slice(0, 32), | ||||||||||||||||||||||||||||||||||||||||||
| personality: companion.personality.slice(0, 200), | ||||||||||||||||||||||||||||||||||||||||||
| species: companion.species, | ||||||||||||||||||||||||||||||||||||||||||
| rarity: companion.rarity, | ||||||||||||||||||||||||||||||||||||||||||
| stats: companion.stats, | ||||||||||||||||||||||||||||||||||||||||||
| transcript, | ||||||||||||||||||||||||||||||||||||||||||
| reason: addressed ? 'addressed' : 'turn', | ||||||||||||||||||||||||||||||||||||||||||
| recent: recentReactions.map(r => r.slice(0, 200)), | ||||||||||||||||||||||||||||||||||||||||||
| addressed, | ||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||
| signal: AbortSignal.timeout(10_000), | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!resp.ok) return null | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||
| const data = (await resp.json()) as { reaction?: string } | ||||||||||||||||||||||||||||||||||||||||||
| return data.reaction?.trim() || null | ||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||
| return null | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
src/path aliases for new TSX imports.Lines 6-9 introduce new relative imports; these should use
src/...aliases.Suggested fix
As per coding guidelines
Import src/ path alias via tsconfig mapping instead of relative paths in imports.📝 Committable suggestion
🤖 Prompt for AI Agents