Skip to content

Commit 9dd180d

Browse files
Merge branch 'pr/amDosion/82'
2 parents c9c14c8 + 7d4adce commit 9dd180d

7 files changed

Lines changed: 383 additions & 181 deletions

File tree

src/buddy/CompanionCard.tsx

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

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)