205 lines
7.5 KiB
TypeScript
205 lines
7.5 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import { z } from 'zod/v4'
|
|
import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'
|
|
import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js'
|
|
import { logEvent } from '../../services/analytics/index.js'
|
|
import type { ValidationResult } from '../../Tool.js'
|
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
|
import { lazySchema } from '../../utils/lazySchema.js'
|
|
import { plural } from '../../utils/stringUtils.js'
|
|
import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
|
|
import {
|
|
BRIEF_TOOL_NAME,
|
|
BRIEF_TOOL_PROMPT,
|
|
DESCRIPTION,
|
|
LEGACY_BRIEF_TOOL_NAME,
|
|
} from './prompt.js'
|
|
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
|
|
|
const inputSchema = lazySchema(() =>
|
|
z.strictObject({
|
|
message: z
|
|
.string()
|
|
.describe('The message for the user. Supports markdown formatting.'),
|
|
attachments: z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe(
|
|
'Optional file paths (absolute or relative to cwd) to attach. Use for photos, screenshots, diffs, logs, or any file the user should see alongside your message.',
|
|
),
|
|
status: z
|
|
.enum(['normal', 'proactive'])
|
|
.describe(
|
|
"Use 'proactive' when you're surfacing something the user hasn't asked for and needs to see now — task completion while they're away, a blocker you hit, an unsolicited status update. Use 'normal' when replying to something the user just said.",
|
|
),
|
|
}),
|
|
)
|
|
type InputSchema = ReturnType<typeof inputSchema>
|
|
|
|
// attachments MUST remain optional — resumed sessions replay pre-attachment
|
|
// outputs verbatim and a required field would crash the UI renderer on resume.
|
|
const outputSchema = lazySchema(() =>
|
|
z.object({
|
|
message: z.string().describe('The message'),
|
|
attachments: z
|
|
.array(
|
|
z.object({
|
|
path: z.string(),
|
|
size: z.number(),
|
|
isImage: z.boolean(),
|
|
file_uuid: z.string().optional(),
|
|
}),
|
|
)
|
|
.optional()
|
|
.describe('Resolved attachment metadata'),
|
|
sentAt: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'ISO timestamp captured at tool execution on the emitting process. Optional — resumed sessions replay pre-sentAt outputs verbatim.',
|
|
),
|
|
}),
|
|
)
|
|
type OutputSchema = ReturnType<typeof outputSchema>
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
const KAIROS_BRIEF_REFRESH_MS = 5 * 60 * 1000
|
|
|
|
/**
|
|
* Entitlement check — is the user ALLOWED to use Brief? Combines build-time
|
|
* flags with runtime GB gate + assistant-mode passthrough. No opt-in check
|
|
* here — this decides whether opt-in should be HONORED, not whether the user
|
|
* has opted in.
|
|
*
|
|
* Build-time OR-gated on KAIROS || KAIROS_BRIEF (same pattern as
|
|
* PROACTIVE || KAIROS): assistant mode depends on Brief, so KAIROS alone
|
|
* must bundle it. KAIROS_BRIEF lets Brief ship independently.
|
|
*
|
|
* Use this to decide whether `--brief` / `defaultView: 'chat'` / `--tools`
|
|
* listing should be honored. Use `isBriefEnabled()` to decide whether the
|
|
* tool is actually active in the current session.
|
|
*
|
|
* CLAUDE_CODE_BRIEF env var force-grants entitlement for dev/testing —
|
|
* bypasses the GB gate so you can test without being enrolled. Still
|
|
* requires an opt-in action to activate (--brief, defaultView, etc.), but
|
|
* the env var alone also sets userMsgOptIn via maybeActivateBrief().
|
|
*/
|
|
export function isBriefEntitled(): boolean {
|
|
// Positive ternary — see docs/feature-gating.md. Negative early-return
|
|
// would not eliminate the GB gate string from external builds.
|
|
return feature('KAIROS') || feature('KAIROS_BRIEF')
|
|
? getKairosActive() ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_BRIEF) ||
|
|
getFeatureValue_CACHED_WITH_REFRESH(
|
|
'tengu_kairos_brief',
|
|
false,
|
|
KAIROS_BRIEF_REFRESH_MS,
|
|
)
|
|
: false
|
|
}
|
|
|
|
/**
|
|
* Unified activation gate for the Brief tool. Governs model-facing behavior
|
|
* as a unit: tool availability, system prompt section (getBriefSection),
|
|
* tool-deferral bypass (isDeferredTool), and todo-nag suppression.
|
|
*
|
|
* Activation requires explicit opt-in (userMsgOptIn) set by one of:
|
|
* - `--brief` CLI flag (maybeActivateBrief in main.tsx)
|
|
* - `defaultView: 'chat'` in settings (main.tsx init)
|
|
* - `/brief` slash command (brief.ts)
|
|
* - `/config` defaultView picker (Config.tsx)
|
|
* - SendUserMessage in `--tools` / SDK `tools` option (main.tsx)
|
|
* - CLAUDE_CODE_BRIEF env var (maybeActivateBrief — dev/testing bypass)
|
|
* Assistant mode (kairosActive) bypasses opt-in since its system prompt
|
|
* hard-codes "you MUST use SendUserMessage" (systemPrompt.md:14).
|
|
*
|
|
* The GB gate is re-checked here as a kill-switch AND — flipping
|
|
* tengu_kairos_brief off mid-session disables the tool on the next 5-min
|
|
* refresh even for opted-in sessions. No opt-in → always false regardless
|
|
* of GB (this is the fix for "brief defaults on for enrolled ants").
|
|
*
|
|
* Called from Tool.isEnabled() (lazy, post-init), never at module scope.
|
|
* getKairosActive() and getUserMsgOptIn() are set in main.tsx before any
|
|
* caller reaches here.
|
|
*/
|
|
export function isBriefEnabled(): boolean {
|
|
// Top-level feature() guard is load-bearing for DCE: Bun can constant-fold
|
|
// the ternary to `false` in external builds and then dead-code the BriefTool
|
|
// object. Composing isBriefEntitled() alone (which has its own guard) is
|
|
// semantically equivalent but defeats constant-folding across the boundary.
|
|
return feature('KAIROS') || feature('KAIROS_BRIEF')
|
|
? (getKairosActive() || getUserMsgOptIn()) && isBriefEntitled()
|
|
: false
|
|
}
|
|
|
|
export const BriefTool = buildTool({
|
|
name: BRIEF_TOOL_NAME,
|
|
aliases: [LEGACY_BRIEF_TOOL_NAME],
|
|
searchHint:
|
|
'send a message to the user — your primary visible output channel',
|
|
maxResultSizeChars: 100_000,
|
|
userFacingName() {
|
|
return ''
|
|
},
|
|
get inputSchema(): InputSchema {
|
|
return inputSchema()
|
|
},
|
|
get outputSchema(): OutputSchema {
|
|
return outputSchema()
|
|
},
|
|
isEnabled() {
|
|
return isBriefEnabled()
|
|
},
|
|
isConcurrencySafe() {
|
|
return true
|
|
},
|
|
isReadOnly() {
|
|
return true
|
|
},
|
|
toAutoClassifierInput(input) {
|
|
return input.message
|
|
},
|
|
async validateInput({ attachments }, _context): Promise<ValidationResult> {
|
|
if (!attachments || attachments.length === 0) {
|
|
return { result: true }
|
|
}
|
|
return validateAttachmentPaths(attachments)
|
|
},
|
|
async description() {
|
|
return DESCRIPTION
|
|
},
|
|
async prompt() {
|
|
return BRIEF_TOOL_PROMPT
|
|
},
|
|
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
|
const n = output.attachments?.length ?? 0
|
|
const suffix = n === 0 ? '' : ` (${n} ${plural(n, 'attachment')} included)`
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: `Message delivered to user.${suffix}`,
|
|
}
|
|
},
|
|
renderToolUseMessage,
|
|
renderToolResultMessage,
|
|
async call({ message, attachments, status }, context) {
|
|
const sentAt = new Date().toISOString()
|
|
logEvent('tengu_brief_send', {
|
|
proactive: status === 'proactive',
|
|
attachment_count: attachments?.length ?? 0,
|
|
})
|
|
if (!attachments || attachments.length === 0) {
|
|
return { data: { message, sentAt } }
|
|
}
|
|
const appState = context.getAppState()
|
|
const resolved = await resolveAttachments(attachments, {
|
|
replBridgeEnabled: appState.replBridgeEnabled,
|
|
signal: context.abortController.signal,
|
|
})
|
|
return {
|
|
data: { message, attachments: resolved, sentAt },
|
|
}
|
|
},
|
|
} satisfies ToolDef<InputSchema, Output>)
|