Skip to content

Commit 9911194

Browse files
unraidclaude
andcommitted
refactor(buddy): align companion system with official CLI
## Summary Reverse-engineered the official Claude Code CLI (v2.1.91) buddy/companion system and aligned our implementation to match. ## Changes (7 files) ### Added - `src/buddy/CompanionCard.tsx` (+109) JSX bordered card matching official vc8: rarity header, colored sprite, name, personality, 10-bar stats, last reaction in nested border. - `src/buddy/companionReact.ts` (+156) Reaction system matching official ZUK+Dc8: 45s rate limiting, @-mention detection, transcript builder (12 msgs, 5000 chars), POST buddy_react API. ### Modified - `src/commands/buddy/index.ts` type: local -> local-jsx, description/argumentHint/immediate/isHidden. - `src/commands/buddy/buddy.ts` LocalCommandCall -> LocalJSXCommandCall signature (onDone, context, args). Removed mute/unmute/rehatch (official uses off/on only). /buddy show returns CompanionCard JSX instead of plain text. Pet auto-unmutes. companionMuted writes globalConfig (matches UI read source). - `src/screens/REPL.tsx` (line 2808) globalThis.fireCompanionObserver -> import triggerCompanionReaction. - `src/state/AppStateStore.ts` — comment fix. - `src/types/global.d.ts` — removed fireCompanionObserver declaration. ## Data flow (verified consistent) - companionMuted: saveGlobalConfig() <-> getGlobalConfig() (6 read sites) - companionReaction: setAppState() <-> useAppState() (4 sites) - companionPetAt: setAppState() <-> useAppState() (2 sites) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7935bfb commit 9911194

7 files changed

Lines changed: 382 additions & 181 deletions

File tree

src/buddy/CompanionCard.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Companion display card — shown by /buddy (no args).
3+
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
4+
*/
5+
import React from 'react';
6+
import { Box, Text } from '../ink.js';
7+
import { useInput } from '../ink.js';
8+
import { renderSprite } from './sprites.js';
9+
import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from './types.js';
10+
11+
const CARD_WIDTH = 40;
12+
const CARD_PADDING_X = 2;
13+
14+
function StatBar({ name, value }: { name: string; value: number }) {
15+
const filled = Math.round(value / 10);
16+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
17+
return (
18+
<Text>
19+
{name.padEnd(10)} {bar} {String(value).padStart(3)}
20+
</Text>
21+
);
22+
}
23+
24+
export function CompanionCard({
25+
companion,
26+
lastReaction,
27+
onDone,
28+
}: {
29+
companion: Companion;
30+
lastReaction?: string;
31+
onDone?: (result?: string, options?: { display?: string }) => void;
32+
}) {
33+
const color = RARITY_COLORS[companion.rarity];
34+
const stars = RARITY_STARS[companion.rarity];
35+
const sprite = renderSprite(companion, 0);
36+
37+
// Press any key to dismiss
38+
useInput(
39+
() => {
40+
onDone?.(undefined, { display: 'skip' });
41+
},
42+
{ isActive: onDone !== undefined },
43+
);
44+
45+
return (
46+
<Box
47+
flexDirection="column"
48+
borderStyle="round"
49+
borderColor={color}
50+
paddingX={CARD_PADDING_X}
51+
paddingY={1}
52+
width={CARD_WIDTH}
53+
flexShrink={0}
54+
>
55+
{/* Header: rarity + species */}
56+
<Box justifyContent="space-between">
57+
<Text bold color={color}>
58+
{stars} {companion.rarity.toUpperCase()}
59+
</Text>
60+
<Text color={color}>{companion.species.toUpperCase()}</Text>
61+
</Box>
62+
63+
{/* Shiny indicator */}
64+
{companion.shiny && (
65+
<Text color="warning" bold>
66+
{'\u2728'} SHINY {'\u2728'}
67+
</Text>
68+
)}
69+
70+
{/* Sprite */}
71+
<Box flexDirection="column" marginY={1}>
72+
{sprite.map((line, i) => (
73+
<Text key={i} color={color}>
74+
{line}
75+
</Text>
76+
))}
77+
</Box>
78+
79+
{/* Name */}
80+
<Text bold>{companion.name}</Text>
81+
82+
{/* Personality */}
83+
<Box marginY={1}>
84+
<Text dimColor italic>
85+
&quot;{companion.personality}&quot;
86+
</Text>
87+
</Box>
88+
89+
{/* Stats */}
90+
<Box flexDirection="column">
91+
{STAT_NAMES.map(name => (
92+
<StatBar key={name} name={name} value={companion.stats[name] ?? 0} />
93+
))}
94+
</Box>
95+
96+
{/* Last reaction */}
97+
{lastReaction && (
98+
<Box flexDirection="column" marginTop={1}>
99+
<Text dimColor>last said</Text>
100+
<Box borderStyle="round" borderColor="inactive" paddingX={1}>
101+
<Text dimColor italic>
102+
{lastReaction}
103+
</Text>
104+
</Box>
105+
</Box>
106+
)}
107+
</Box>
108+
);
109+
}

