File size: 5,057 Bytes
97289d7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | /**
* 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'
// βββ 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
}
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
}
}
|