Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/buddy/CompanionCard.tsx
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';
Comment on lines +6 to +9
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use src/ path aliases for new TSX imports.

Lines 6-9 introduce new relative imports; these should use src/... aliases.

Suggested fix
-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';
+import { Box, Text } from 'src/ink.js';
+import { useInput } from 'src/ink.js';
+import { renderSprite } from 'src/buddy/sprites.js';
+import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from 'src/buddy/types.js';

As per coding guidelines Import src/ path alias via tsconfig mapping instead of relative paths in imports.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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';
import { Box, Text } from 'src/ink.js';
import { useInput } from 'src/ink.js';
import { renderSprite } from 'src/buddy/sprites.js';
import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from 'src/buddy/types.js';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/buddy/CompanionCard.tsx` around lines 6 - 9, Replace the relative imports
in CompanionCard.tsx with tsconfig path-alias imports: import Box, Text and
useInput from the aliased module for ink (instead of '../ink.js'), import
renderSprite from the aliased sprites module (instead of './sprites.js'), and
import RARITY_COLORS, RARITY_STARS, STAT_NAMES and the Companion type from the
aliased types module (instead of './types.js'); update the import targets to use
the project's "src/..." path mappings so the symbols Box, Text, useInput,
renderSprite, RARITY_COLORS, RARITY_STARS, STAT_NAMES and Companion are resolved
via the tsconfig alias.


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>
&quot;{companion.personality}&quot;
</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>
);
}
160 changes: 160 additions & 0 deletions src/buddy/companionReact.ts
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use src/ path alias imports instead of relative imports

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, Import src/ path alias via tsconfig mapping instead of relative paths in imports.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/buddy/companionReact.ts` around lines 8 - 13, The imports in
companionReact.ts use relative paths; replace them with the tsconfig path-alias
imports that start with "src/" to follow repo conventions—for example update
imports referencing getCompanion, getGlobalConfig, getClaudeAIOAuthTokens,
getOauthConfig, getUserAgent and the Message type to use "src/..." alias paths
(e.g., import { getCompanion } from 'src/buddy/companion') so the module
resolver and linting conform to the project's tsconfig mapping.


// ─── 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

@-mention detection is not actually enforced

This regex matches bare names (Bob) rather than explicit mentions (@Bob), so addressed can be true when the user did not mention with @.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
const pattern = new RegExp(`(?:^|\\s)@${escapeRegex(name)}(?=\\b|\\s|$)`, '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
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/buddy/companionReact.ts` around lines 68 - 77, The current detection
regex (pattern built with escapeRegex(name)) matches bare names like "Bob"
instead of explicit `@-mentions`; update the pattern construction in
companionReact.ts to require an '@' before the name (for example use a regex
like `(^|\\s)@${escapeRegex(name)}\\b` with case-insensitive flag) so the loop
over messages (messages, pattern, escapeRegex, name) only returns true when the
user was explicitly mentioned with '@'.

}
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
}
}
Loading