src/buddy/companionReact.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Companion reaction system — aligns with official ZUK + Dc8 pattern.
3+
*
4+
* Called from REPL.tsx after each query turn. Checks mute state, frequency
5+
* limits, and @-mention detection, then calls the buddy_react API to
6+
* generate a reaction shown in the CompanionSprite speech bubble.
7+
*/
8+
import { getCompanion } from './companion.js'
9+
import { getGlobalConfig } from '../utils/config.js'
10+
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
11+
import { getOauthConfig } from '../constants/oauth.js'
12+
import { getUserAgent } from '../utils/http.js'
13+
import type { Message } from '../types/message.js'
14+
15+
// ─── Rate limiting ──────────────────────────────────
16+
17+
let lastReactTime = 0
18+
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
19+
20+
// ─── Recent reactions (avoid repetition) ────────────
21+
22+
const recentReactions: string[] = []
23+
const MAX_RECENT = 8
24+
25+
// ─── Public API ─────────────────────────────────────
26+
27+
/**
28+
* Trigger a companion reaction after a query turn.
29+
*
30+
* Mirrors official `ZUK()`:
31+
* 1. Check companion exists and is not muted
32+
* 2. Detect if user @-mentioned companion by name
33+
* 3. Apply rate limiting (skip if not addressed and too soon)
34+
* 4. Build conversation transcript
35+
* 5. Call buddy_react API
36+
* 6. Pass reaction text to setReaction callback
37+
*/
38+
export function triggerCompanionReaction(
39+
messages: Message[],
40+
setReaction: (text: string | undefined) => void,
41+
): void {
42+
const companion = getCompanion()
43+
if (!companion || getGlobalConfig().companionMuted) return
44+
45+
const addressed = isAddressed(messages, companion.name)
46+
47+
const now = Date.now()
48+
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
49+
50+
const transcript = buildTranscript(messages)
51+
if (!transcript.trim()) return
52+
53+
lastReactTime = now
54+
55+
void callBuddyReactAPI(companion, transcript, addressed)
56+
.then(reaction => {
57+
if (!reaction) return
58+
recentReactions.push(reaction)
59+
if (recentReactions.length > MAX_RECENT) recentReactions.shift()
60+
setReaction(reaction)
61+
})
62+
.catch(() => {})
63+
}
64+
65+
// ─── Helpers ────────────────────────────────────────
66+
67+
function isAddressed(messages: Message[], name: string): boolean {
68+
const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')
69+
for (
70+
let i = messages.length - 1;
71+
i >= Math.max(0, messages.length - 3);
72+
i--
73+
) {
74+
const m = messages[i]
75+
if (m?.type !== 'user') continue
76+
const content = (m as any).message?.content
77+
if (typeof content === 'string' && pattern.test(content)) return true
78+
}
79+
return false
80+
}
81+
82+
function escapeRegex(s: string): string {
83+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84+
}
85+
86+
function buildTranscript(messages: Message[]): string {
87+
return messages
88+
.slice(-12)
89+
.filter(m => m.type === 'user' || m.type === 'assistant')
90+
.map(m => {
91+
const role = m.type === 'user' ? 'user' : 'claude'
92+
const content = (m as any).message?.content
93+
const text =
94+
typeof content === 'string'
95+
? content.slice(0, 300)
96+
: Array.isArray(content)
97+
? content
98+
.filter((b: any) => b?.type === 'text')
99+
.map((b: any) => b.text)
100+
.join(' ')
101+
.slice(0, 300)
102+
: ''
103+
return `${role}: ${text}`
104+
})
105+
.join('\n')
106+
.slice(0, 5000)
107+
}
108+
109+
// ─── API call ───────────────────────────────────────
110+
111+
async function callBuddyReactAPI(
112+
companion: {
113+
name: string
114+
personality: string
115+
species: string
116+
rarity: string
117+
stats: Record<string, number>
118+
},
119+
transcript: string,
120+
addressed: boolean,
121+
): Promise<string | null> {
122+
const tokens = getClaudeAIOAuthTokens()
123+
if (!tokens?.accessToken) return null
124+
125+
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
126+
if (!orgId) return null
127+
128+
const baseUrl = getOauthConfig().BASE_API_URL
129+
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
130+
131+
const resp = await fetch(url, {
132+
method: 'POST',
133+
headers: {
134+
Authorization: `Bearer ${tokens.accessToken}`,
135+
'Content-Type': 'application/json',
136+
'User-Agent': getUserAgent(),
137+
},
138+
body: JSON.stringify({
139+
name: companion.name.slice(0, 32),
140+
personality: companion.personality.slice(0, 200),
141+
species: companion.species,
142+
rarity: companion.rarity,
143+
stats: companion.stats,
144+
transcript,
145+
reason: addressed ? 'addressed' : 'turn',
146+
recent: recentReactions.map(r => r.slice(0, 200)),
147+
addressed,
148+
}),
149+
signal: AbortSignal.timeout(10_000),
150+
})
151+
152+
if (!resp.ok) return null
153+
154+
const data = (await resp.json()) as { reaction?: string }
155+
return data.reaction?.trim() || null
156+
}

0 commit comments

Comments
 (0)