/ tools / BriefTool / BriefTool.ts
BriefTool.ts
  1  import { feature } from 'bun:bundle'
  2  import { z } from 'zod/v4'
  3  import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'
  4  import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js'
  5  import { logEvent } from '../../services/analytics/index.js'
  6  import type { ValidationResult } from '../../Tool.js'
  7  import { buildTool, type ToolDef } from '../../Tool.js'
  8  import { isEnvTruthy } from '../../utils/envUtils.js'
  9  import { lazySchema } from '../../utils/lazySchema.js'
 10  import { plural } from '../../utils/stringUtils.js'
 11  import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
 12  import {
 13    BRIEF_TOOL_NAME,
 14    BRIEF_TOOL_PROMPT,
 15    DESCRIPTION,
 16    LEGACY_BRIEF_TOOL_NAME,
 17  } from './prompt.js'
 18  import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
 19  
 20  const inputSchema = lazySchema(() =>
 21    z.strictObject({
 22      message: z
 23        .string()
 24        .describe('The message for the user. Supports markdown formatting.'),
 25      attachments: z
 26        .array(z.string())
 27        .optional()
 28        .describe(
 29          '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.',
 30        ),
 31      status: z
 32        .enum(['normal', 'proactive'])
 33        .describe(
 34          "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.",
 35        ),
 36    }),
 37  )
 38  type InputSchema = ReturnType<typeof inputSchema>
 39  
 40  // attachments MUST remain optional — resumed sessions replay pre-attachment
 41  // outputs verbatim and a required field would crash the UI renderer on resume.
 42  const outputSchema = lazySchema(() =>
 43    z.object({
 44      message: z.string().describe('The message'),
 45      attachments: z
 46        .array(
 47          z.object({
 48            path: z.string(),
 49            size: z.number(),
 50            isImage: z.boolean(),
 51            file_uuid: z.string().optional(),
 52          }),
 53        )
 54        .optional()
 55        .describe('Resolved attachment metadata'),
 56      sentAt: z
 57        .string()
 58        .optional()
 59        .describe(
 60          'ISO timestamp captured at tool execution on the emitting process. Optional — resumed sessions replay pre-sentAt outputs verbatim.',
 61        ),
 62    }),
 63  )
 64  type OutputSchema = ReturnType<typeof outputSchema>
 65  export type Output = z.infer<OutputSchema>
 66  
 67  const KAIROS_BRIEF_REFRESH_MS = 5 * 60 * 1000
 68  
 69  /**
 70   * Entitlement check — is the user ALLOWED to use Brief? Combines build-time
 71   * flags with runtime GB gate + assistant-mode passthrough. No opt-in check
 72   * here — this decides whether opt-in should be HONORED, not whether the user
 73   * has opted in.
 74   *
 75   * Build-time OR-gated on KAIROS || KAIROS_BRIEF (same pattern as
 76   * PROACTIVE || KAIROS): assistant mode depends on Brief, so KAIROS alone
 77   * must bundle it. KAIROS_BRIEF lets Brief ship independently.
 78   *
 79   * Use this to decide whether `--brief` / `defaultView: 'chat'` / `--tools`
 80   * listing should be honored. Use `isBriefEnabled()` to decide whether the
 81   * tool is actually active in the current session.
 82   *
 83   * CLAUDE_CODE_BRIEF env var force-grants entitlement for dev/testing —
 84   * bypasses the GB gate so you can test without being enrolled. Still
 85   * requires an opt-in action to activate (--brief, defaultView, etc.), but
 86   * the env var alone also sets userMsgOptIn via maybeActivateBrief().
 87   */
 88  export function isBriefEntitled(): boolean {
 89    // Positive ternary — see docs/feature-gating.md. Negative early-return
 90    // would not eliminate the GB gate string from external builds.
 91    return feature('KAIROS') || feature('KAIROS_BRIEF')
 92      ? getKairosActive() ||
 93          isEnvTruthy(process.env.CLAUDE_CODE_BRIEF) ||
 94          getFeatureValue_CACHED_WITH_REFRESH(
 95            'tengu_kairos_brief',
 96            false,
 97            KAIROS_BRIEF_REFRESH_MS,
 98          )
 99      : false
100  }
101  
102  /**
103   * Unified activation gate for the Brief tool. Governs model-facing behavior
104   * as a unit: tool availability, system prompt section (getBriefSection),
105   * tool-deferral bypass (isDeferredTool), and todo-nag suppression.
106   *
107   * Activation requires explicit opt-in (userMsgOptIn) set by one of:
108   *   - `--brief` CLI flag (maybeActivateBrief in main.tsx)
109   *   - `defaultView: 'chat'` in settings (main.tsx init)
110   *   - `/brief` slash command (brief.ts)
111   *   - `/config` defaultView picker (Config.tsx)
112   *   - SendUserMessage in `--tools` / SDK `tools` option (main.tsx)
113   *   - CLAUDE_CODE_BRIEF env var (maybeActivateBrief — dev/testing bypass)
114   * Assistant mode (kairosActive) bypasses opt-in since its system prompt
115   * hard-codes "you MUST use SendUserMessage" (systemPrompt.md:14).
116   *
117   * The GB gate is re-checked here as a kill-switch AND — flipping
118   * tengu_kairos_brief off mid-session disables the tool on the next 5-min
119   * refresh even for opted-in sessions. No opt-in → always false regardless
120   * of GB (this is the fix for "brief defaults on for enrolled ants").
121   *
122   * Called from Tool.isEnabled() (lazy, post-init), never at module scope.
123   * getKairosActive() and getUserMsgOptIn() are set in main.tsx before any
124   * caller reaches here.
125   */
126  export function isBriefEnabled(): boolean {
127    // Top-level feature() guard is load-bearing for DCE: Bun can constant-fold
128    // the ternary to `false` in external builds and then dead-code the BriefTool
129    // object. Composing isBriefEntitled() alone (which has its own guard) is
130    // semantically equivalent but defeats constant-folding across the boundary.
131    return feature('KAIROS') || feature('KAIROS_BRIEF')
132      ? (getKairosActive() || getUserMsgOptIn()) && isBriefEntitled()
133      : false
134  }
135  
136  export const BriefTool = buildTool({
137    name: BRIEF_TOOL_NAME,
138    aliases: [LEGACY_BRIEF_TOOL_NAME],
139    searchHint:
140      'send a message to the user — your primary visible output channel',
141    maxResultSizeChars: 100_000,
142    userFacingName() {
143      return ''
144    },
145    get inputSchema(): InputSchema {
146      return inputSchema()
147    },
148    get outputSchema(): OutputSchema {
149      return outputSchema()
150    },
151    isEnabled() {
152      return isBriefEnabled()
153    },
154    isConcurrencySafe() {
155      return true
156    },
157    isReadOnly() {
158      return true
159    },
160    toAutoClassifierInput(input) {
161      return input.message
162    },
163    async validateInput({ attachments }, _context): Promise<ValidationResult> {
164      if (!attachments || attachments.length === 0) {
165        return { result: true }
166      }
167      return validateAttachmentPaths(attachments)
168    },
169    async description() {
170      return DESCRIPTION
171    },
172    async prompt() {
173      return BRIEF_TOOL_PROMPT
174    },
175    mapToolResultToToolResultBlockParam(output, toolUseID) {
176      const n = output.attachments?.length ?? 0
177      const suffix = n === 0 ? '' : ` (${n} ${plural(n, 'attachment')} included)`
178      return {
179        tool_use_id: toolUseID,
180        type: 'tool_result',
181        content: `Message delivered to user.${suffix}`,
182      }
183    },
184    renderToolUseMessage,
185    renderToolResultMessage,
186    async call({ message, attachments, status }, context) {
187      const sentAt = new Date().toISOString()
188      logEvent('tengu_brief_send', {
189        proactive: status === 'proactive',
190        attachment_count: attachments?.length ?? 0,
191      })
192      if (!attachments || attachments.length === 0) {
193        return { data: { message, sentAt } }
194      }
195      const appState = context.getAppState()
196      const resolved = await resolveAttachments(attachments, {
197        replBridgeEnabled: appState.replBridgeEnabled,
198        signal: context.abortController.signal,
199      })
200      return {
201        data: { message, attachments: resolved, sentAt },
202      }
203    },
204  } satisfies ToolDef<InputSchema, Output>)