3998 lines
124 KiB
TypeScript
3998 lines
124 KiB
TypeScript
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||
import {
|
||
logEvent,
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
} from 'src/services/analytics/index.js'
|
||
import {
|
||
toolMatchesName,
|
||
type Tools,
|
||
type ToolUseContext,
|
||
type ToolPermissionContext,
|
||
} from '../Tool.js'
|
||
import {
|
||
FileReadTool,
|
||
MaxFileReadTokenExceededError,
|
||
type Output as FileReadToolOutput,
|
||
readImageWithTokenBudget,
|
||
} from '../tools/FileReadTool/FileReadTool.js'
|
||
import { FileTooLargeError, readFileInRange } from './readFileInRange.js'
|
||
import { expandPath } from './path.js'
|
||
import { countCharInString } from './stringUtils.js'
|
||
import { count, uniq } from './array.js'
|
||
import { getFsImplementation } from './fsOperations.js'
|
||
import { readdir, stat } from 'fs/promises'
|
||
import type { IDESelection } from '../hooks/useIdeSelection.js'
|
||
import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
|
||
import { TASK_CREATE_TOOL_NAME } from '../tools/TaskCreateTool/constants.js'
|
||
import { TASK_UPDATE_TOOL_NAME } from '../tools/TaskUpdateTool/constants.js'
|
||
import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
|
||
import { SKILL_TOOL_NAME } from '../tools/SkillTool/constants.js'
|
||
import type { TodoList } from './todo/types.js'
|
||
import {
|
||
type Task,
|
||
listTasks,
|
||
getTaskListId,
|
||
isTodoV2Enabled,
|
||
} from './tasks.js'
|
||
import { getPlanFilePath, getPlan } from './plans.js'
|
||
import { getConnectedIdeName } from './ide.js'
|
||
import {
|
||
filterInjectedMemoryFiles,
|
||
getManagedAndUserConditionalRules,
|
||
getMemoryFiles,
|
||
getMemoryFilesForNestedDirectory,
|
||
getConditionalRulesForCwdLevelDirectory,
|
||
type MemoryFileInfo,
|
||
} from './claudemd.js'
|
||
import { dirname, parse, relative, resolve } from 'path'
|
||
import { getCwd } from 'src/utils/cwd.js'
|
||
import { getViewedTeammateTask } from '../state/selectors.js'
|
||
import { logError } from './log.js'
|
||
import { logAntError } from './debug.js'
|
||
import { isENOENT, toError } from './errors.js'
|
||
import type { DiagnosticFile } from '../services/diagnosticTracking.js'
|
||
import { diagnosticTracker } from '../services/diagnosticTracking.js'
|
||
import type {
|
||
AttachmentMessage,
|
||
Message,
|
||
MessageOrigin,
|
||
} from 'src/types/message.js'
|
||
import {
|
||
type QueuedCommand,
|
||
getImagePasteIds,
|
||
isValidImagePaste,
|
||
} from 'src/types/textInputTypes.js'
|
||
import { randomUUID, type UUID } from 'crypto'
|
||
import { getSettings_DEPRECATED } from './settings/settings.js'
|
||
import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js'
|
||
import type {
|
||
ContentBlockParam,
|
||
ImageBlockParam,
|
||
Base64ImageSource,
|
||
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
||
import { maybeResizeAndDownsampleImageBlock } from './imageResizer.js'
|
||
import type { PastedContent } from './config.js'
|
||
import { getGlobalConfig } from './config.js'
|
||
import {
|
||
getDefaultSonnetModel,
|
||
getDefaultHaikuModel,
|
||
getDefaultOpusModel,
|
||
} from './model/model.js'
|
||
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
|
||
import { getSkillToolCommands, getMcpSkillCommands } from '../commands.js'
|
||
import type { Command } from '../types/command.js'
|
||
import uniqBy from 'lodash-es/uniqBy.js'
|
||
import { getProjectRoot } from '../bootstrap/state.js'
|
||
import { formatCommandsWithinBudget } from '../tools/SkillTool/prompt.js'
|
||
import { getContextWindowForModel } from './context.js'
|
||
import type { DiscoverySignal } from '../services/skillSearch/signals.js'
|
||
// Conditional require for DCE. All skill-search string literals that would
|
||
// otherwise leak into external builds live inside these modules. The only
|
||
// surfaces in THIS file are: the maybe() call (gated via spread below) and
|
||
// the skill_listing suppression check (uses the same skillSearchModules null
|
||
// check). The type-only DiscoverySignal import above is erased at compile time.
|
||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const skillSearchModules = feature('EXPERIMENTAL_SKILL_SEARCH')
|
||
? {
|
||
featureCheck:
|
||
require('../services/skillSearch/featureCheck.js') as typeof import('../services/skillSearch/featureCheck.js'),
|
||
prefetch:
|
||
require('../services/skillSearch/prefetch.js') as typeof import('../services/skillSearch/prefetch.js'),
|
||
}
|
||
: null
|
||
const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
|
||
? (require('./permissions/autoModeState.js') as typeof import('./permissions/autoModeState.js'))
|
||
: null
|
||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||
import {
|
||
MAX_LINES_TO_READ,
|
||
FILE_READ_TOOL_NAME,
|
||
} from 'src/tools/FileReadTool/prompt.js'
|
||
import { getDefaultFileReadingLimits } from 'src/tools/FileReadTool/limits.js'
|
||
import { cacheKeys, type FileStateCache } from './fileStateCache.js'
|
||
import {
|
||
createAbortController,
|
||
createChildAbortController,
|
||
} from './abortController.js'
|
||
import { isAbortError } from './errors.js'
|
||
import {
|
||
getFileModificationTimeAsync,
|
||
isFileWithinReadSizeLimit,
|
||
} from './file.js'
|
||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||
import { filterAgentsByMcpRequirements } from '../tools/AgentTool/loadAgentsDir.js'
|
||
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'
|
||
import {
|
||
formatAgentLine,
|
||
shouldInjectAgentListInMessages,
|
||
} from '../tools/AgentTool/prompt.js'
|
||
import { filterDeniedAgents } from './permissions/permissions.js'
|
||
import { getSubscriptionType } from './auth.js'
|
||
import { mcpInfoFromString } from '../services/mcp/mcpStringUtils.js'
|
||
import {
|
||
matchingRuleForInput,
|
||
pathInAllowedWorkingPath,
|
||
} from './permissions/filesystem.js'
|
||
import {
|
||
generateTaskAttachments,
|
||
applyTaskOffsetsAndEvictions,
|
||
} from './task/framework.js'
|
||
import { getTaskOutputPath } from './task/diskOutput.js'
|
||
import { drainPendingMessages } from '../tasks/LocalAgentTask/LocalAgentTask.js'
|
||
import type { TaskType, TaskStatus } from '../Task.js'
|
||
import {
|
||
getOriginalCwd,
|
||
getSessionId,
|
||
getSdkBetas,
|
||
getTotalCostUSD,
|
||
getTotalOutputTokens,
|
||
getCurrentTurnTokenBudget,
|
||
getTurnOutputTokens,
|
||
hasExitedPlanModeInSession,
|
||
setHasExitedPlanMode,
|
||
needsPlanModeExitAttachment,
|
||
setNeedsPlanModeExitAttachment,
|
||
needsAutoModeExitAttachment,
|
||
setNeedsAutoModeExitAttachment,
|
||
getLastEmittedDate,
|
||
setLastEmittedDate,
|
||
getKairosActive,
|
||
} from '../bootstrap/state.js'
|
||
import type { QuerySource } from '../constants/querySource.js'
|
||
import {
|
||
getDeferredToolsDelta,
|
||
isDeferredToolsDeltaEnabled,
|
||
isToolSearchEnabledOptimistic,
|
||
isToolSearchToolAvailable,
|
||
modelSupportsToolReference,
|
||
type DeferredToolsDeltaScanContext,
|
||
} from './toolSearch.js'
|
||
import {
|
||
getMcpInstructionsDelta,
|
||
isMcpInstructionsDeltaEnabled,
|
||
type ClientSideInstruction,
|
||
} from './mcpInstructionsDelta.js'
|
||
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from './claudeInChrome/common.js'
|
||
import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from './claudeInChrome/prompt.js'
|
||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||
import type {
|
||
HookEvent,
|
||
SyncHookJSONOutput,
|
||
} from 'src/entrypoints/agentSdkTypes.js'
|
||
import {
|
||
checkForAsyncHookResponses,
|
||
removeDeliveredAsyncHooks,
|
||
} from './hooks/AsyncHookRegistry.js'
|
||
import {
|
||
checkForLSPDiagnostics,
|
||
clearAllLSPDiagnostics,
|
||
} from '../services/lsp/LSPDiagnosticRegistry.js'
|
||
import { logForDebugging } from './debug.js'
|
||
import {
|
||
extractTextContent,
|
||
getUserMessageText,
|
||
isThinkingMessage,
|
||
} from './messages.js'
|
||
import { isHumanTurn } from './messagePredicates.js'
|
||
import { isEnvTruthy, getClaudeConfigHomeDir } from './envUtils.js'
|
||
import { feature } from 'bun:bundle'
|
||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const BRIEF_TOOL_NAME: string | null =
|
||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||
? (
|
||
require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js')
|
||
).BRIEF_TOOL_NAME
|
||
: null
|
||
const sessionTranscriptModule = feature('KAIROS')
|
||
? (require('../services/sessionTranscript/sessionTranscript.js') as typeof import('../services/sessionTranscript/sessionTranscript.js'))
|
||
: null
|
||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||
import { hasUltrathinkKeyword, isUltrathinkEnabled } from './thinking.js'
|
||
import {
|
||
tokenCountFromLastAPIResponse,
|
||
tokenCountWithEstimation,
|
||
} from './tokens.js'
|
||
import {
|
||
getEffectiveContextWindowSize,
|
||
isAutoCompactEnabled,
|
||
} from '../services/compact/autoCompact.js'
|
||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||
import {
|
||
hasInstructionsLoadedHook,
|
||
executeInstructionsLoadedHooks,
|
||
type HookBlockingError,
|
||
type InstructionsMemoryType,
|
||
} from './hooks.js'
|
||
import { jsonStringify } from './slowOperations.js'
|
||
import { isPDFExtension } from './pdfUtils.js'
|
||
import { getLocalISODate } from '../constants/common.js'
|
||
import { getPDFPageCount } from './pdf.js'
|
||
import { PDF_AT_MENTION_INLINE_THRESHOLD } from '../constants/apiLimits.js'
|
||
import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
|
||
import { findRelevantMemories } from '../memdir/findRelevantMemories.js'
|
||
import { memoryAge, memoryFreshnessText } from '../memdir/memoryAge.js'
|
||
import { getAutoMemPath, isAutoMemoryEnabled } from '../memdir/paths.js'
|
||
import { getAgentMemoryDir } from '../tools/AgentTool/agentMemory.js'
|
||
import {
|
||
readUnreadMessages,
|
||
markMessagesAsReadByPredicate,
|
||
isShutdownApproved,
|
||
isStructuredProtocolMessage,
|
||
isIdleNotification,
|
||
} from './teammateMailbox.js'
|
||
import {
|
||
getAgentName,
|
||
getAgentId,
|
||
getTeamName,
|
||
isTeamLead,
|
||
} from './teammate.js'
|
||
import { isInProcessTeammate } from './teammateContext.js'
|
||
import { removeTeammateFromTeamFile } from './swarm/teamHelpers.js'
|
||
import { unassignTeammateTasks } from './tasks.js'
|
||
import { getCompanionIntroAttachment } from '../buddy/prompt.js'
|
||
|
||
export const TODO_REMINDER_CONFIG = {
|
||
TURNS_SINCE_WRITE: 10,
|
||
TURNS_BETWEEN_REMINDERS: 10,
|
||
} as const
|
||
|
||
export const PLAN_MODE_ATTACHMENT_CONFIG = {
|
||
TURNS_BETWEEN_ATTACHMENTS: 5,
|
||
FULL_REMINDER_EVERY_N_ATTACHMENTS: 5,
|
||
} as const
|
||
|
||
export const AUTO_MODE_ATTACHMENT_CONFIG = {
|
||
TURNS_BETWEEN_ATTACHMENTS: 5,
|
||
FULL_REMINDER_EVERY_N_ATTACHMENTS: 5,
|
||
} as const
|
||
|
||
const MAX_MEMORY_LINES = 200
|
||
// Line cap alone doesn't bound size (200 × 500-char lines = 100KB). The
|
||
// surfacer injects up to 5 files per turn via <system-reminder>, bypassing
|
||
// the per-message tool-result budget, so a tight per-file byte cap keeps
|
||
// aggregate injection bounded (5 × 4KB = 20KB/turn). Enforced via
|
||
// readFileInRange's truncateOnByteLimit option. Truncation means the
|
||
// most-relevant memory still surfaces: the frontmatter + opening context
|
||
// is usually what matters.
|
||
const MAX_MEMORY_BYTES = 4096
|
||
|
||
export const RELEVANT_MEMORIES_CONFIG = {
|
||
// Per-turn cap (5 × 4KB = 20KB) bounds a single injection, but over a
|
||
// long session the selector keeps surfacing distinct files — ~26K tokens/
|
||
// session observed in prod. Cap the cumulative bytes: once hit, stop
|
||
// prefetching entirely. Budget is ~3 full injections; after that the
|
||
// most-relevant memories are already in context. Scanning messages
|
||
// (rather than tracking in toolUseContext) means compact naturally
|
||
// resets the counter — old attachments are gone from context, so
|
||
// re-surfacing is valid.
|
||
MAX_SESSION_BYTES: 60 * 1024,
|
||
} as const
|
||
|
||
export const VERIFY_PLAN_REMINDER_CONFIG = {
|
||
TURNS_BETWEEN_REMINDERS: 10,
|
||
} as const
|
||
|
||
export type FileAttachment = {
|
||
type: 'file'
|
||
filename: string
|
||
content: FileReadToolOutput
|
||
/**
|
||
* Whether the file was truncated due to size limits
|
||
*/
|
||
truncated?: boolean
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
|
||
export type CompactFileReferenceAttachment = {
|
||
type: 'compact_file_reference'
|
||
filename: string
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
|
||
export type PDFReferenceAttachment = {
|
||
type: 'pdf_reference'
|
||
filename: string
|
||
pageCount: number
|
||
fileSize: number
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
|
||
export type AlreadyReadFileAttachment = {
|
||
type: 'already_read_file'
|
||
filename: string
|
||
content: FileReadToolOutput
|
||
/**
|
||
* Whether the file was truncated due to size limits
|
||
*/
|
||
truncated?: boolean
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
|
||
export type AgentMentionAttachment = {
|
||
type: 'agent_mention'
|
||
agentType: string
|
||
}
|
||
|
||
export type AsyncHookResponseAttachment = {
|
||
type: 'async_hook_response'
|
||
processId: string
|
||
hookName: string
|
||
hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
|
||
toolName?: string
|
||
response: SyncHookJSONOutput
|
||
stdout: string
|
||
stderr: string
|
||
exitCode?: number
|
||
}
|
||
|
||
export type HookAttachment =
|
||
| HookCancelledAttachment
|
||
| {
|
||
type: 'hook_blocking_error'
|
||
blockingError: HookBlockingError
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
}
|
||
| HookNonBlockingErrorAttachment
|
||
| HookErrorDuringExecutionAttachment
|
||
| {
|
||
type: 'hook_stopped_continuation'
|
||
message: string
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
}
|
||
| HookSuccessAttachment
|
||
| {
|
||
type: 'hook_additional_context'
|
||
content: string[]
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
}
|
||
| HookSystemMessageAttachment
|
||
| HookPermissionDecisionAttachment
|
||
|
||
export type HookPermissionDecisionAttachment = {
|
||
type: 'hook_permission_decision'
|
||
decision: 'allow' | 'deny'
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
}
|
||
|
||
export type HookSystemMessageAttachment = {
|
||
type: 'hook_system_message'
|
||
content: string
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
}
|
||
|
||
export type HookCancelledAttachment = {
|
||
type: 'hook_cancelled'
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
command?: string
|
||
durationMs?: number
|
||
}
|
||
|
||
export type HookErrorDuringExecutionAttachment = {
|
||
type: 'hook_error_during_execution'
|
||
content: string
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
command?: string
|
||
durationMs?: number
|
||
}
|
||
|
||
export type HookSuccessAttachment = {
|
||
type: 'hook_success'
|
||
content: string
|
||
hookName: string
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
stdout?: string
|
||
stderr?: string
|
||
exitCode?: number
|
||
command?: string
|
||
durationMs?: number
|
||
}
|
||
|
||
export type HookNonBlockingErrorAttachment = {
|
||
type: 'hook_non_blocking_error'
|
||
hookName: string
|
||
stderr: string
|
||
stdout: string
|
||
exitCode: number
|
||
toolUseID: string
|
||
hookEvent: HookEvent
|
||
command?: string
|
||
durationMs?: number
|
||
}
|
||
|
||
export type Attachment =
|
||
/**
|
||
* User at-mentioned the file
|
||
*/
|
||
| FileAttachment
|
||
| CompactFileReferenceAttachment
|
||
| PDFReferenceAttachment
|
||
| AlreadyReadFileAttachment
|
||
/**
|
||
* An at-mentioned file was edited
|
||
*/
|
||
| {
|
||
type: 'edited_text_file'
|
||
filename: string
|
||
snippet: string
|
||
}
|
||
| {
|
||
type: 'edited_image_file'
|
||
filename: string
|
||
content: FileReadToolOutput
|
||
}
|
||
| {
|
||
type: 'directory'
|
||
path: string
|
||
content: string
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
| {
|
||
type: 'selected_lines_in_ide'
|
||
ideName: string
|
||
lineStart: number
|
||
lineEnd: number
|
||
filename: string
|
||
content: string
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
| {
|
||
type: 'opened_file_in_ide'
|
||
filename: string
|
||
}
|
||
| {
|
||
type: 'todo_reminder'
|
||
content: TodoList
|
||
itemCount: number
|
||
}
|
||
| {
|
||
type: 'task_reminder'
|
||
content: Task[]
|
||
itemCount: number
|
||
}
|
||
| {
|
||
type: 'nested_memory'
|
||
path: string
|
||
content: MemoryFileInfo
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
| {
|
||
type: 'relevant_memories'
|
||
memories: {
|
||
path: string
|
||
content: string
|
||
mtimeMs: number
|
||
/**
|
||
* Pre-computed header string (age + path prefix). Computed once
|
||
* at attachment-creation time so the rendered bytes are stable
|
||
* across turns — recomputing memoryAge(mtimeMs) at render time
|
||
* calls Date.now(), so "saved 3 days ago" becomes "saved 4 days
|
||
* ago" across turns → different bytes → prompt cache bust.
|
||
* Optional for backward compat with resumed sessions; render
|
||
* path falls back to recomputing if missing.
|
||
*/
|
||
header?: string
|
||
/**
|
||
* lineCount when the file was truncated by readMemoriesForSurfacing,
|
||
* else undefined. Threaded to the readFileState write so
|
||
* getChangedFiles skips truncated memories (partial content would
|
||
* yield a misleading diff).
|
||
*/
|
||
limit?: number
|
||
}[]
|
||
}
|
||
| {
|
||
type: 'dynamic_skill'
|
||
skillDir: string
|
||
skillNames: string[]
|
||
/** Path relative to CWD at creation time, for stable display */
|
||
displayPath: string
|
||
}
|
||
| {
|
||
type: 'skill_listing'
|
||
content: string
|
||
skillCount: number
|
||
isInitial: boolean
|
||
}
|
||
| {
|
||
type: 'skill_discovery'
|
||
skills: { name: string; description: string; shortId?: string }[]
|
||
signal: DiscoverySignal
|
||
source: 'native' | 'aki' | 'both'
|
||
}
|
||
| {
|
||
type: 'queued_command'
|
||
prompt: string | Array<ContentBlockParam>
|
||
source_uuid?: UUID
|
||
imagePasteIds?: number[]
|
||
/** Original queue mode — 'prompt' for user messages, 'task-notification' for system events */
|
||
commandMode?: string
|
||
/** Provenance carried from QueuedCommand so mid-turn drains preserve it */
|
||
origin?: MessageOrigin
|
||
/** Carried from QueuedCommand.isMeta — distinguishes human-typed from system-injected */
|
||
isMeta?: boolean
|
||
}
|
||
| {
|
||
type: 'output_style'
|
||
style: string
|
||
}
|
||
| {
|
||
type: 'diagnostics'
|
||
files: DiagnosticFile[]
|
||
isNew: boolean
|
||
}
|
||
| {
|
||
type: 'plan_mode'
|
||
reminderType: 'full' | 'sparse'
|
||
isSubAgent?: boolean
|
||
planFilePath: string
|
||
planExists: boolean
|
||
}
|
||
| {
|
||
type: 'plan_mode_reentry'
|
||
planFilePath: string
|
||
}
|
||
| {
|
||
type: 'plan_mode_exit'
|
||
planFilePath: string
|
||
planExists: boolean
|
||
}
|
||
| {
|
||
type: 'auto_mode'
|
||
reminderType: 'full' | 'sparse'
|
||
}
|
||
| {
|
||
type: 'auto_mode_exit'
|
||
}
|
||
| {
|
||
type: 'critical_system_reminder'
|
||
content: string
|
||
}
|
||
| {
|
||
type: 'plan_file_reference'
|
||
planFilePath: string
|
||
planContent: string
|
||
}
|
||
| {
|
||
type: 'mcp_resource'
|
||
server: string
|
||
uri: string
|
||
name: string
|
||
description?: string
|
||
content: ReadResourceResult
|
||
}
|
||
| {
|
||
type: 'command_permissions'
|
||
allowedTools: string[]
|
||
model?: string
|
||
}
|
||
| AgentMentionAttachment
|
||
| {
|
||
type: 'task_status'
|
||
taskId: string
|
||
taskType: TaskType
|
||
status: TaskStatus
|
||
description: string
|
||
deltaSummary: string | null
|
||
outputFilePath?: string
|
||
}
|
||
| AsyncHookResponseAttachment
|
||
| {
|
||
type: 'token_usage'
|
||
used: number
|
||
total: number
|
||
remaining: number
|
||
}
|
||
| {
|
||
type: 'budget_usd'
|
||
used: number
|
||
total: number
|
||
remaining: number
|
||
}
|
||
| {
|
||
type: 'output_token_usage'
|
||
turn: number
|
||
session: number
|
||
budget: number | null
|
||
}
|
||
| {
|
||
type: 'structured_output'
|
||
data: unknown
|
||
}
|
||
| TeammateMailboxAttachment
|
||
| TeamContextAttachment
|
||
| HookAttachment
|
||
| {
|
||
type: 'invoked_skills'
|
||
skills: Array<{
|
||
name: string
|
||
path: string
|
||
content: string
|
||
}>
|
||
}
|
||
| {
|
||
type: 'verify_plan_reminder'
|
||
}
|
||
| {
|
||
type: 'max_turns_reached'
|
||
maxTurns: number
|
||
turnCount: number
|
||
}
|
||
| {
|
||
type: 'current_session_memory'
|
||
content: string
|
||
path: string
|
||
tokenCount: number
|
||
}
|
||
| {
|
||
type: 'teammate_shutdown_batch'
|
||
count: number
|
||
}
|
||
| {
|
||
type: 'compaction_reminder'
|
||
}
|
||
| {
|
||
type: 'context_efficiency'
|
||
}
|
||
| {
|
||
type: 'date_change'
|
||
newDate: string
|
||
}
|
||
| {
|
||
type: 'ultrathink_effort'
|
||
level: 'high'
|
||
}
|
||
| {
|
||
type: 'deferred_tools_delta'
|
||
addedNames: string[]
|
||
addedLines: string[]
|
||
removedNames: string[]
|
||
}
|
||
| {
|
||
type: 'agent_listing_delta'
|
||
addedTypes: string[]
|
||
addedLines: string[]
|
||
removedTypes: string[]
|
||
/** True when this is the first announcement in the conversation */
|
||
isInitial: boolean
|
||
/** Whether to include the "launch multiple agents concurrently" note (non-pro subscriptions) */
|
||
showConcurrencyNote: boolean
|
||
}
|
||
| {
|
||
type: 'mcp_instructions_delta'
|
||
addedNames: string[]
|
||
addedBlocks: string[]
|
||
removedNames: string[]
|
||
}
|
||
| {
|
||
type: 'companion_intro'
|
||
name: string
|
||
species: string
|
||
}
|
||
| {
|
||
type: 'bagel_console'
|
||
errorCount: number
|
||
warningCount: number
|
||
sample: string
|
||
}
|
||
|
||
export type TeammateMailboxAttachment = {
|
||
type: 'teammate_mailbox'
|
||
messages: Array<{
|
||
from: string
|
||
text: string
|
||
timestamp: string
|
||
color?: string
|
||
summary?: string
|
||
}>
|
||
}
|
||
|
||
export type TeamContextAttachment = {
|
||
type: 'team_context'
|
||
agentId: string
|
||
agentName: string
|
||
teamName: string
|
||
teamConfigPath: string
|
||
taskListPath: string
|
||
}
|
||
|
||
/**
|
||
* This is janky
|
||
* TODO: Generate attachments when we create messages
|
||
*/
|
||
export async function getAttachments(
|
||
input: string | null,
|
||
toolUseContext: ToolUseContext,
|
||
ideSelection: IDESelection | null,
|
||
queuedCommands: QueuedCommand[],
|
||
messages?: Message[],
|
||
querySource?: QuerySource,
|
||
options?: { skipSkillDiscovery?: boolean },
|
||
): Promise<Attachment[]> {
|
||
if (
|
||
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS) ||
|
||
isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||
) {
|
||
// query.ts:removeFromQueue dequeues these unconditionally after
|
||
// getAttachmentMessages runs — returning [] here silently drops them.
|
||
// Coworker runs with --bare and depends on task-notification for
|
||
// mid-tool-call notifications from Local*Task/Remote*Task.
|
||
return getQueuedCommandAttachments(queuedCommands)
|
||
}
|
||
|
||
// This will slow down submissions
|
||
// TODO: Compute attachments as the user types, not here (though we use this
|
||
// function for slash command prompts too)
|
||
const abortController = createAbortController()
|
||
const timeoutId = setTimeout(ac => ac.abort(), 1000, abortController)
|
||
const context = { ...toolUseContext, abortController }
|
||
|
||
const isMainThread = !toolUseContext.agentId
|
||
|
||
// Attachments which are added in response to on user input
|
||
const userInputAttachments = input
|
||
? [
|
||
maybe('at_mentioned_files', () =>
|
||
processAtMentionedFiles(input, context),
|
||
),
|
||
maybe('mcp_resources', () =>
|
||
processMcpResourceAttachments(input, context),
|
||
),
|
||
maybe('agent_mentions', () =>
|
||
Promise.resolve(
|
||
processAgentMentions(
|
||
input,
|
||
toolUseContext.options.agentDefinitions.activeAgents,
|
||
),
|
||
),
|
||
),
|
||
// Skill discovery on turn 0 (user input as signal). Inter-turn
|
||
// discovery runs via startSkillDiscoveryPrefetch in query.ts,
|
||
// gated on write-pivot detection — see skillSearch/prefetch.ts.
|
||
// feature() here lets DCE drop the 'skill_discovery' string (and the
|
||
// function it calls) from external builds.
|
||
//
|
||
// skipSkillDiscovery gates out the SKILL.md-expansion path
|
||
// (getMessagesForPromptSlashCommand). When a skill is invoked, its
|
||
// SKILL.md content is passed as `input` here to extract @-mentions —
|
||
// but that content is NOT user intent and must not trigger discovery.
|
||
// Without this gate, a 110KB SKILL.md fires ~3.3s of chunked AKI
|
||
// queries on every skill invocation (session 13a9afae).
|
||
...(feature('EXPERIMENTAL_SKILL_SEARCH') &&
|
||
skillSearchModules &&
|
||
!options?.skipSkillDiscovery
|
||
? [
|
||
maybe('skill_discovery', () =>
|
||
skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
|
||
input,
|
||
messages ?? [],
|
||
context,
|
||
),
|
||
),
|
||
]
|
||
: []),
|
||
]
|
||
: []
|
||
|
||
// Process user input attachments first (includes @mentioned files)
|
||
// This ensures files are added to nestedMemoryAttachmentTriggers before nested_memory processes them
|
||
const userAttachmentResults = await Promise.all(userInputAttachments)
|
||
|
||
// Thread-safe attachments available in sub-agents
|
||
// NOTE: These must be created AFTER userInputAttachments completes to ensure
|
||
// nestedMemoryAttachmentTriggers is populated before getNestedMemoryAttachments runs
|
||
const allThreadAttachments = [
|
||
// queuedCommands is already agent-scoped by the drain gate in query.ts —
|
||
// main thread gets agentId===undefined, subagents get their own agentId.
|
||
// Must run for all threads or subagent notifications drain into the void
|
||
// (removed from queue by removeFromQueue but never attached).
|
||
maybe('queued_commands', () => getQueuedCommandAttachments(queuedCommands)),
|
||
maybe('date_change', () =>
|
||
Promise.resolve(getDateChangeAttachments(messages)),
|
||
),
|
||
maybe('ultrathink_effort', () =>
|
||
Promise.resolve(getUltrathinkEffortAttachment(input)),
|
||
),
|
||
maybe('deferred_tools_delta', () =>
|
||
Promise.resolve(
|
||
getDeferredToolsDeltaAttachment(
|
||
toolUseContext.options.tools,
|
||
toolUseContext.options.mainLoopModel,
|
||
messages,
|
||
{
|
||
callSite: isMainThread
|
||
? 'attachments_main'
|
||
: 'attachments_subagent',
|
||
querySource,
|
||
},
|
||
),
|
||
),
|
||
),
|
||
maybe('agent_listing_delta', () =>
|
||
Promise.resolve(getAgentListingDeltaAttachment(toolUseContext, messages)),
|
||
),
|
||
maybe('mcp_instructions_delta', () =>
|
||
Promise.resolve(
|
||
getMcpInstructionsDeltaAttachment(
|
||
toolUseContext.options.mcpClients,
|
||
toolUseContext.options.tools,
|
||
toolUseContext.options.mainLoopModel,
|
||
messages,
|
||
),
|
||
),
|
||
),
|
||
...(feature('BUDDY')
|
||
? [
|
||
maybe('companion_intro', () =>
|
||
Promise.resolve(getCompanionIntroAttachment(messages)),
|
||
),
|
||
]
|
||
: []),
|
||
maybe('changed_files', () => getChangedFiles(context)),
|
||
maybe('nested_memory', () => getNestedMemoryAttachments(context)),
|
||
// relevant_memories moved to async prefetch (startRelevantMemoryPrefetch)
|
||
maybe('dynamic_skill', () => getDynamicSkillAttachments(context)),
|
||
maybe('skill_listing', () => getSkillListingAttachments(context)),
|
||
// Inter-turn skill discovery now runs via startSkillDiscoveryPrefetch
|
||
// (query.ts, concurrent with the main turn). The blocking call that
|
||
// previously lived here was the assistant_turn signal — 97% of those
|
||
// Haiku calls found nothing in prod. Prefetch + await-at-collection
|
||
// replaces it; see src/services/skillSearch/prefetch.ts.
|
||
maybe('plan_mode', () => getPlanModeAttachments(messages, toolUseContext)),
|
||
maybe('plan_mode_exit', () => getPlanModeExitAttachment(toolUseContext)),
|
||
...(feature('TRANSCRIPT_CLASSIFIER')
|
||
? [
|
||
maybe('auto_mode', () =>
|
||
getAutoModeAttachments(messages, toolUseContext),
|
||
),
|
||
maybe('auto_mode_exit', () =>
|
||
getAutoModeExitAttachment(toolUseContext),
|
||
),
|
||
]
|
||
: []),
|
||
maybe('todo_reminders', () =>
|
||
isTodoV2Enabled()
|
||
? getTaskReminderAttachments(messages, toolUseContext)
|
||
: getTodoReminderAttachments(messages, toolUseContext),
|
||
),
|
||
...(isAgentSwarmsEnabled()
|
||
? [
|
||
// Skip teammate mailbox for the session_memory forked agent.
|
||
// It shares AppState.teamContext with the leader, so isTeamLead resolves
|
||
// true and it reads+marks-as-read the leader's DMs as ephemeral attachments,
|
||
// silently stealing messages that should be delivered as permanent turns.
|
||
...(querySource === 'session_memory'
|
||
? []
|
||
: [
|
||
maybe('teammate_mailbox', async () =>
|
||
getTeammateMailboxAttachments(toolUseContext),
|
||
),
|
||
]),
|
||
maybe('team_context', async () =>
|
||
getTeamContextAttachment(messages ?? []),
|
||
),
|
||
]
|
||
: []),
|
||
maybe('agent_pending_messages', async () =>
|
||
getAgentPendingMessageAttachments(toolUseContext),
|
||
),
|
||
maybe('critical_system_reminder', () =>
|
||
Promise.resolve(getCriticalSystemReminderAttachment(toolUseContext)),
|
||
),
|
||
...(feature('COMPACTION_REMINDERS')
|
||
? [
|
||
maybe('compaction_reminder', () =>
|
||
Promise.resolve(
|
||
getCompactionReminderAttachment(
|
||
messages ?? [],
|
||
toolUseContext.options.mainLoopModel,
|
||
),
|
||
),
|
||
),
|
||
]
|
||
: []),
|
||
...(feature('HISTORY_SNIP')
|
||
? [
|
||
maybe('context_efficiency', () =>
|
||
Promise.resolve(getContextEfficiencyAttachment(messages ?? [])),
|
||
),
|
||
]
|
||
: []),
|
||
]
|
||
|
||
// Attachments which are semantically only for the main conversation or don't have concurrency-safe implementations
|
||
const mainThreadAttachments = isMainThread
|
||
? [
|
||
maybe('ide_selection', async () =>
|
||
getSelectedLinesFromIDE(ideSelection, toolUseContext),
|
||
),
|
||
maybe('ide_opened_file', async () =>
|
||
getOpenedFileFromIDE(ideSelection, toolUseContext),
|
||
),
|
||
maybe('output_style', async () =>
|
||
Promise.resolve(getOutputStyleAttachment()),
|
||
),
|
||
maybe('diagnostics', async () =>
|
||
getDiagnosticAttachments(toolUseContext),
|
||
),
|
||
maybe('lsp_diagnostics', async () =>
|
||
getLSPDiagnosticAttachments(toolUseContext),
|
||
),
|
||
maybe('unified_tasks', async () =>
|
||
getUnifiedTaskAttachments(toolUseContext),
|
||
),
|
||
maybe('async_hook_responses', async () =>
|
||
getAsyncHookResponseAttachments(),
|
||
),
|
||
maybe('token_usage', async () =>
|
||
Promise.resolve(
|
||
getTokenUsageAttachment(
|
||
messages ?? [],
|
||
toolUseContext.options.mainLoopModel,
|
||
),
|
||
),
|
||
),
|
||
maybe('budget_usd', async () =>
|
||
Promise.resolve(
|
||
getMaxBudgetUsdAttachment(toolUseContext.options.maxBudgetUsd),
|
||
),
|
||
),
|
||
maybe('output_token_usage', async () =>
|
||
Promise.resolve(getOutputTokenUsageAttachment()),
|
||
),
|
||
maybe('verify_plan_reminder', async () =>
|
||
getVerifyPlanReminderAttachment(messages, toolUseContext),
|
||
),
|
||
]
|
||
: []
|
||
|
||
// Process thread and main thread attachments in parallel (no dependencies between them)
|
||
const [threadAttachmentResults, mainThreadAttachmentResults] =
|
||
await Promise.all([
|
||
Promise.all(allThreadAttachments),
|
||
Promise.all(mainThreadAttachments),
|
||
])
|
||
|
||
clearTimeout(timeoutId)
|
||
// Defensive: a getter leaking [undefined] crashes .map(a => a.type) below.
|
||
return [
|
||
...userAttachmentResults.flat(),
|
||
...threadAttachmentResults.flat(),
|
||
...mainThreadAttachmentResults.flat(),
|
||
].filter(a => a !== undefined && a !== null)
|
||
}
|
||
|
||
async function maybe<A>(label: string, f: () => Promise<A[]>): Promise<A[]> {
|
||
const startTime = Date.now()
|
||
try {
|
||
const result = await f()
|
||
const duration = Date.now() - startTime
|
||
// Log only 5% of events to reduce volume
|
||
if (Math.random() < 0.05) {
|
||
// jsonStringify(undefined) returns undefined, so .length would throw
|
||
const attachmentSizeBytes = result
|
||
.filter(a => a !== undefined && a !== null)
|
||
.reduce((total, attachment) => {
|
||
return total + jsonStringify(attachment).length
|
||
}, 0)
|
||
logEvent('tengu_attachment_compute_duration', {
|
||
label,
|
||
duration_ms: duration,
|
||
attachment_size_bytes: attachmentSizeBytes,
|
||
attachment_count: result.length,
|
||
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||
}
|
||
return result
|
||
} catch (e) {
|
||
const duration = Date.now() - startTime
|
||
// Log only 5% of events to reduce volume
|
||
if (Math.random() < 0.05) {
|
||
logEvent('tengu_attachment_compute_duration', {
|
||
label,
|
||
duration_ms: duration,
|
||
error: true,
|
||
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||
}
|
||
logError(e)
|
||
// For Ant users, log the full error to help with debugging
|
||
logAntError(`Attachment error in ${label}`, e)
|
||
|
||
return []
|
||
}
|
||
}
|
||
|
||
const INLINE_NOTIFICATION_MODES = new Set(['prompt', 'task-notification'])
|
||
|
||
export async function getQueuedCommandAttachments(
|
||
queuedCommands: QueuedCommand[],
|
||
): Promise<Attachment[]> {
|
||
if (!queuedCommands) {
|
||
return []
|
||
}
|
||
// Include both 'prompt' and 'task-notification' commands as attachments.
|
||
// During proactive agentic loops, task-notification commands would otherwise
|
||
// stay in the queue permanently (useQueueProcessor can't run while a query
|
||
// is active), causing hasPendingNotifications() to return true and Sleep to
|
||
// wake immediately with 0ms duration in an infinite loop.
|
||
const filtered = queuedCommands.filter(_ =>
|
||
INLINE_NOTIFICATION_MODES.has(_.mode),
|
||
)
|
||
return Promise.all(
|
||
filtered.map(async _ => {
|
||
const imageBlocks = await buildImageContentBlocks(_.pastedContents)
|
||
let prompt: string | Array<ContentBlockParam> = _.value
|
||
if (imageBlocks.length > 0) {
|
||
// Build content block array with text + images so the model sees them
|
||
const textValue =
|
||
typeof _.value === 'string'
|
||
? _.value
|
||
: extractTextContent(_.value, '\n')
|
||
prompt = [{ type: 'text' as const, text: textValue }, ...imageBlocks]
|
||
}
|
||
return {
|
||
type: 'queued_command' as const,
|
||
prompt,
|
||
source_uuid: _.uuid,
|
||
imagePasteIds: getImagePasteIds(_.pastedContents),
|
||
commandMode: _.mode,
|
||
origin: _.origin,
|
||
isMeta: _.isMeta,
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
export function getAgentPendingMessageAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Attachment[] {
|
||
const agentId = toolUseContext.agentId
|
||
if (!agentId) return []
|
||
const drained = drainPendingMessages(
|
||
agentId,
|
||
toolUseContext.getAppState,
|
||
toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState,
|
||
)
|
||
return drained.map(msg => ({
|
||
type: 'queued_command' as const,
|
||
prompt: msg,
|
||
origin: { kind: 'coordinator' as const },
|
||
isMeta: true,
|
||
}))
|
||
}
|
||
|
||
async function buildImageContentBlocks(
|
||
pastedContents: Record<number, PastedContent> | undefined,
|
||
): Promise<ImageBlockParam[]> {
|
||
if (!pastedContents) {
|
||
return []
|
||
}
|
||
const imageContents = Object.values(pastedContents).filter(isValidImagePaste)
|
||
if (imageContents.length === 0) {
|
||
return []
|
||
}
|
||
const results = await Promise.all(
|
||
imageContents.map(async img => {
|
||
const imageBlock: ImageBlockParam = {
|
||
type: 'image',
|
||
source: {
|
||
type: 'base64',
|
||
media_type: (img.mediaType ||
|
||
'image/png') as Base64ImageSource['media_type'],
|
||
data: img.content,
|
||
},
|
||
}
|
||
const resized = await maybeResizeAndDownsampleImageBlock(imageBlock)
|
||
return resized.block
|
||
}),
|
||
)
|
||
return results
|
||
}
|
||
|
||
function getPlanModeAttachmentTurnCount(messages: Message[]): {
|
||
turnCount: number
|
||
foundPlanModeAttachment: boolean
|
||
} {
|
||
let turnsSinceLastAttachment = 0
|
||
let foundPlanModeAttachment = false
|
||
|
||
// Iterate backwards to find most recent plan_mode attachment.
|
||
// Count HUMAN turns (non-meta, non-tool-result user messages), not assistant
|
||
// messages — the tool loop in query.ts calls getAttachmentMessages on every
|
||
// tool round, so counting assistant messages would fire the reminder every
|
||
// 5 tool calls instead of every 5 human turns.
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
|
||
if (
|
||
message?.type === 'user' &&
|
||
!message.isMeta &&
|
||
!hasToolResultContent(message.message.content)
|
||
) {
|
||
turnsSinceLastAttachment++
|
||
} else if (
|
||
message?.type === 'attachment' &&
|
||
(message.attachment.type === 'plan_mode' ||
|
||
message.attachment.type === 'plan_mode_reentry')
|
||
) {
|
||
foundPlanModeAttachment = true
|
||
break
|
||
}
|
||
}
|
||
|
||
return { turnCount: turnsSinceLastAttachment, foundPlanModeAttachment }
|
||
}
|
||
|
||
/**
|
||
* Count plan_mode attachments since the last plan_mode_exit (or from start if no exit).
|
||
* This ensures the full/sparse cycle resets when re-entering plan mode.
|
||
*/
|
||
function countPlanModeAttachmentsSinceLastExit(messages: Message[]): number {
|
||
let count = 0
|
||
// Iterate backwards - if we hit a plan_mode_exit, stop counting
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
if (message?.type === 'attachment') {
|
||
if (message.attachment.type === 'plan_mode_exit') {
|
||
break // Stop counting at the last exit
|
||
}
|
||
if (message.attachment.type === 'plan_mode') {
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
async function getPlanModeAttachments(
|
||
messages: Message[] | undefined,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const appState = toolUseContext.getAppState()
|
||
const permissionContext = appState.toolPermissionContext
|
||
if (permissionContext.mode !== 'plan') {
|
||
return []
|
||
}
|
||
|
||
// Check if we should attach based on turn count (except for first turn)
|
||
if (messages && messages.length > 0) {
|
||
const { turnCount, foundPlanModeAttachment } =
|
||
getPlanModeAttachmentTurnCount(messages)
|
||
// Only throttle if we've already sent a plan_mode attachment before
|
||
// On first turn in plan mode, always attach
|
||
if (
|
||
foundPlanModeAttachment &&
|
||
turnCount < PLAN_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS
|
||
) {
|
||
return []
|
||
}
|
||
}
|
||
|
||
const planFilePath = getPlanFilePath(toolUseContext.agentId)
|
||
const existingPlan = getPlan(toolUseContext.agentId)
|
||
|
||
const attachments: Attachment[] = []
|
||
|
||
// Check for re-entry: flag is set AND plan file exists
|
||
if (hasExitedPlanModeInSession() && existingPlan !== null) {
|
||
attachments.push({ type: 'plan_mode_reentry', planFilePath })
|
||
setHasExitedPlanMode(false) // Clear flag - one-time guidance
|
||
}
|
||
|
||
// Determine if this should be a full or sparse reminder
|
||
// Full reminder on 1st, 6th, 11th... (every Nth attachment)
|
||
const attachmentCount =
|
||
countPlanModeAttachmentsSinceLastExit(messages ?? []) + 1
|
||
const reminderType: 'full' | 'sparse' =
|
||
attachmentCount %
|
||
PLAN_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS ===
|
||
1
|
||
? 'full'
|
||
: 'sparse'
|
||
|
||
// Always add the main plan_mode attachment
|
||
attachments.push({
|
||
type: 'plan_mode',
|
||
reminderType,
|
||
isSubAgent: !!toolUseContext.agentId,
|
||
planFilePath,
|
||
planExists: existingPlan !== null,
|
||
})
|
||
|
||
return attachments
|
||
}
|
||
|
||
/**
|
||
* Returns a plan_mode_exit attachment if we just exited plan mode.
|
||
* This is a one-time notification to tell the model it's no longer in plan mode.
|
||
*/
|
||
async function getPlanModeExitAttachment(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
// Only trigger if the flag is set (we just exited plan mode)
|
||
if (!needsPlanModeExitAttachment()) {
|
||
return []
|
||
}
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
if (appState.toolPermissionContext.mode === 'plan') {
|
||
setNeedsPlanModeExitAttachment(false)
|
||
return []
|
||
}
|
||
|
||
// Clear the flag - this is a one-time notification
|
||
setNeedsPlanModeExitAttachment(false)
|
||
|
||
const planFilePath = getPlanFilePath(toolUseContext.agentId)
|
||
const planExists = getPlan(toolUseContext.agentId) !== null
|
||
|
||
// Note: skill discovery does NOT fire on plan exit. By the time the plan is
|
||
// written, it's too late — the model should have had relevant skills WHILE
|
||
// planning. The user_message signal already fires on the request that
|
||
// triggers planning ("plan how to deploy this"), which is the right moment.
|
||
return [{ type: 'plan_mode_exit', planFilePath, planExists }]
|
||
}
|
||
|
||
function getAutoModeAttachmentTurnCount(messages: Message[]): {
|
||
turnCount: number
|
||
foundAutoModeAttachment: boolean
|
||
} {
|
||
let turnsSinceLastAttachment = 0
|
||
let foundAutoModeAttachment = false
|
||
|
||
// Iterate backwards to find most recent auto_mode attachment.
|
||
// Count HUMAN turns (non-meta, non-tool-result user messages), not assistant
|
||
// messages — the tool loop in query.ts calls getAttachmentMessages on every
|
||
// tool round, so a single human turn with 100 tool calls would fire ~20
|
||
// reminders if we counted assistant messages. Auto mode's target use case is
|
||
// long agentic sessions, where this accumulated 60-105× per session.
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
|
||
if (
|
||
message?.type === 'user' &&
|
||
!message.isMeta &&
|
||
!hasToolResultContent(message.message.content)
|
||
) {
|
||
turnsSinceLastAttachment++
|
||
} else if (
|
||
message?.type === 'attachment' &&
|
||
message.attachment.type === 'auto_mode'
|
||
) {
|
||
foundAutoModeAttachment = true
|
||
break
|
||
} else if (
|
||
message?.type === 'attachment' &&
|
||
message.attachment.type === 'auto_mode_exit'
|
||
) {
|
||
// Exit resets the throttle — treat as if no prior attachment exists
|
||
break
|
||
}
|
||
}
|
||
|
||
return { turnCount: turnsSinceLastAttachment, foundAutoModeAttachment }
|
||
}
|
||
|
||
/**
|
||
* Count auto_mode attachments since the last auto_mode_exit (or from start if no exit).
|
||
* This ensures the full/sparse cycle resets when re-entering auto mode.
|
||
*/
|
||
function countAutoModeAttachmentsSinceLastExit(messages: Message[]): number {
|
||
let count = 0
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
if (message?.type === 'attachment') {
|
||
if (message.attachment.type === 'auto_mode_exit') {
|
||
break
|
||
}
|
||
if (message.attachment.type === 'auto_mode') {
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
async function getAutoModeAttachments(
|
||
messages: Message[] | undefined,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const appState = toolUseContext.getAppState()
|
||
const permissionContext = appState.toolPermissionContext
|
||
const inAuto = permissionContext.mode === 'auto'
|
||
const inPlanWithAuto =
|
||
permissionContext.mode === 'plan' &&
|
||
(autoModeStateModule?.isAutoModeActive() ?? false)
|
||
if (!inAuto && !inPlanWithAuto) {
|
||
return []
|
||
}
|
||
|
||
// Check if we should attach based on turn count (except for first turn)
|
||
if (messages && messages.length > 0) {
|
||
const { turnCount, foundAutoModeAttachment } =
|
||
getAutoModeAttachmentTurnCount(messages)
|
||
// Only throttle if we've already sent an auto_mode attachment before
|
||
// On first turn in auto mode, always attach
|
||
if (
|
||
foundAutoModeAttachment &&
|
||
turnCount < AUTO_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS
|
||
) {
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Determine if this should be a full or sparse reminder
|
||
const attachmentCount =
|
||
countAutoModeAttachmentsSinceLastExit(messages ?? []) + 1
|
||
const reminderType: 'full' | 'sparse' =
|
||
attachmentCount %
|
||
AUTO_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS ===
|
||
1
|
||
? 'full'
|
||
: 'sparse'
|
||
|
||
return [{ type: 'auto_mode', reminderType }]
|
||
}
|
||
|
||
/**
|
||
* Returns an auto_mode_exit attachment if we just exited auto mode.
|
||
* This is a one-time notification to tell the model it's no longer in auto mode.
|
||
*/
|
||
async function getAutoModeExitAttachment(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
if (!needsAutoModeExitAttachment()) {
|
||
return []
|
||
}
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
// Suppress when auto is still active — covers both mode==='auto' and
|
||
// plan-with-auto-active (where mode==='plan' but classifier runs).
|
||
if (
|
||
appState.toolPermissionContext.mode === 'auto' ||
|
||
(autoModeStateModule?.isAutoModeActive() ?? false)
|
||
) {
|
||
setNeedsAutoModeExitAttachment(false)
|
||
return []
|
||
}
|
||
|
||
setNeedsAutoModeExitAttachment(false)
|
||
return [{ type: 'auto_mode_exit' }]
|
||
}
|
||
|
||
/**
|
||
* Detects when the local date has changed since the last turn (user coding
|
||
* past midnight) and emits an attachment to notify the model.
|
||
*
|
||
* The date_change attachment is appended at the tail of the conversation,
|
||
* so the model learns the new date without mutating the cached prefix.
|
||
* messages[0] (from getUserContext → prependUserContext) intentionally
|
||
* keeps the stale date — clearing that cache would regenerate the prefix
|
||
* and turn the entire conversation into cache_creation on the next turn
|
||
* (~920K effective tokens per midnight crossing per overnight session).
|
||
*
|
||
* Exported for testing — regression guard for the cache-clear removal.
|
||
*/
|
||
export function getDateChangeAttachments(
|
||
messages: Message[] | undefined,
|
||
): Attachment[] {
|
||
const currentDate = getLocalISODate()
|
||
const lastDate = getLastEmittedDate()
|
||
|
||
if (lastDate === null) {
|
||
// First turn — just record, no attachment needed
|
||
setLastEmittedDate(currentDate)
|
||
return []
|
||
}
|
||
|
||
if (currentDate === lastDate) {
|
||
return []
|
||
}
|
||
|
||
setLastEmittedDate(currentDate)
|
||
|
||
// Assistant mode: flush yesterday's transcript to the per-day file so
|
||
// the /dream skill (1–5am local) finds it even if no compaction fires
|
||
// today. Fire-and-forget; writeSessionTranscriptSegment buckets by
|
||
// message timestamp so a multi-day gap flushes each day correctly.
|
||
if (feature('KAIROS')) {
|
||
if (getKairosActive() && messages !== undefined) {
|
||
sessionTranscriptModule?.flushOnDateChange(messages, currentDate)
|
||
}
|
||
}
|
||
|
||
return [{ type: 'date_change', newDate: currentDate }]
|
||
}
|
||
|
||
function getUltrathinkEffortAttachment(input: string | null): Attachment[] {
|
||
if (!isUltrathinkEnabled() || !input || !hasUltrathinkKeyword(input)) {
|
||
return []
|
||
}
|
||
logEvent('tengu_ultrathink', {})
|
||
return [{ type: 'ultrathink_effort', level: 'high' }]
|
||
}
|
||
|
||
// Exported for compact.ts — the gate must be identical at both call sites.
|
||
export function getDeferredToolsDeltaAttachment(
|
||
tools: Tools,
|
||
model: string,
|
||
messages: Message[] | undefined,
|
||
scanContext?: DeferredToolsDeltaScanContext,
|
||
): Attachment[] {
|
||
if (!isDeferredToolsDeltaEnabled()) return []
|
||
// These three checks mirror the sync parts of isToolSearchEnabled —
|
||
// the attachment text says "available via ToolSearch", so ToolSearch
|
||
// has to actually be in the request. The async auto-threshold check
|
||
// is not replicated (would double-fire tengu_tool_search_mode_decision);
|
||
// in tst-auto below-threshold the attachment can fire while ToolSearch
|
||
// is filtered out, but that's a narrow case and the tools announced
|
||
// are directly callable anyway.
|
||
if (!isToolSearchEnabledOptimistic()) return []
|
||
if (!modelSupportsToolReference(model)) return []
|
||
if (!isToolSearchToolAvailable(tools)) return []
|
||
const delta = getDeferredToolsDelta(tools, messages ?? [], scanContext)
|
||
if (!delta) return []
|
||
return [{ type: 'deferred_tools_delta', ...delta }]
|
||
}
|
||
|
||
/**
|
||
* Diff the current filtered agent pool against what's already been announced
|
||
* in this conversation (reconstructed from prior agent_listing_delta
|
||
* attachments). Returns [] if nothing changed or the gate is off.
|
||
*
|
||
* The agent list was embedded in AgentTool's description, causing ~10.2% of
|
||
* fleet cache_creation: MCP async connect, /reload-plugins, or
|
||
* permission-mode change → description changes → full tool-schema cache bust.
|
||
* Moving the list here keeps the tool description static.
|
||
*
|
||
* Exported for compact.ts — re-announces the full set after compaction eats
|
||
* prior deltas.
|
||
*/
|
||
export function getAgentListingDeltaAttachment(
|
||
toolUseContext: ToolUseContext,
|
||
messages: Message[] | undefined,
|
||
): Attachment[] {
|
||
if (!shouldInjectAgentListInMessages()) return []
|
||
|
||
// Skip if AgentTool isn't in the pool — the listing would be unactionable.
|
||
if (
|
||
!toolUseContext.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME))
|
||
) {
|
||
return []
|
||
}
|
||
|
||
const { activeAgents, allowedAgentTypes } =
|
||
toolUseContext.options.agentDefinitions
|
||
|
||
// Mirror AgentTool.prompt()'s filtering: MCP requirements → deny rules →
|
||
// allowedAgentTypes restriction. Keep this in sync with AgentTool.tsx.
|
||
const mcpServers = new Set<string>()
|
||
for (const tool of toolUseContext.options.tools) {
|
||
const info = mcpInfoFromString(tool.name)
|
||
if (info) mcpServers.add(info.serverName)
|
||
}
|
||
const permissionContext = toolUseContext.getAppState().toolPermissionContext
|
||
let filtered = filterDeniedAgents(
|
||
filterAgentsByMcpRequirements(activeAgents, [...mcpServers]),
|
||
permissionContext,
|
||
AGENT_TOOL_NAME,
|
||
)
|
||
if (allowedAgentTypes) {
|
||
filtered = filtered.filter(a => allowedAgentTypes.includes(a.agentType))
|
||
}
|
||
|
||
// Reconstruct announced set from prior deltas in the transcript.
|
||
const announced = new Set<string>()
|
||
for (const msg of messages ?? []) {
|
||
if (msg.type !== 'attachment') continue
|
||
if (msg.attachment.type !== 'agent_listing_delta') continue
|
||
for (const t of msg.attachment.addedTypes) announced.add(t)
|
||
for (const t of msg.attachment.removedTypes) announced.delete(t)
|
||
}
|
||
|
||
const currentTypes = new Set(filtered.map(a => a.agentType))
|
||
const added = filtered.filter(a => !announced.has(a.agentType))
|
||
const removed: string[] = []
|
||
for (const t of announced) {
|
||
if (!currentTypes.has(t)) removed.push(t)
|
||
}
|
||
|
||
if (added.length === 0 && removed.length === 0) return []
|
||
|
||
// Sort for deterministic output — agent load order is nondeterministic
|
||
// (plugin load races, MCP async connect).
|
||
added.sort((a, b) => a.agentType.localeCompare(b.agentType))
|
||
removed.sort()
|
||
|
||
return [
|
||
{
|
||
type: 'agent_listing_delta',
|
||
addedTypes: added.map(a => a.agentType),
|
||
addedLines: added.map(formatAgentLine),
|
||
removedTypes: removed,
|
||
isInitial: announced.size === 0,
|
||
showConcurrencyNote: getSubscriptionType() !== 'pro',
|
||
},
|
||
]
|
||
}
|
||
|
||
// Exported for compact.ts / reactiveCompact.ts — single source of truth for the gate.
|
||
export function getMcpInstructionsDeltaAttachment(
|
||
mcpClients: MCPServerConnection[],
|
||
tools: Tools,
|
||
model: string,
|
||
messages: Message[] | undefined,
|
||
): Attachment[] {
|
||
if (!isMcpInstructionsDeltaEnabled()) return []
|
||
|
||
// The chrome ToolSearch hint is client-authored and ToolSearch-conditional;
|
||
// actual server `instructions` are unconditional. Decide the chrome part
|
||
// here, pass it into the pure diff as a synthesized entry.
|
||
const clientSide: ClientSideInstruction[] = []
|
||
if (
|
||
isToolSearchEnabledOptimistic() &&
|
||
modelSupportsToolReference(model) &&
|
||
isToolSearchToolAvailable(tools)
|
||
) {
|
||
clientSide.push({
|
||
serverName: CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
||
block: CHROME_TOOL_SEARCH_INSTRUCTIONS,
|
||
})
|
||
}
|
||
|
||
const delta = getMcpInstructionsDelta(mcpClients, messages ?? [], clientSide)
|
||
if (!delta) return []
|
||
return [{ type: 'mcp_instructions_delta', ...delta }]
|
||
}
|
||
|
||
function getCriticalSystemReminderAttachment(
|
||
toolUseContext: ToolUseContext,
|
||
): Attachment[] {
|
||
const reminder = toolUseContext.criticalSystemReminder_EXPERIMENTAL
|
||
if (!reminder) {
|
||
return []
|
||
}
|
||
return [{ type: 'critical_system_reminder', content: reminder }]
|
||
}
|
||
|
||
function getOutputStyleAttachment(): Attachment[] {
|
||
const settings = getSettings_DEPRECATED()
|
||
const outputStyle = settings?.outputStyle || 'default'
|
||
|
||
// Only show for non-default styles
|
||
if (outputStyle === 'default') {
|
||
return []
|
||
}
|
||
|
||
return [
|
||
{
|
||
type: 'output_style',
|
||
style: outputStyle,
|
||
},
|
||
]
|
||
}
|
||
|
||
async function getSelectedLinesFromIDE(
|
||
ideSelection: IDESelection | null,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const ideName = getConnectedIdeName(toolUseContext.options.mcpClients)
|
||
if (
|
||
!ideName ||
|
||
ideSelection?.lineStart === undefined ||
|
||
!ideSelection.text ||
|
||
!ideSelection.filePath
|
||
) {
|
||
return []
|
||
}
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) {
|
||
return []
|
||
}
|
||
|
||
return [
|
||
{
|
||
type: 'selected_lines_in_ide',
|
||
ideName,
|
||
lineStart: ideSelection.lineStart,
|
||
lineEnd: ideSelection.lineStart + ideSelection.lineCount - 1,
|
||
filename: ideSelection.filePath,
|
||
content: ideSelection.text,
|
||
displayPath: relative(getCwd(), ideSelection.filePath),
|
||
},
|
||
]
|
||
}
|
||
|
||
/**
|
||
* Computes the directories to process for nested memory file loading.
|
||
* Returns two lists:
|
||
* - nestedDirs: Directories between CWD and targetPath (processed for CLAUDE.md + all rules)
|
||
* - cwdLevelDirs: Directories from root to CWD (processed for conditional rules only)
|
||
*
|
||
* @param targetPath The target file path
|
||
* @param originalCwd The original current working directory
|
||
* @returns Object with nestedDirs and cwdLevelDirs arrays, both ordered from parent to child
|
||
*/
|
||
export function getDirectoriesToProcess(
|
||
targetPath: string,
|
||
originalCwd: string,
|
||
): { nestedDirs: string[]; cwdLevelDirs: string[] } {
|
||
// Build list of directories from original CWD to targetPath's directory
|
||
const targetDir = dirname(resolve(targetPath))
|
||
const nestedDirs: string[] = []
|
||
let currentDir = targetDir
|
||
|
||
// Walk up from target directory to original CWD
|
||
while (currentDir !== originalCwd && currentDir !== parse(currentDir).root) {
|
||
if (currentDir.startsWith(originalCwd)) {
|
||
nestedDirs.push(currentDir)
|
||
}
|
||
currentDir = dirname(currentDir)
|
||
}
|
||
|
||
// Reverse to get order from CWD down to target
|
||
nestedDirs.reverse()
|
||
|
||
// Build list of directories from root to CWD (for conditional rules only)
|
||
const cwdLevelDirs: string[] = []
|
||
currentDir = originalCwd
|
||
|
||
while (currentDir !== parse(currentDir).root) {
|
||
cwdLevelDirs.push(currentDir)
|
||
currentDir = dirname(currentDir)
|
||
}
|
||
|
||
// Reverse to get order from root to CWD
|
||
cwdLevelDirs.reverse()
|
||
|
||
return { nestedDirs, cwdLevelDirs }
|
||
}
|
||
|
||
/**
|
||
* Converts memory files to attachments, filtering out already-loaded files.
|
||
*
|
||
* @param memoryFiles The memory files to convert
|
||
* @param toolUseContext The tool use context (for tracking loaded files)
|
||
* @returns Array of nested memory attachments
|
||
*/
|
||
function isInstructionsMemoryType(
|
||
type: MemoryFileInfo['type'],
|
||
): type is InstructionsMemoryType {
|
||
return (
|
||
type === 'User' ||
|
||
type === 'Project' ||
|
||
type === 'Local' ||
|
||
type === 'Managed'
|
||
)
|
||
}
|
||
|
||
/** Exported for testing — regression guard for LRU-eviction re-injection. */
|
||
export function memoryFilesToAttachments(
|
||
memoryFiles: MemoryFileInfo[],
|
||
toolUseContext: ToolUseContext,
|
||
triggerFilePath?: string,
|
||
): Attachment[] {
|
||
const attachments: Attachment[] = []
|
||
const shouldFireHook = hasInstructionsLoadedHook()
|
||
|
||
for (const memoryFile of memoryFiles) {
|
||
// Dedup: loadedNestedMemoryPaths is a non-evicting Set; readFileState
|
||
// is a 100-entry LRU that drops entries in busy sessions, so relying
|
||
// on it alone re-injects the same CLAUDE.md on every eviction cycle.
|
||
if (toolUseContext.loadedNestedMemoryPaths?.has(memoryFile.path)) {
|
||
continue
|
||
}
|
||
if (!toolUseContext.readFileState.has(memoryFile.path)) {
|
||
attachments.push({
|
||
type: 'nested_memory',
|
||
path: memoryFile.path,
|
||
content: memoryFile,
|
||
displayPath: relative(getCwd(), memoryFile.path),
|
||
})
|
||
toolUseContext.loadedNestedMemoryPaths?.add(memoryFile.path)
|
||
|
||
// Mark as loaded in readFileState — this provides cross-function and
|
||
// cross-turn dedup via the .has() check above.
|
||
//
|
||
// When the injected content doesn't match disk (stripped HTML comments,
|
||
// stripped frontmatter, truncated MEMORY.md), cache the RAW disk bytes
|
||
// with `isPartialView: true`. Edit/Write see the flag and require a real
|
||
// Read first; getChangedFiles sees real content + undefined offset/limit
|
||
// so mid-session change detection still works.
|
||
toolUseContext.readFileState.set(memoryFile.path, {
|
||
content: memoryFile.contentDiffersFromDisk
|
||
? (memoryFile.rawContent ?? memoryFile.content)
|
||
: memoryFile.content,
|
||
timestamp: Date.now(),
|
||
offset: undefined,
|
||
limit: undefined,
|
||
isPartialView: memoryFile.contentDiffersFromDisk,
|
||
})
|
||
|
||
|
||
// Fire InstructionsLoaded hook for audit/observability (fire-and-forget)
|
||
if (shouldFireHook && isInstructionsMemoryType(memoryFile.type)) {
|
||
const loadReason = memoryFile.globs
|
||
? 'path_glob_match'
|
||
: memoryFile.parent
|
||
? 'include'
|
||
: 'nested_traversal'
|
||
void executeInstructionsLoadedHooks(
|
||
memoryFile.path,
|
||
memoryFile.type,
|
||
loadReason,
|
||
{
|
||
globs: memoryFile.globs,
|
||
triggerFilePath,
|
||
parentFilePath: memoryFile.parent,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
return attachments
|
||
}
|
||
|
||
/**
|
||
* Loads nested memory files for a given file path and returns them as attachments.
|
||
* This function performs directory traversal to find CLAUDE.md files and conditional rules
|
||
* that apply to the target file path.
|
||
*
|
||
* Processing order (must be preserved):
|
||
* 1. Managed/User conditional rules matching targetPath
|
||
* 2. Nested directories (CWD → target): CLAUDE.md + unconditional + conditional rules
|
||
* 3. CWD-level directories (root → CWD): conditional rules only
|
||
*
|
||
* @param filePath The file path to get nested memory files for
|
||
* @param toolUseContext The tool use context
|
||
* @param appState The app state containing tool permission context
|
||
* @returns Array of nested memory attachments
|
||
*/
|
||
async function getNestedMemoryAttachmentsForFile(
|
||
filePath: string,
|
||
toolUseContext: ToolUseContext,
|
||
appState: { toolPermissionContext: ToolPermissionContext },
|
||
): Promise<Attachment[]> {
|
||
const attachments: Attachment[] = []
|
||
|
||
try {
|
||
// Early return if path is not in allowed working path
|
||
if (!pathInAllowedWorkingPath(filePath, appState.toolPermissionContext)) {
|
||
return attachments
|
||
}
|
||
|
||
const processedPaths = new Set<string>()
|
||
const originalCwd = getOriginalCwd()
|
||
|
||
// Phase 1: Process Managed and User conditional rules
|
||
const managedUserRules = await getManagedAndUserConditionalRules(
|
||
filePath,
|
||
processedPaths,
|
||
)
|
||
attachments.push(
|
||
...memoryFilesToAttachments(managedUserRules, toolUseContext, filePath),
|
||
)
|
||
|
||
// Phase 2: Get directories to process
|
||
const { nestedDirs, cwdLevelDirs } = getDirectoriesToProcess(
|
||
filePath,
|
||
originalCwd,
|
||
)
|
||
|
||
const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE(
|
||
'tengu_paper_halyard',
|
||
false,
|
||
)
|
||
|
||
// Phase 3: Process nested directories (CWD → target)
|
||
// Each directory gets: CLAUDE.md + unconditional rules + conditional rules
|
||
for (const dir of nestedDirs) {
|
||
const memoryFiles = (
|
||
await getMemoryFilesForNestedDirectory(dir, filePath, processedPaths)
|
||
).filter(
|
||
f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'),
|
||
)
|
||
attachments.push(
|
||
...memoryFilesToAttachments(memoryFiles, toolUseContext, filePath),
|
||
)
|
||
}
|
||
|
||
// Phase 4: Process CWD-level directories (root → CWD)
|
||
// Only conditional rules (unconditional rules are already loaded eagerly)
|
||
for (const dir of cwdLevelDirs) {
|
||
const conditionalRules = (
|
||
await getConditionalRulesForCwdLevelDirectory(
|
||
dir,
|
||
filePath,
|
||
processedPaths,
|
||
)
|
||
).filter(
|
||
f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'),
|
||
)
|
||
attachments.push(
|
||
...memoryFilesToAttachments(conditionalRules, toolUseContext, filePath),
|
||
)
|
||
}
|
||
} catch (error) {
|
||
logError(error)
|
||
}
|
||
|
||
return attachments
|
||
}
|
||
|
||
async function getOpenedFileFromIDE(
|
||
ideSelection: IDESelection | null,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
if (!ideSelection?.filePath || ideSelection.text) {
|
||
return []
|
||
}
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) {
|
||
return []
|
||
}
|
||
|
||
// Get nested memory files
|
||
const nestedMemoryAttachments = await getNestedMemoryAttachmentsForFile(
|
||
ideSelection.filePath,
|
||
toolUseContext,
|
||
appState,
|
||
)
|
||
|
||
// Return nested memory attachments followed by the opened file attachment
|
||
return [
|
||
...nestedMemoryAttachments,
|
||
{
|
||
type: 'opened_file_in_ide',
|
||
filename: ideSelection.filePath,
|
||
},
|
||
]
|
||
}
|
||
|
||
async function processAtMentionedFiles(
|
||
input: string,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const files = extractAtMentionedFiles(input)
|
||
if (files.length === 0) return []
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
const results = await Promise.all(
|
||
files.map(async file => {
|
||
try {
|
||
const { filename, lineStart, lineEnd } = parseAtMentionedFileLines(file)
|
||
const absoluteFilename = expandPath(filename)
|
||
|
||
if (
|
||
isFileReadDenied(absoluteFilename, appState.toolPermissionContext)
|
||
) {
|
||
return null
|
||
}
|
||
|
||
// Check if it's a directory
|
||
try {
|
||
const stats = await stat(absoluteFilename)
|
||
if (stats.isDirectory()) {
|
||
try {
|
||
const entries = await readdir(absoluteFilename, {
|
||
withFileTypes: true,
|
||
})
|
||
const MAX_DIR_ENTRIES = 1000
|
||
const truncated = entries.length > MAX_DIR_ENTRIES
|
||
const names = entries.slice(0, MAX_DIR_ENTRIES).map(e => e.name)
|
||
if (truncated) {
|
||
names.push(
|
||
`\u2026 and ${entries.length - MAX_DIR_ENTRIES} more entries`,
|
||
)
|
||
}
|
||
const stdout = names.join('\n')
|
||
logEvent('tengu_at_mention_extracting_directory_success', {})
|
||
|
||
return {
|
||
type: 'directory' as const,
|
||
path: absoluteFilename,
|
||
content: stdout,
|
||
displayPath: relative(getCwd(), absoluteFilename),
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
} catch {
|
||
// If stat fails, continue with file logic
|
||
}
|
||
|
||
return await generateFileAttachment(
|
||
absoluteFilename,
|
||
toolUseContext,
|
||
'tengu_at_mention_extracting_filename_success',
|
||
'tengu_at_mention_extracting_filename_error',
|
||
'at-mention',
|
||
{
|
||
offset: lineStart,
|
||
limit: lineEnd && lineStart ? lineEnd - lineStart + 1 : undefined,
|
||
},
|
||
)
|
||
} catch {
|
||
logEvent('tengu_at_mention_extracting_filename_error', {})
|
||
}
|
||
}),
|
||
)
|
||
return results.filter(Boolean) as Attachment[]
|
||
}
|
||
|
||
function processAgentMentions(
|
||
input: string,
|
||
agents: AgentDefinition[],
|
||
): Attachment[] {
|
||
const agentMentions = extractAgentMentions(input)
|
||
if (agentMentions.length === 0) return []
|
||
|
||
const results = agentMentions.map(mention => {
|
||
const agentType = mention.replace('agent-', '')
|
||
const agentDef = agents.find(def => def.agentType === agentType)
|
||
|
||
if (!agentDef) {
|
||
logEvent('tengu_at_mention_agent_not_found', {})
|
||
return null
|
||
}
|
||
|
||
logEvent('tengu_at_mention_agent_success', {})
|
||
|
||
return {
|
||
type: 'agent_mention' as const,
|
||
agentType: agentDef.agentType,
|
||
}
|
||
})
|
||
|
||
return results.filter(
|
||
(result): result is NonNullable<typeof result> => result !== null,
|
||
)
|
||
}
|
||
|
||
async function processMcpResourceAttachments(
|
||
input: string,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const resourceMentions = extractMcpResourceMentions(input)
|
||
if (resourceMentions.length === 0) return []
|
||
|
||
const mcpClients = toolUseContext.options.mcpClients || []
|
||
|
||
const results = await Promise.all(
|
||
resourceMentions.map(async mention => {
|
||
try {
|
||
const [serverName, ...uriParts] = mention.split(':')
|
||
const uri = uriParts.join(':') // Rejoin in case URI contains colons
|
||
|
||
if (!serverName || !uri) {
|
||
logEvent('tengu_at_mention_mcp_resource_error', {})
|
||
return null
|
||
}
|
||
|
||
// Find the MCP client
|
||
const client = mcpClients.find(c => c.name === serverName)
|
||
if (!client || client.type !== 'connected') {
|
||
logEvent('tengu_at_mention_mcp_resource_error', {})
|
||
return null
|
||
}
|
||
|
||
// Find the resource in available resources to get its metadata
|
||
const serverResources =
|
||
toolUseContext.options.mcpResources?.[serverName] || []
|
||
const resourceInfo = serverResources.find(r => r.uri === uri)
|
||
if (!resourceInfo) {
|
||
logEvent('tengu_at_mention_mcp_resource_error', {})
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const result = await client.client.readResource({
|
||
uri,
|
||
})
|
||
|
||
logEvent('tengu_at_mention_mcp_resource_success', {})
|
||
|
||
return {
|
||
type: 'mcp_resource' as const,
|
||
server: serverName,
|
||
uri,
|
||
name: resourceInfo.name || uri,
|
||
description: resourceInfo.description,
|
||
content: result,
|
||
}
|
||
} catch (error) {
|
||
logEvent('tengu_at_mention_mcp_resource_error', {})
|
||
logError(error)
|
||
return null
|
||
}
|
||
} catch {
|
||
logEvent('tengu_at_mention_mcp_resource_error', {})
|
||
return null
|
||
}
|
||
}),
|
||
)
|
||
|
||
return results.filter(
|
||
(result): result is NonNullable<typeof result> => result !== null,
|
||
) as Attachment[]
|
||
}
|
||
|
||
export async function getChangedFiles(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const filePaths = cacheKeys(toolUseContext.readFileState)
|
||
if (filePaths.length === 0) return []
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
const results = await Promise.all(
|
||
filePaths.map(async filePath => {
|
||
const fileState = toolUseContext.readFileState.get(filePath)
|
||
if (!fileState) return null
|
||
|
||
// TODO: Implement offset/limit support for changed files
|
||
if (fileState.offset !== undefined || fileState.limit !== undefined) {
|
||
return null
|
||
}
|
||
|
||
const normalizedPath = expandPath(filePath)
|
||
|
||
// Check if file has a deny rule configured
|
||
if (isFileReadDenied(normalizedPath, appState.toolPermissionContext)) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const mtime = await getFileModificationTimeAsync(normalizedPath)
|
||
if (mtime <= fileState.timestamp) {
|
||
return null
|
||
}
|
||
|
||
const fileInput = { file_path: normalizedPath }
|
||
|
||
// Validate file path is valid
|
||
const isValid = await FileReadTool.validateInput(
|
||
fileInput,
|
||
toolUseContext,
|
||
)
|
||
if (!isValid.result) {
|
||
return null
|
||
}
|
||
|
||
const result = await FileReadTool.call(fileInput, toolUseContext)
|
||
// Extract only the changed section
|
||
if (result.data.type === 'text') {
|
||
const snippet = getSnippetForTwoFileDiff(
|
||
fileState.content,
|
||
result.data.file.content,
|
||
)
|
||
|
||
// File was touched but not modified
|
||
if (snippet === '') {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
type: 'edited_text_file' as const,
|
||
filename: normalizedPath,
|
||
snippet,
|
||
}
|
||
}
|
||
|
||
// For non-text files (images), apply the same token limit logic as FileReadTool
|
||
if (result.data.type === 'image') {
|
||
try {
|
||
const data = await readImageWithTokenBudget(normalizedPath)
|
||
return {
|
||
type: 'edited_image_file' as const,
|
||
filename: normalizedPath,
|
||
content: data,
|
||
}
|
||
} catch (compressionError) {
|
||
logError(compressionError)
|
||
logEvent('tengu_watched_file_compression_failed', {
|
||
file: normalizedPath,
|
||
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// notebook / pdf / parts — no diff representation; explicitly
|
||
// null so the map callback has no implicit-undefined path.
|
||
return null
|
||
} catch (err) {
|
||
// Evict ONLY on ENOENT (file truly deleted). Transient stat
|
||
// failures — atomic-save races (editor writes tmp→rename and
|
||
// stat hits the gap), EACCES churn, network-FS hiccups — must
|
||
// NOT evict, or the next Edit fails code-6 even though the
|
||
// file still exists and the model just read it. VS Code
|
||
// auto-save/format-on-save hits this race especially often.
|
||
// See regression analysis on PR #18525.
|
||
if (isENOENT(err)) {
|
||
toolUseContext.readFileState.delete(filePath)
|
||
}
|
||
return null
|
||
}
|
||
}),
|
||
)
|
||
return results.filter(result => result != null) as Attachment[]
|
||
}
|
||
|
||
/**
|
||
* Processes paths that need nested memory attachments and checks for nested CLAUDE.md files
|
||
* Uses nestedMemoryAttachmentTriggers field from ToolUseContext
|
||
*/
|
||
async function getNestedMemoryAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
// Check triggers first — getAppState() waits for a React render cycle,
|
||
// and the common case is an empty trigger set.
|
||
if (
|
||
!toolUseContext.nestedMemoryAttachmentTriggers ||
|
||
toolUseContext.nestedMemoryAttachmentTriggers.size === 0
|
||
) {
|
||
return []
|
||
}
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
const attachments: Attachment[] = []
|
||
|
||
for (const filePath of toolUseContext.nestedMemoryAttachmentTriggers) {
|
||
const nestedAttachments = await getNestedMemoryAttachmentsForFile(
|
||
filePath,
|
||
toolUseContext,
|
||
appState,
|
||
)
|
||
attachments.push(...nestedAttachments)
|
||
}
|
||
|
||
toolUseContext.nestedMemoryAttachmentTriggers.clear()
|
||
|
||
return attachments
|
||
}
|
||
|
||
async function getRelevantMemoryAttachments(
|
||
input: string,
|
||
agents: AgentDefinition[],
|
||
readFileState: FileStateCache,
|
||
recentTools: readonly string[],
|
||
signal: AbortSignal,
|
||
alreadySurfaced: ReadonlySet<string>,
|
||
): Promise<Attachment[]> {
|
||
// If an agent is @-mentioned, search only its memory dir (isolation).
|
||
// Otherwise search the auto-memory dir.
|
||
const memoryDirs = extractAgentMentions(input).flatMap(mention => {
|
||
const agentType = mention.replace('agent-', '')
|
||
const agentDef = agents.find(def => def.agentType === agentType)
|
||
return agentDef?.memory
|
||
? [getAgentMemoryDir(agentType, agentDef.memory)]
|
||
: []
|
||
})
|
||
const dirs = memoryDirs.length > 0 ? memoryDirs : [getAutoMemPath()]
|
||
|
||
const allResults = await Promise.all(
|
||
dirs.map(dir =>
|
||
findRelevantMemories(
|
||
input,
|
||
dir,
|
||
signal,
|
||
recentTools,
|
||
alreadySurfaced,
|
||
).catch(() => []),
|
||
),
|
||
)
|
||
// alreadySurfaced is filtered inside the selector so Sonnet spends its
|
||
// 5-slot budget on fresh candidates; readFileState catches files the
|
||
// model read via FileReadTool. The redundant alreadySurfaced check here
|
||
// is a belt-and-suspenders guard (multi-dir results may re-introduce a
|
||
// path the selector filtered in a different dir).
|
||
const selected = allResults
|
||
.flat()
|
||
.filter(m => !readFileState.has(m.path) && !alreadySurfaced.has(m.path))
|
||
.slice(0, 5)
|
||
|
||
const memories = await readMemoriesForSurfacing(selected, signal)
|
||
|
||
if (memories.length === 0) {
|
||
return []
|
||
}
|
||
return [{ type: 'relevant_memories' as const, memories }]
|
||
}
|
||
|
||
/**
|
||
* Scan messages for past relevant_memories attachments. Returns both the
|
||
* set of surfaced paths (for selector de-dup) and cumulative byte count
|
||
* (for session-total throttle). Scanning messages rather than tracking
|
||
* in toolUseContext means compact naturally resets both — old attachments
|
||
* are gone from the compacted transcript, so re-surfacing is valid again.
|
||
*/
|
||
export function collectSurfacedMemories(messages: ReadonlyArray<Message>): {
|
||
paths: Set<string>
|
||
totalBytes: number
|
||
} {
|
||
const paths = new Set<string>()
|
||
let totalBytes = 0
|
||
for (const m of messages) {
|
||
if (m.type === 'attachment' && m.attachment.type === 'relevant_memories') {
|
||
for (const mem of m.attachment.memories) {
|
||
paths.add(mem.path)
|
||
totalBytes += mem.content.length
|
||
}
|
||
}
|
||
}
|
||
return { paths, totalBytes }
|
||
}
|
||
|
||
/**
|
||
* Reads a set of relevance-ranked memory files for injection as
|
||
* <system-reminder> attachments. Enforces both MAX_MEMORY_LINES and
|
||
* MAX_MEMORY_BYTES via readFileInRange's truncateOnByteLimit option.
|
||
* Truncation surfaces partial
|
||
* content with a note rather than dropping the file — findRelevantMemories
|
||
* already picked this as most-relevant, so the frontmatter + opening context
|
||
* is worth surfacing even if later lines are cut.
|
||
*
|
||
* Exported for direct testing without mocking the ranker + GB gates.
|
||
*/
|
||
export async function readMemoriesForSurfacing(
|
||
selected: ReadonlyArray<{ path: string; mtimeMs: number }>,
|
||
signal?: AbortSignal,
|
||
): Promise<
|
||
Array<{
|
||
path: string
|
||
content: string
|
||
mtimeMs: number
|
||
header: string
|
||
limit?: number
|
||
}>
|
||
> {
|
||
const results = await Promise.all(
|
||
selected.map(async ({ path: filePath, mtimeMs }) => {
|
||
try {
|
||
const result = await readFileInRange(
|
||
filePath,
|
||
0,
|
||
MAX_MEMORY_LINES,
|
||
MAX_MEMORY_BYTES,
|
||
signal,
|
||
{ truncateOnByteLimit: true },
|
||
)
|
||
const truncated =
|
||
result.totalLines > MAX_MEMORY_LINES || result.truncatedByBytes
|
||
const content = truncated
|
||
? result.content +
|
||
`\n\n> This memory file was truncated (${result.truncatedByBytes ? `${MAX_MEMORY_BYTES} byte limit` : `first ${MAX_MEMORY_LINES} lines`}). Use the ${FILE_READ_TOOL_NAME} tool to view the complete file at: ${filePath}`
|
||
: result.content
|
||
return {
|
||
path: filePath,
|
||
content,
|
||
mtimeMs,
|
||
header: memoryHeader(filePath, mtimeMs),
|
||
limit: truncated ? result.lineCount : undefined,
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}),
|
||
)
|
||
return results.filter(r => r !== null)
|
||
}
|
||
|
||
/**
|
||
* Header string for a relevant-memory block. Exported so messages.ts
|
||
* can fall back for resumed sessions where the stored header is missing.
|
||
*/
|
||
export function memoryHeader(path: string, mtimeMs: number): string {
|
||
const staleness = memoryFreshnessText(mtimeMs)
|
||
return staleness
|
||
? `${staleness}\n\nMemory: ${path}:`
|
||
: `Memory (saved ${memoryAge(mtimeMs)}): ${path}:`
|
||
}
|
||
|
||
/**
|
||
* A memory relevance-selector prefetch handle. The promise is started once
|
||
* per user turn and runs while the main model streams and tools execute.
|
||
* At the collect point (post-tools), the caller reads settledAt to
|
||
* consume-if-ready or skip-and-retry-next-iteration — the prefetch never
|
||
* blocks the turn.
|
||
*
|
||
* Disposable: query.ts binds with `using`, so [Symbol.dispose] fires on all
|
||
* generator exit paths (return, throw, .return() closure) — aborting the
|
||
* in-flight request and emitting terminal telemetry without instrumenting
|
||
* each of the ~13 return sites inside the while loop.
|
||
*/
|
||
export type MemoryPrefetch = {
|
||
promise: Promise<Attachment[]>
|
||
/** Set by promise.finally(). null until the promise settles. */
|
||
settledAt: number | null
|
||
/** Set by the collect point in query.ts. -1 until consumed. */
|
||
consumedOnIteration: number
|
||
[Symbol.dispose](): void
|
||
}
|
||
|
||
/**
|
||
* Starts the relevant memory search as an async prefetch.
|
||
* Extracts the last real user prompt from messages (skipping isMeta system
|
||
* injections) and kicks off a non-blocking search. Returns a Disposable
|
||
* handle with settlement tracking. Bound with `using` in query.ts.
|
||
*/
|
||
export function startRelevantMemoryPrefetch(
|
||
messages: ReadonlyArray<Message>,
|
||
toolUseContext: ToolUseContext,
|
||
): MemoryPrefetch | undefined {
|
||
if (
|
||
!isAutoMemoryEnabled() ||
|
||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false)
|
||
) {
|
||
return undefined
|
||
}
|
||
|
||
const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta)
|
||
if (!lastUserMessage) {
|
||
return undefined
|
||
}
|
||
|
||
const input = getUserMessageText(lastUserMessage)
|
||
// Single-word prompts lack enough context for meaningful term extraction
|
||
if (!input || !/\s/.test(input.trim())) {
|
||
return undefined
|
||
}
|
||
|
||
const surfaced = collectSurfacedMemories(messages)
|
||
if (surfaced.totalBytes >= RELEVANT_MEMORIES_CONFIG.MAX_SESSION_BYTES) {
|
||
return undefined
|
||
}
|
||
|
||
// Chained to the turn-level abort so user Escape cancels the sideQuery
|
||
// immediately, not just on [Symbol.dispose] when queryLoop exits.
|
||
const controller = createChildAbortController(toolUseContext.abortController)
|
||
const firedAt = Date.now()
|
||
const promise = getRelevantMemoryAttachments(
|
||
input,
|
||
toolUseContext.options.agentDefinitions.activeAgents,
|
||
toolUseContext.readFileState,
|
||
collectRecentSuccessfulTools(messages, lastUserMessage),
|
||
controller.signal,
|
||
surfaced.paths,
|
||
).catch(e => {
|
||
if (!isAbortError(e)) {
|
||
logError(e)
|
||
}
|
||
return []
|
||
})
|
||
|
||
const handle: MemoryPrefetch = {
|
||
promise,
|
||
settledAt: null,
|
||
consumedOnIteration: -1,
|
||
[Symbol.dispose]() {
|
||
controller.abort()
|
||
logEvent('tengu_memdir_prefetch_collected', {
|
||
hidden_by_first_iteration:
|
||
handle.settledAt !== null && handle.consumedOnIteration === 0,
|
||
consumed_on_iteration: handle.consumedOnIteration,
|
||
latency_ms: (handle.settledAt ?? Date.now()) - firedAt,
|
||
})
|
||
},
|
||
}
|
||
void promise.finally(() => {
|
||
handle.settledAt = Date.now()
|
||
})
|
||
return handle
|
||
}
|
||
|
||
type ToolResultBlock = {
|
||
type: 'tool_result'
|
||
tool_use_id: string
|
||
is_error?: boolean
|
||
}
|
||
|
||
function isToolResultBlock(b: unknown): b is ToolResultBlock {
|
||
return (
|
||
typeof b === 'object' &&
|
||
b !== null &&
|
||
(b as ToolResultBlock).type === 'tool_result' &&
|
||
typeof (b as ToolResultBlock).tool_use_id === 'string'
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Check whether a user message's content contains tool_result blocks.
|
||
* This is more reliable than checking `toolUseResult === undefined` because
|
||
* sub-agent tool result messages explicitly set `toolUseResult` to `undefined`
|
||
* when `preserveToolUseResults` is false (the default for Explore agents).
|
||
*/
|
||
function hasToolResultContent(content: unknown): boolean {
|
||
return Array.isArray(content) && content.some(isToolResultBlock)
|
||
}
|
||
|
||
/**
|
||
* Tools that succeeded (and never errored) since the previous real turn
|
||
* boundary. The memory selector uses this to suppress docs about tools
|
||
* that are working — surfacing reference material for a tool the model
|
||
* is already calling successfully is noise.
|
||
*
|
||
* Any error → tool excluded (model is struggling, docs stay available).
|
||
* No result yet → also excluded (outcome unknown).
|
||
*
|
||
* tool_use lives in assistant content; tool_result in user content
|
||
* (toolUseResult set, isMeta undefined). Both are within the scan window.
|
||
* Backward scan sees results before uses so we collect both by id and
|
||
* resolve after.
|
||
*/
|
||
export function collectRecentSuccessfulTools(
|
||
messages: ReadonlyArray<Message>,
|
||
lastUserMessage: Message,
|
||
): readonly string[] {
|
||
const useIdToName = new Map<string, string>()
|
||
const resultByUseId = new Map<string, boolean>()
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const m = messages[i]
|
||
if (!m) continue
|
||
if (isHumanTurn(m) && m !== lastUserMessage) break
|
||
if (m.type === 'assistant' && typeof m.message.content !== 'string') {
|
||
for (const block of m.message.content) {
|
||
if (block.type === 'tool_use') useIdToName.set(block.id, block.name)
|
||
}
|
||
} else if (
|
||
m.type === 'user' &&
|
||
'message' in m &&
|
||
Array.isArray(m.message.content)
|
||
) {
|
||
for (const block of m.message.content) {
|
||
if (isToolResultBlock(block)) {
|
||
resultByUseId.set(block.tool_use_id, block.is_error === true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const failed = new Set<string>()
|
||
const succeeded = new Set<string>()
|
||
for (const [id, name] of useIdToName) {
|
||
const errored = resultByUseId.get(id)
|
||
if (errored === undefined) continue
|
||
if (errored) {
|
||
failed.add(name)
|
||
} else {
|
||
succeeded.add(name)
|
||
}
|
||
}
|
||
return [...succeeded].filter(t => !failed.has(t))
|
||
}
|
||
|
||
|
||
/**
|
||
* Filters prefetched memory attachments to exclude memories the model already
|
||
* has in context via FileRead/Write/Edit tool calls (any iteration this turn)
|
||
* or a previous turn's memory surfacing — both tracked in the cumulative
|
||
* readFileState. Survivors are then marked in readFileState so subsequent
|
||
* turns won't re-surface them.
|
||
*
|
||
* The mark-after-filter ordering is load-bearing: readMemoriesForSurfacing
|
||
* used to write to readFileState during the prefetch, which meant the filter
|
||
* saw every prefetch-selected path as "already in context" and dropped them
|
||
* all (self-referential filter). Deferring the write to here, after the
|
||
* filter runs, breaks that cycle while still deduping against tool calls
|
||
* from any iteration.
|
||
*/
|
||
export function filterDuplicateMemoryAttachments(
|
||
attachments: Attachment[],
|
||
readFileState: FileStateCache,
|
||
): Attachment[] {
|
||
return attachments
|
||
.map(attachment => {
|
||
if (attachment.type !== 'relevant_memories') return attachment
|
||
const filtered = attachment.memories.filter(
|
||
m => !readFileState.has(m.path),
|
||
)
|
||
for (const m of filtered) {
|
||
readFileState.set(m.path, {
|
||
content: m.content,
|
||
timestamp: m.mtimeMs,
|
||
offset: undefined,
|
||
limit: m.limit,
|
||
})
|
||
}
|
||
return filtered.length > 0 ? { ...attachment, memories: filtered } : null
|
||
})
|
||
.filter((a): a is Attachment => a !== null)
|
||
}
|
||
|
||
/**
|
||
* Processes skill directories that were discovered during file operations.
|
||
* Uses dynamicSkillDirTriggers field from ToolUseContext
|
||
*/
|
||
async function getDynamicSkillAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const attachments: Attachment[] = []
|
||
|
||
if (
|
||
toolUseContext.dynamicSkillDirTriggers &&
|
||
toolUseContext.dynamicSkillDirTriggers.size > 0
|
||
) {
|
||
// Parallelize: readdir all skill dirs concurrently
|
||
const perDirResults = await Promise.all(
|
||
Array.from(toolUseContext.dynamicSkillDirTriggers).map(async skillDir => {
|
||
try {
|
||
const entries = await readdir(skillDir, { withFileTypes: true })
|
||
const candidates = entries
|
||
.filter(e => e.isDirectory() || e.isSymbolicLink())
|
||
.map(e => e.name)
|
||
// Parallelize: stat all SKILL.md candidates concurrently
|
||
const checked = await Promise.all(
|
||
candidates.map(async name => {
|
||
try {
|
||
await stat(resolve(skillDir, name, 'SKILL.md'))
|
||
return name
|
||
} catch {
|
||
return null // SKILL.md doesn't exist, skip this entry
|
||
}
|
||
}),
|
||
)
|
||
return {
|
||
skillDir,
|
||
skillNames: checked.filter((n): n is string => n !== null),
|
||
}
|
||
} catch {
|
||
// Ignore errors reading skill directories (e.g., directory doesn't exist)
|
||
return { skillDir, skillNames: [] }
|
||
}
|
||
}),
|
||
)
|
||
|
||
for (const { skillDir, skillNames } of perDirResults) {
|
||
if (skillNames.length > 0) {
|
||
attachments.push({
|
||
type: 'dynamic_skill',
|
||
skillDir,
|
||
skillNames,
|
||
displayPath: relative(getCwd(), skillDir),
|
||
})
|
||
}
|
||
}
|
||
|
||
toolUseContext.dynamicSkillDirTriggers.clear()
|
||
}
|
||
|
||
return attachments
|
||
}
|
||
|
||
// Track which skills have been sent to avoid re-sending. Keyed by agentId
|
||
// (empty string = main thread) so subagents get their own turn-0 listing —
|
||
// without per-agent scoping, the main thread populating this Set would cause
|
||
// every subagent's filterToBundledAndMcp result to dedup to empty.
|
||
const sentSkillNames = new Map<string, Set<string>>()
|
||
|
||
// Called when the skill set genuinely changes (plugin reload, skill file
|
||
// change on disk) so new skills get announced. NOT called on compact —
|
||
// post-compact re-injection costs ~4K tokens/event for marginal benefit.
|
||
export function resetSentSkillNames(): void {
|
||
sentSkillNames.clear()
|
||
suppressNext = false
|
||
}
|
||
|
||
/**
|
||
* Suppress the next skill-listing injection. Called by conversationRecovery
|
||
* on --resume when a skill_listing attachment already exists in the
|
||
* transcript.
|
||
*
|
||
* `sentSkillNames` is module-scope — process-local. Each `claude -p` spawn
|
||
* starts with an empty Map, so without this every resume re-injects the
|
||
* full ~600-token listing even though it's already in the conversation from
|
||
* the prior process. Shows up on every --resume; particularly loud for
|
||
* daemons that respawn frequently.
|
||
*
|
||
* Trade-off: skills added between sessions won't be announced until the
|
||
* next non-resume session. Acceptable — skill_listing was never meant to
|
||
* cover cross-process deltas, and the agent can still call them (they're
|
||
* in the Skill tool's runtime registry regardless).
|
||
*/
|
||
export function suppressNextSkillListing(): void {
|
||
suppressNext = true
|
||
}
|
||
let suppressNext = false
|
||
|
||
// When skill-search is enabled and the filtered (bundled + MCP) listing exceeds
|
||
// this count, fall back to bundled-only. Protects MCP-heavy users (100+ servers)
|
||
// from truncation while keeping the turn-0 guarantee for typical setups.
|
||
const FILTERED_LISTING_MAX = 30
|
||
|
||
/**
|
||
* Filter skills to bundled (Anthropic-curated) + MCP (user-connected) only.
|
||
* Used when skill-search is enabled to resolve the turn-0 gap for subagents:
|
||
* these sources are small, intent-signaled, and won't hit the truncation budget.
|
||
* User/project/plugin skills (the long tail — 200+) go through discovery instead.
|
||
*
|
||
* Falls back to bundled-only if bundled+mcp exceeds FILTERED_LISTING_MAX.
|
||
*/
|
||
export function filterToBundledAndMcp(commands: Command[]): Command[] {
|
||
const filtered = commands.filter(
|
||
cmd => cmd.loadedFrom === 'bundled' || cmd.loadedFrom === 'mcp',
|
||
)
|
||
if (filtered.length > FILTERED_LISTING_MAX) {
|
||
return filtered.filter(cmd => cmd.loadedFrom === 'bundled')
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
async function getSkillListingAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
if (process.env.NODE_ENV === 'test') {
|
||
return []
|
||
}
|
||
|
||
// Skip skill listing for agents that don't have the Skill tool — they can't use skills directly.
|
||
if (
|
||
!toolUseContext.options.tools.some(t => toolMatchesName(t, SKILL_TOOL_NAME))
|
||
) {
|
||
return []
|
||
}
|
||
|
||
const cwd = getProjectRoot()
|
||
const localCommands = await getSkillToolCommands(cwd)
|
||
const mcpSkills = getMcpSkillCommands(
|
||
toolUseContext.getAppState().mcp.commands,
|
||
)
|
||
let allCommands =
|
||
mcpSkills.length > 0
|
||
? uniqBy([...localCommands, ...mcpSkills], 'name')
|
||
: localCommands
|
||
|
||
// When skill search is active, filter to bundled + MCP instead of full
|
||
// suppression. Resolves the turn-0 gap: main thread gets turn-0 discovery
|
||
// via getTurnZeroSkillDiscovery (blocking), but subagents use the async
|
||
// subagent_spawn signal (collected post-tools, visible turn 1). Bundled +
|
||
// MCP are small and intent-signaled; user/project/plugin skills go through
|
||
// discovery. feature() first for DCE — the property-access string leaks
|
||
// otherwise even with ?. on null.
|
||
if (
|
||
feature('EXPERIMENTAL_SKILL_SEARCH') &&
|
||
skillSearchModules?.featureCheck.isSkillSearchEnabled()
|
||
) {
|
||
allCommands = filterToBundledAndMcp(allCommands)
|
||
}
|
||
|
||
const agentKey = toolUseContext.agentId ?? ''
|
||
let sent = sentSkillNames.get(agentKey)
|
||
if (!sent) {
|
||
sent = new Set()
|
||
sentSkillNames.set(agentKey, sent)
|
||
}
|
||
|
||
// Resume path: prior process already injected a listing; it's in the
|
||
// transcript. Mark everything current as sent so only post-resume deltas
|
||
// (skills loaded later via /reload-plugins etc) get announced.
|
||
if (suppressNext) {
|
||
suppressNext = false
|
||
for (const cmd of allCommands) {
|
||
sent.add(cmd.name)
|
||
}
|
||
return []
|
||
}
|
||
|
||
// Find skills we haven't sent yet
|
||
const newSkills = allCommands.filter(cmd => !sent.has(cmd.name))
|
||
|
||
if (newSkills.length === 0) {
|
||
return []
|
||
}
|
||
|
||
// If no skills have been sent yet, this is the initial batch
|
||
const isInitial = sent.size === 0
|
||
|
||
// Mark as sent
|
||
for (const cmd of newSkills) {
|
||
sent.add(cmd.name)
|
||
}
|
||
|
||
logForDebugging(
|
||
`Sending ${newSkills.length} skills via attachment (${isInitial ? 'initial' : 'dynamic'}, ${sent.size} total sent)`,
|
||
)
|
||
|
||
// Format within budget using existing logic
|
||
const contextWindowTokens = getContextWindowForModel(
|
||
toolUseContext.options.mainLoopModel,
|
||
getSdkBetas(),
|
||
)
|
||
const content = formatCommandsWithinBudget(newSkills, contextWindowTokens)
|
||
|
||
return [
|
||
{
|
||
type: 'skill_listing',
|
||
content,
|
||
skillCount: newSkills.length,
|
||
isInitial,
|
||
},
|
||
]
|
||
}
|
||
|
||
// getSkillDiscoveryAttachment moved to skillSearch/prefetch.ts as
|
||
// getTurnZeroSkillDiscovery — keeps the 'skill_discovery' string literal inside
|
||
// a feature-gated module so it doesn't leak into external builds.
|
||
|
||
export function extractAtMentionedFiles(content: string): string[] {
|
||
// Extract filenames mentioned with @ symbol, including line range syntax: @file.txt#L10-20
|
||
// Also supports quoted paths for files with spaces: @"my/file with spaces.txt"
|
||
// Example: "foo bar @baz moo" would extract "baz"
|
||
// Example: 'check @"my file.txt" please' would extract "my file.txt"
|
||
|
||
// Two patterns: quoted paths and regular paths
|
||
const quotedAtMentionRegex = /(^|\s)@"([^"]+)"/g
|
||
const regularAtMentionRegex = /(^|\s)@([^\s]+)\b/g
|
||
|
||
const quotedMatches: string[] = []
|
||
const regularMatches: string[] = []
|
||
|
||
// Extract quoted mentions first (skip agent mentions like @"code-reviewer (agent)")
|
||
let match
|
||
while ((match = quotedAtMentionRegex.exec(content)) !== null) {
|
||
if (match[2] && !match[2].endsWith(' (agent)')) {
|
||
quotedMatches.push(match[2]) // The content inside quotes
|
||
}
|
||
}
|
||
|
||
// Extract regular mentions
|
||
const regularMatchArray = content.match(regularAtMentionRegex) || []
|
||
regularMatchArray.forEach(match => {
|
||
const filename = match.slice(match.indexOf('@') + 1)
|
||
// Don't include if it starts with a quote (already handled as quoted)
|
||
if (!filename.startsWith('"')) {
|
||
regularMatches.push(filename)
|
||
}
|
||
})
|
||
|
||
// Combine and deduplicate
|
||
return uniq([...quotedMatches, ...regularMatches])
|
||
}
|
||
|
||
export function extractMcpResourceMentions(content: string): string[] {
|
||
// Extract MCP resources mentioned with @ symbol in format @server:uri
|
||
// Example: "@server1:resource/path" would extract "server1:resource/path"
|
||
const atMentionRegex = /(^|\s)@([^\s]+:[^\s]+)\b/g
|
||
const matches = content.match(atMentionRegex) || []
|
||
|
||
// Remove the prefix (everything before @) from each match
|
||
return uniq(matches.map(match => match.slice(match.indexOf('@') + 1)))
|
||
}
|
||
|
||
export function extractAgentMentions(content: string): string[] {
|
||
// Extract agent mentions in two formats:
|
||
// 1. @agent-<agent-type> (legacy/manual typing)
|
||
// Example: "@agent-code-elegance-refiner" → "agent-code-elegance-refiner"
|
||
// 2. @"<agent-type> (agent)" (from autocomplete selection)
|
||
// Example: '@"code-reviewer (agent)"' → "code-reviewer"
|
||
// Supports colons, dots, and at-signs for plugin-scoped agents like "@agent-asana:project-status-updater"
|
||
const results: string[] = []
|
||
|
||
// Match quoted format: @"<type> (agent)"
|
||
const quotedAgentRegex = /(^|\s)@"([\w:.@-]+) \(agent\)"/g
|
||
let match
|
||
while ((match = quotedAgentRegex.exec(content)) !== null) {
|
||
if (match[2]) {
|
||
results.push(match[2])
|
||
}
|
||
}
|
||
|
||
// Match unquoted format: @agent-<type>
|
||
const unquotedAgentRegex = /(^|\s)@(agent-[\w:.@-]+)/g
|
||
const unquotedMatches = content.match(unquotedAgentRegex) || []
|
||
for (const m of unquotedMatches) {
|
||
results.push(m.slice(m.indexOf('@') + 1))
|
||
}
|
||
|
||
return uniq(results)
|
||
}
|
||
|
||
interface AtMentionedFileLines {
|
||
filename: string
|
||
lineStart?: number
|
||
lineEnd?: number
|
||
}
|
||
|
||
export function parseAtMentionedFileLines(
|
||
mention: string,
|
||
): AtMentionedFileLines {
|
||
// Parse mentions like "file.txt#L10-20", "file.txt#heading", or just "file.txt"
|
||
// Supports line ranges (#L10, #L10-20) and strips non-line-range fragments (#heading)
|
||
const match = mention.match(/^([^#]+)(?:#L(\d+)(?:-(\d+))?)?(?:#[^#]*)?$/)
|
||
|
||
if (!match) {
|
||
return { filename: mention }
|
||
}
|
||
|
||
const [, filename, lineStartStr, lineEndStr] = match
|
||
const lineStart = lineStartStr ? parseInt(lineStartStr, 10) : undefined
|
||
const lineEnd = lineEndStr ? parseInt(lineEndStr, 10) : lineStart
|
||
|
||
return { filename: filename ?? mention, lineStart, lineEnd }
|
||
}
|
||
|
||
async function getDiagnosticAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
// Diagnostics are only useful if the agent has the Bash tool to act on them
|
||
if (
|
||
!toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME))
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// Get new diagnostics from the tracker (IDE diagnostics via MCP)
|
||
const newDiagnostics = await diagnosticTracker.getNewDiagnostics()
|
||
if (newDiagnostics.length === 0) {
|
||
return []
|
||
}
|
||
|
||
return [
|
||
{
|
||
type: 'diagnostics',
|
||
files: newDiagnostics,
|
||
isNew: true,
|
||
},
|
||
]
|
||
}
|
||
|
||
/**
|
||
* Get LSP diagnostic attachments from passive LSP servers.
|
||
* Follows the AsyncHookRegistry pattern for consistent async attachment delivery.
|
||
*/
|
||
async function getLSPDiagnosticAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
// LSP diagnostics are only useful if the agent has the Bash tool to act on them
|
||
if (
|
||
!toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME))
|
||
) {
|
||
return []
|
||
}
|
||
|
||
logForDebugging('LSP Diagnostics: getLSPDiagnosticAttachments called')
|
||
|
||
try {
|
||
const diagnosticSets = checkForLSPDiagnostics()
|
||
|
||
if (diagnosticSets.length === 0) {
|
||
return []
|
||
}
|
||
|
||
logForDebugging(
|
||
`LSP Diagnostics: Found ${diagnosticSets.length} pending diagnostic set(s)`,
|
||
)
|
||
|
||
// Convert each diagnostic set to an attachment
|
||
const attachments: Attachment[] = diagnosticSets.map(({ files }) => ({
|
||
type: 'diagnostics' as const,
|
||
files,
|
||
isNew: true,
|
||
}))
|
||
|
||
// Clear delivered diagnostics from registry to prevent memory leak
|
||
// Follows same pattern as removeDeliveredAsyncHooks
|
||
if (diagnosticSets.length > 0) {
|
||
clearAllLSPDiagnostics()
|
||
logForDebugging(
|
||
`LSP Diagnostics: Cleared ${diagnosticSets.length} delivered diagnostic(s) from registry`,
|
||
)
|
||
}
|
||
|
||
logForDebugging(
|
||
`LSP Diagnostics: Returning ${attachments.length} diagnostic attachment(s)`,
|
||
)
|
||
|
||
return attachments
|
||
} catch (error) {
|
||
const err = toError(error)
|
||
logError(
|
||
new Error(`Failed to get LSP diagnostic attachments: ${err.message}`),
|
||
)
|
||
// Return empty array to allow other attachments to proceed
|
||
return []
|
||
}
|
||
}
|
||
|
||
export async function* getAttachmentMessages(
|
||
input: string | null,
|
||
toolUseContext: ToolUseContext,
|
||
ideSelection: IDESelection | null,
|
||
queuedCommands: QueuedCommand[],
|
||
messages?: Message[],
|
||
querySource?: QuerySource,
|
||
options?: { skipSkillDiscovery?: boolean },
|
||
): AsyncGenerator<AttachmentMessage, void> {
|
||
// TODO: Compute this upstream
|
||
const attachments = await getAttachments(
|
||
input,
|
||
toolUseContext,
|
||
ideSelection,
|
||
queuedCommands,
|
||
messages,
|
||
querySource,
|
||
options,
|
||
)
|
||
|
||
if (attachments.length === 0) {
|
||
return
|
||
}
|
||
|
||
logEvent('tengu_attachments', {
|
||
attachment_types: attachments.map(
|
||
_ => _.type,
|
||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
for (const attachment of attachments) {
|
||
yield createAttachmentMessage(attachment)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generates a file attachment by reading a file with proper validation and truncation.
|
||
* This is the core file reading logic shared between @-mentioned files and post-compact restoration.
|
||
*
|
||
* @param filename The absolute path to the file to read
|
||
* @param toolUseContext The tool use context for calling FileReadTool
|
||
* @param options Optional configuration for file reading
|
||
* @returns A new_file attachment or null if the file couldn't be read
|
||
*/
|
||
/**
|
||
* Check if a PDF file should be represented as a lightweight reference
|
||
* instead of being inlined. Returns a PDFReferenceAttachment for large PDFs
|
||
* (more than PDF_AT_MENTION_INLINE_THRESHOLD pages), or null otherwise.
|
||
*/
|
||
export async function tryGetPDFReference(
|
||
filename: string,
|
||
): Promise<PDFReferenceAttachment | null> {
|
||
const ext = parse(filename).ext.toLowerCase()
|
||
if (!isPDFExtension(ext)) {
|
||
return null
|
||
}
|
||
try {
|
||
const [stats, pageCount] = await Promise.all([
|
||
getFsImplementation().stat(filename),
|
||
getPDFPageCount(filename),
|
||
])
|
||
// Use page count if available, otherwise fall back to size heuristic (~100KB per page)
|
||
const effectivePageCount = pageCount ?? Math.ceil(stats.size / (100 * 1024))
|
||
if (effectivePageCount > PDF_AT_MENTION_INLINE_THRESHOLD) {
|
||
logEvent('tengu_pdf_reference_attachment', {
|
||
pageCount: effectivePageCount,
|
||
fileSize: stats.size,
|
||
hadPdfinfo: pageCount !== null,
|
||
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||
return {
|
||
type: 'pdf_reference',
|
||
filename,
|
||
pageCount: effectivePageCount,
|
||
fileSize: stats.size,
|
||
displayPath: relative(getCwd(), filename),
|
||
}
|
||
}
|
||
} catch {
|
||
// If we can't stat the file, return null to proceed with normal reading
|
||
}
|
||
return null
|
||
}
|
||
|
||
export async function generateFileAttachment(
|
||
filename: string,
|
||
toolUseContext: ToolUseContext,
|
||
successEventName: string,
|
||
errorEventName: string,
|
||
mode: 'compact' | 'at-mention',
|
||
options?: {
|
||
offset?: number
|
||
limit?: number
|
||
},
|
||
): Promise<
|
||
| FileAttachment
|
||
| CompactFileReferenceAttachment
|
||
| PDFReferenceAttachment
|
||
| AlreadyReadFileAttachment
|
||
| null
|
||
> {
|
||
const { offset, limit } = options ?? {}
|
||
|
||
// Check if file has a deny rule configured
|
||
const appState = toolUseContext.getAppState()
|
||
if (isFileReadDenied(filename, appState.toolPermissionContext)) {
|
||
return null
|
||
}
|
||
|
||
// Check file size before attempting to read (skip for PDFs — they have their own size/page handling below)
|
||
if (
|
||
mode === 'at-mention' &&
|
||
!isFileWithinReadSizeLimit(
|
||
filename,
|
||
getDefaultFileReadingLimits().maxSizeBytes,
|
||
)
|
||
) {
|
||
const ext = parse(filename).ext.toLowerCase()
|
||
if (!isPDFExtension(ext)) {
|
||
try {
|
||
const stats = await getFsImplementation().stat(filename)
|
||
logEvent('tengu_attachment_file_too_large', {
|
||
size_bytes: stats.size,
|
||
mode,
|
||
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||
return null
|
||
} catch {
|
||
// If we can't stat the file, proceed with normal reading (will fail later if file doesn't exist)
|
||
}
|
||
}
|
||
}
|
||
|
||
// For large PDFs on @ mention, return a lightweight reference instead of inlining
|
||
if (mode === 'at-mention') {
|
||
const pdfRef = await tryGetPDFReference(filename)
|
||
if (pdfRef) {
|
||
return pdfRef
|
||
}
|
||
}
|
||
|
||
// Check if file is already in context with latest version
|
||
const existingFileState = toolUseContext.readFileState.get(filename)
|
||
if (existingFileState && mode === 'at-mention') {
|
||
try {
|
||
// Check if the file has been modified since we last read it
|
||
const mtimeMs = await getFileModificationTimeAsync(filename)
|
||
|
||
// Handle timestamp format inconsistency:
|
||
// - FileReadTool stores Date.now() (current time when read)
|
||
// - FileEdit/WriteTools store mtimeMs (file modification time)
|
||
//
|
||
// If timestamp > mtimeMs, it was stored by FileReadTool using Date.now()
|
||
// In this case, we should not use the optimization since we can't reliably
|
||
// compare modification times. Only use optimization when timestamp <= mtimeMs,
|
||
// indicating it was stored by FileEdit/WriteTool with actual mtimeMs.
|
||
|
||
if (
|
||
existingFileState.timestamp <= mtimeMs &&
|
||
mtimeMs === existingFileState.timestamp
|
||
) {
|
||
// File hasn't been modified, return already_read_file attachment
|
||
// This tells the system the file is already in context and doesn't need to be sent to API
|
||
logEvent(successEventName, {})
|
||
return {
|
||
type: 'already_read_file',
|
||
filename,
|
||
displayPath: relative(getCwd(), filename),
|
||
content: {
|
||
type: 'text',
|
||
file: {
|
||
filePath: filename,
|
||
content: existingFileState.content,
|
||
numLines: countCharInString(existingFileState.content, '\n') + 1,
|
||
startLine: offset ?? 1,
|
||
totalLines:
|
||
countCharInString(existingFileState.content, '\n') + 1,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
} catch {
|
||
// If we can't stat the file, proceed with normal reading
|
||
}
|
||
}
|
||
|
||
try {
|
||
const fileInput = {
|
||
file_path: filename,
|
||
offset,
|
||
limit,
|
||
}
|
||
|
||
async function readTruncatedFile(): Promise<
|
||
| FileAttachment
|
||
| CompactFileReferenceAttachment
|
||
| AlreadyReadFileAttachment
|
||
| null
|
||
> {
|
||
if (mode === 'compact') {
|
||
return {
|
||
type: 'compact_file_reference',
|
||
filename,
|
||
displayPath: relative(getCwd(), filename),
|
||
}
|
||
}
|
||
|
||
// Check deny rules before reading truncated file
|
||
const appState = toolUseContext.getAppState()
|
||
if (isFileReadDenied(filename, appState.toolPermissionContext)) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
// Read only the first MAX_LINES_TO_READ lines for files that are too large
|
||
const truncatedInput = {
|
||
file_path: filename,
|
||
offset: offset ?? 1,
|
||
limit: MAX_LINES_TO_READ,
|
||
}
|
||
const result = await FileReadTool.call(truncatedInput, toolUseContext)
|
||
logEvent(successEventName, {})
|
||
|
||
return {
|
||
type: 'file' as const,
|
||
filename,
|
||
content: result.data,
|
||
truncated: true,
|
||
displayPath: relative(getCwd(), filename),
|
||
}
|
||
} catch {
|
||
logEvent(errorEventName, {})
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Validate file path is valid
|
||
const isValid = await FileReadTool.validateInput(fileInput, toolUseContext)
|
||
if (!isValid.result) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const result = await FileReadTool.call(fileInput, toolUseContext)
|
||
logEvent(successEventName, {})
|
||
return {
|
||
type: 'file',
|
||
filename,
|
||
content: result.data,
|
||
displayPath: relative(getCwd(), filename),
|
||
}
|
||
} catch (error) {
|
||
if (
|
||
error instanceof MaxFileReadTokenExceededError ||
|
||
error instanceof FileTooLargeError
|
||
) {
|
||
return await readTruncatedFile()
|
||
}
|
||
throw error
|
||
}
|
||
} catch {
|
||
logEvent(errorEventName, {})
|
||
return null
|
||
}
|
||
}
|
||
|
||
export function createAttachmentMessage(
|
||
attachment: Attachment,
|
||
): AttachmentMessage {
|
||
return {
|
||
attachment,
|
||
type: 'attachment',
|
||
uuid: randomUUID(),
|
||
timestamp: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
function getTodoReminderTurnCounts(messages: Message[]): {
|
||
turnsSinceLastTodoWrite: number
|
||
turnsSinceLastReminder: number
|
||
} {
|
||
let lastTodoWriteIndex = -1
|
||
let lastReminderIndex = -1
|
||
let assistantTurnsSinceWrite = 0
|
||
let assistantTurnsSinceReminder = 0
|
||
|
||
// Iterate backwards to find most recent events
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
|
||
if (message?.type === 'assistant') {
|
||
if (isThinkingMessage(message)) {
|
||
// Skip thinking messages
|
||
continue
|
||
}
|
||
|
||
// Check for TodoWrite usage BEFORE incrementing counter
|
||
// (we don't want to count the TodoWrite message itself as "1 turn since write")
|
||
if (
|
||
lastTodoWriteIndex === -1 &&
|
||
'message' in message &&
|
||
Array.isArray(message.message?.content) &&
|
||
message.message.content.some(
|
||
block => block.type === 'tool_use' && block.name === 'TodoWrite',
|
||
)
|
||
) {
|
||
lastTodoWriteIndex = i
|
||
}
|
||
|
||
// Count assistant turns before finding events
|
||
if (lastTodoWriteIndex === -1) assistantTurnsSinceWrite++
|
||
if (lastReminderIndex === -1) assistantTurnsSinceReminder++
|
||
} else if (
|
||
lastReminderIndex === -1 &&
|
||
message?.type === 'attachment' &&
|
||
message.attachment.type === 'todo_reminder'
|
||
) {
|
||
lastReminderIndex = i
|
||
}
|
||
|
||
if (lastTodoWriteIndex !== -1 && lastReminderIndex !== -1) {
|
||
break
|
||
}
|
||
}
|
||
|
||
return {
|
||
turnsSinceLastTodoWrite: assistantTurnsSinceWrite,
|
||
turnsSinceLastReminder: assistantTurnsSinceReminder,
|
||
}
|
||
}
|
||
|
||
async function getTodoReminderAttachments(
|
||
messages: Message[] | undefined,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
// Skip if TodoWrite tool is not available
|
||
if (
|
||
!toolUseContext.options.tools.some(t =>
|
||
toolMatchesName(t, TODO_WRITE_TOOL_NAME),
|
||
)
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// When SendUserMessage is in the toolkit, it's the primary communication
|
||
// channel and the model is always told to use it (#20467). TodoWrite
|
||
// becomes a side channel — nudging the model about it conflicts with the
|
||
// brief workflow. The tool itself stays available; this only gates the
|
||
// "you haven't used it in a while" nag.
|
||
if (
|
||
BRIEF_TOOL_NAME &&
|
||
toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME))
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// Skip if no messages provided
|
||
if (!messages || messages.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const { turnsSinceLastTodoWrite, turnsSinceLastReminder } =
|
||
getTodoReminderTurnCounts(messages)
|
||
|
||
// Check if we should show a reminder
|
||
if (
|
||
turnsSinceLastTodoWrite >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE &&
|
||
turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
|
||
) {
|
||
const todoKey = toolUseContext.agentId ?? getSessionId()
|
||
const appState = toolUseContext.getAppState()
|
||
const todos = appState.todos[todoKey] ?? []
|
||
return [
|
||
{
|
||
type: 'todo_reminder',
|
||
content: todos,
|
||
itemCount: todos.length,
|
||
},
|
||
]
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
function getTaskReminderTurnCounts(messages: Message[]): {
|
||
turnsSinceLastTaskManagement: number
|
||
turnsSinceLastReminder: number
|
||
} {
|
||
let lastTaskManagementIndex = -1
|
||
let lastReminderIndex = -1
|
||
let assistantTurnsSinceTaskManagement = 0
|
||
let assistantTurnsSinceReminder = 0
|
||
|
||
// Iterate backwards to find most recent events
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
|
||
if (message?.type === 'assistant') {
|
||
if (isThinkingMessage(message)) {
|
||
// Skip thinking messages
|
||
continue
|
||
}
|
||
|
||
// Check for TaskCreate or TaskUpdate usage BEFORE incrementing counter
|
||
if (
|
||
lastTaskManagementIndex === -1 &&
|
||
'message' in message &&
|
||
Array.isArray(message.message?.content) &&
|
||
message.message.content.some(
|
||
block =>
|
||
block.type === 'tool_use' &&
|
||
(block.name === TASK_CREATE_TOOL_NAME ||
|
||
block.name === TASK_UPDATE_TOOL_NAME),
|
||
)
|
||
) {
|
||
lastTaskManagementIndex = i
|
||
}
|
||
|
||
// Count assistant turns before finding events
|
||
if (lastTaskManagementIndex === -1) assistantTurnsSinceTaskManagement++
|
||
if (lastReminderIndex === -1) assistantTurnsSinceReminder++
|
||
} else if (
|
||
lastReminderIndex === -1 &&
|
||
message?.type === 'attachment' &&
|
||
message.attachment.type === 'task_reminder'
|
||
) {
|
||
lastReminderIndex = i
|
||
}
|
||
|
||
if (lastTaskManagementIndex !== -1 && lastReminderIndex !== -1) {
|
||
break
|
||
}
|
||
}
|
||
|
||
return {
|
||
turnsSinceLastTaskManagement: assistantTurnsSinceTaskManagement,
|
||
turnsSinceLastReminder: assistantTurnsSinceReminder,
|
||
}
|
||
}
|
||
|
||
async function getTaskReminderAttachments(
|
||
messages: Message[] | undefined,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
if (!isTodoV2Enabled()) {
|
||
return []
|
||
}
|
||
|
||
// Skip for ant users
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
return []
|
||
}
|
||
|
||
// When SendUserMessage is in the toolkit, it's the primary communication
|
||
// channel and the model is always told to use it (#20467). TaskUpdate
|
||
// becomes a side channel — nudging the model about it conflicts with the
|
||
// brief workflow. The tool itself stays available; this only gates the nag.
|
||
if (
|
||
BRIEF_TOOL_NAME &&
|
||
toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME))
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// Skip if TaskUpdate tool is not available
|
||
if (
|
||
!toolUseContext.options.tools.some(t =>
|
||
toolMatchesName(t, TASK_UPDATE_TOOL_NAME),
|
||
)
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// Skip if no messages provided
|
||
if (!messages || messages.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const { turnsSinceLastTaskManagement, turnsSinceLastReminder } =
|
||
getTaskReminderTurnCounts(messages)
|
||
|
||
// Check if we should show a reminder
|
||
if (
|
||
turnsSinceLastTaskManagement >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE &&
|
||
turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
|
||
) {
|
||
const tasks = await listTasks(getTaskListId())
|
||
return [
|
||
{
|
||
type: 'task_reminder',
|
||
content: tasks,
|
||
itemCount: tasks.length,
|
||
},
|
||
]
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
/**
|
||
* Get attachments for all unified tasks using the Task framework.
|
||
* Replaces the old getBackgroundShellAttachments, getBackgroundRemoteSessionAttachments,
|
||
* and getAsyncAgentAttachments functions.
|
||
*/
|
||
async function getUnifiedTaskAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
const appState = toolUseContext.getAppState()
|
||
const { attachments, updatedTaskOffsets, evictedTaskIds } =
|
||
await generateTaskAttachments(appState)
|
||
|
||
applyTaskOffsetsAndEvictions(
|
||
toolUseContext.setAppState,
|
||
updatedTaskOffsets,
|
||
evictedTaskIds,
|
||
)
|
||
|
||
// Convert TaskAttachment to Attachment format
|
||
return attachments.map(taskAttachment => ({
|
||
type: 'task_status' as const,
|
||
taskId: taskAttachment.taskId,
|
||
taskType: taskAttachment.taskType,
|
||
status: taskAttachment.status,
|
||
description: taskAttachment.description,
|
||
deltaSummary: taskAttachment.deltaSummary,
|
||
outputFilePath: getTaskOutputPath(taskAttachment.taskId),
|
||
}))
|
||
}
|
||
|
||
async function getAsyncHookResponseAttachments(): Promise<Attachment[]> {
|
||
const responses = await checkForAsyncHookResponses()
|
||
|
||
if (responses.length === 0) {
|
||
return []
|
||
}
|
||
|
||
logForDebugging(
|
||
`Hooks: getAsyncHookResponseAttachments found ${responses.length} responses`,
|
||
)
|
||
|
||
const attachments = responses.map(
|
||
({
|
||
processId,
|
||
response,
|
||
hookName,
|
||
hookEvent,
|
||
toolName,
|
||
pluginId,
|
||
stdout,
|
||
stderr,
|
||
exitCode,
|
||
}) => {
|
||
logForDebugging(
|
||
`Hooks: Creating attachment for ${processId} (${hookName}): ${jsonStringify(response)}`,
|
||
)
|
||
return {
|
||
type: 'async_hook_response' as const,
|
||
processId,
|
||
hookName,
|
||
hookEvent,
|
||
toolName,
|
||
response,
|
||
stdout,
|
||
stderr,
|
||
exitCode,
|
||
}
|
||
},
|
||
)
|
||
|
||
// Remove delivered hooks from registry to prevent re-processing
|
||
if (responses.length > 0) {
|
||
const processIds = responses.map(r => r.processId)
|
||
removeDeliveredAsyncHooks(processIds)
|
||
logForDebugging(
|
||
`Hooks: Removed ${processIds.length} delivered hooks from registry`,
|
||
)
|
||
}
|
||
|
||
logForDebugging(
|
||
`Hooks: getAsyncHookResponseAttachments found ${attachments.length} attachments`,
|
||
)
|
||
|
||
return attachments
|
||
}
|
||
|
||
/**
|
||
* Get teammate mailbox attachments for agent swarm communication
|
||
* Teammates are independent Claude Code sessions running in parallel (swarms),
|
||
* not parent-child subagent relationships.
|
||
*
|
||
* This function checks two sources for messages:
|
||
* 1. File-based mailbox (for messages that arrived between polls)
|
||
* 2. AppState.inbox (for messages queued mid-turn by useInboxPoller)
|
||
*
|
||
* Messages from AppState.inbox are delivered mid-turn as attachments,
|
||
* allowing teammates to receive messages without waiting for the turn to end.
|
||
*/
|
||
async function getTeammateMailboxAttachments(
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
if (!isAgentSwarmsEnabled()) {
|
||
return []
|
||
}
|
||
if (process.env.USER_TYPE !== 'ant') {
|
||
return []
|
||
}
|
||
|
||
// Get AppState early to check for team lead status
|
||
const appState = toolUseContext.getAppState()
|
||
|
||
// Use agent name from helper (checks AsyncLocalStorage, then dynamicTeamContext)
|
||
const envAgentName = getAgentName()
|
||
|
||
// Get team name (checks AsyncLocalStorage, dynamicTeamContext, then AppState)
|
||
const teamName = getTeamName(appState.teamContext)
|
||
|
||
// Check if we're the team lead (uses shared logic from swarm utils)
|
||
const teamLeadStatus = isTeamLead(appState.teamContext)
|
||
|
||
// Check if viewing a teammate's transcript (for in-process teammates)
|
||
const viewedTeammate = getViewedTeammateTask(appState)
|
||
|
||
// Resolve agent name based on who we're VIEWING:
|
||
// - If viewing a teammate, use THEIR name (to read from their mailbox)
|
||
// - Otherwise use env var if set, or leader's name if we're the team lead
|
||
let agentName = viewedTeammate?.identity.agentName ?? envAgentName
|
||
if (!agentName && teamLeadStatus && appState.teamContext) {
|
||
const leadAgentId = appState.teamContext.leadAgentId
|
||
// Look up the lead's name from agents map (not the UUID)
|
||
agentName = appState.teamContext.teammates[leadAgentId]?.name || 'team-lead'
|
||
}
|
||
|
||
logForDebugging(
|
||
`[SwarmMailbox] getTeammateMailboxAttachments called: envAgentName=${envAgentName}, isTeamLead=${teamLeadStatus}, resolved agentName=${agentName}, teamName=${teamName}`,
|
||
)
|
||
|
||
// Only check inbox if running as an agent in a swarm or team lead
|
||
if (!agentName) {
|
||
logForDebugging(
|
||
`[SwarmMailbox] Not checking inbox - not in a swarm or team lead`,
|
||
)
|
||
return []
|
||
}
|
||
|
||
logForDebugging(
|
||
`[SwarmMailbox] Checking inbox for agent="${agentName}" team="${teamName || 'default'}"`,
|
||
)
|
||
|
||
// Check mailbox for unread messages (routes to in-process or file-based)
|
||
// Filter out structured protocol messages (permission requests/responses, shutdown
|
||
// messages, etc.) — these must be left unread for useInboxPoller to route to their
|
||
// proper handlers (workerPermissions queue, sandbox queue, etc.). Without filtering,
|
||
// attachment generation races with InboxPoller: whichever reads first marks all
|
||
// messages as read, and if attachments wins, protocol messages get bundled as raw
|
||
// LLM context text instead of being routed to their UI handlers.
|
||
const allUnreadMessages = await readUnreadMessages(agentName, teamName)
|
||
const unreadMessages = allUnreadMessages.filter(
|
||
m => !isStructuredProtocolMessage(m.text),
|
||
)
|
||
logForDebugging(
|
||
`[MailboxBridge] Found ${allUnreadMessages.length} unread message(s) for "${agentName}" (${allUnreadMessages.length - unreadMessages.length} structured protocol messages filtered out)`,
|
||
)
|
||
|
||
// Also check AppState.inbox for pending messages (queued mid-turn by useInboxPoller)
|
||
// IMPORTANT: appState.inbox contains messages FROM teammates TO the leader.
|
||
// Only show these when viewing the leader's transcript (not a teammate's).
|
||
// When viewing a teammate, their messages come from the file-based mailbox above.
|
||
// In-process teammates share AppState with the leader — appState.inbox contains
|
||
// the LEADER's queued messages, not the teammate's. Skip it to prevent leakage
|
||
// (including self-echo from broadcasts). Teammates receive messages exclusively
|
||
// through their file-based mailbox + waitForNextPromptOrShutdown.
|
||
// Note: viewedTeammate was already computed above for agentName resolution
|
||
const pendingInboxMessages =
|
||
viewedTeammate || isInProcessTeammate()
|
||
? [] // Viewing teammate or running as in-process teammate - don't show leader's inbox
|
||
: appState.inbox.messages.filter(m => m.status === 'pending')
|
||
logForDebugging(
|
||
`[SwarmMailbox] Found ${pendingInboxMessages.length} pending message(s) in AppState.inbox`,
|
||
)
|
||
|
||
// Combine both sources of messages WITH DEDUPLICATION
|
||
// The same message could exist in both file mailbox and AppState.inbox due to race conditions:
|
||
// 1. getTeammateMailboxAttachments reads file -> finds message M
|
||
// 2. InboxPoller reads same file -> queues M in AppState.inbox
|
||
// 3. getTeammateMailboxAttachments reads AppState -> finds M again
|
||
// We deduplicate using from+timestamp+text prefix as the key
|
||
const seen = new Set<string>()
|
||
let allMessages: Array<{
|
||
from: string
|
||
text: string
|
||
timestamp: string
|
||
color?: string
|
||
summary?: string
|
||
}> = []
|
||
|
||
for (const m of [...unreadMessages, ...pendingInboxMessages]) {
|
||
const key = `${m.from}|${m.timestamp}|${m.text.slice(0, 100)}`
|
||
if (!seen.has(key)) {
|
||
seen.add(key)
|
||
allMessages.push({
|
||
from: m.from,
|
||
text: m.text,
|
||
timestamp: m.timestamp,
|
||
color: m.color,
|
||
summary: m.summary,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Collapse multiple idle notifications per agent — keep only the latest.
|
||
// Single pass to parse, then filter without re-parsing.
|
||
const idleAgentByIndex = new Map<number, string>()
|
||
const latestIdleByAgent = new Map<string, number>()
|
||
for (let i = 0; i < allMessages.length; i++) {
|
||
const idle = isIdleNotification(allMessages[i]!.text)
|
||
if (idle) {
|
||
idleAgentByIndex.set(i, idle.from)
|
||
latestIdleByAgent.set(idle.from, i)
|
||
}
|
||
}
|
||
if (idleAgentByIndex.size > latestIdleByAgent.size) {
|
||
const beforeCount = allMessages.length
|
||
allMessages = allMessages.filter((_m, i) => {
|
||
const agent = idleAgentByIndex.get(i)
|
||
if (agent === undefined) return true
|
||
return latestIdleByAgent.get(agent) === i
|
||
})
|
||
logForDebugging(
|
||
`[SwarmMailbox] Collapsed ${beforeCount - allMessages.length} duplicate idle notification(s)`,
|
||
)
|
||
}
|
||
|
||
if (allMessages.length === 0) {
|
||
logForDebugging(`[SwarmMailbox] No messages to deliver, returning empty`)
|
||
return []
|
||
}
|
||
|
||
logForDebugging(
|
||
`[SwarmMailbox] Returning ${allMessages.length} message(s) as attachment for "${agentName}" (${unreadMessages.length} from file, ${pendingInboxMessages.length} from AppState, after dedup)`,
|
||
)
|
||
|
||
// Build the attachment BEFORE marking messages as processed
|
||
// This prevents message loss if any operation below fails
|
||
const attachment: Attachment[] = [
|
||
{
|
||
type: 'teammate_mailbox',
|
||
messages: allMessages,
|
||
},
|
||
]
|
||
|
||
// Mark only non-structured mailbox messages as read after attachment is built.
|
||
// Structured protocol messages stay unread for useInboxPoller to handle.
|
||
if (unreadMessages.length > 0) {
|
||
await markMessagesAsReadByPredicate(
|
||
agentName,
|
||
m => !isStructuredProtocolMessage(m.text),
|
||
teamName,
|
||
)
|
||
logForDebugging(
|
||
`[MailboxBridge] marked ${unreadMessages.length} non-structured message(s) as read for agent="${agentName}" team="${teamName || 'default'}"`,
|
||
)
|
||
}
|
||
|
||
// Process shutdown_approved messages - remove teammates from team file
|
||
// This mirrors what useInboxPoller does in interactive mode (lines 546-606)
|
||
// In -p mode, useInboxPoller doesn't run, so we must handle this here
|
||
if (teamLeadStatus && teamName) {
|
||
for (const m of allMessages) {
|
||
const shutdownApproval = isShutdownApproved(m.text)
|
||
if (shutdownApproval) {
|
||
const teammateToRemove = shutdownApproval.from
|
||
logForDebugging(
|
||
`[SwarmMailbox] Processing shutdown_approved from ${teammateToRemove}`,
|
||
)
|
||
|
||
// Find the teammate ID by name
|
||
const teammateId = appState.teamContext?.teammates
|
||
? Object.entries(appState.teamContext.teammates).find(
|
||
([, t]) => t.name === teammateToRemove,
|
||
)?.[0]
|
||
: undefined
|
||
|
||
if (teammateId) {
|
||
// Remove from team file
|
||
removeTeammateFromTeamFile(teamName, {
|
||
agentId: teammateId,
|
||
name: teammateToRemove,
|
||
})
|
||
logForDebugging(
|
||
`[SwarmMailbox] Removed ${teammateToRemove} from team file`,
|
||
)
|
||
|
||
// Unassign tasks owned by this teammate
|
||
await unassignTeammateTasks(
|
||
teamName,
|
||
teammateId,
|
||
teammateToRemove,
|
||
'shutdown',
|
||
)
|
||
|
||
// Remove from teamContext in AppState
|
||
toolUseContext.setAppState(prev => {
|
||
if (!prev.teamContext?.teammates) return prev
|
||
if (!(teammateId in prev.teamContext.teammates)) return prev
|
||
const { [teammateId]: _, ...remainingTeammates } =
|
||
prev.teamContext.teammates
|
||
return {
|
||
...prev,
|
||
teamContext: {
|
||
...prev.teamContext,
|
||
teammates: remainingTeammates,
|
||
},
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark AppState inbox messages as processed LAST, after attachment is built
|
||
// This ensures messages aren't lost if earlier operations fail
|
||
if (pendingInboxMessages.length > 0) {
|
||
const pendingIds = new Set(pendingInboxMessages.map(m => m.id))
|
||
toolUseContext.setAppState(prev => ({
|
||
...prev,
|
||
inbox: {
|
||
messages: prev.inbox.messages.map(m =>
|
||
pendingIds.has(m.id) ? { ...m, status: 'processed' as const } : m,
|
||
),
|
||
},
|
||
}))
|
||
}
|
||
|
||
return attachment
|
||
}
|
||
|
||
/**
|
||
* Get team context attachment for teammates in a swarm.
|
||
* Only injected on the first turn to provide team coordination instructions.
|
||
*/
|
||
function getTeamContextAttachment(messages: Message[]): Attachment[] {
|
||
const teamName = getTeamName()
|
||
const agentId = getAgentId()
|
||
const agentName = getAgentName()
|
||
|
||
// Only inject for teammates (not team lead or non-team sessions)
|
||
if (!teamName || !agentId) {
|
||
return []
|
||
}
|
||
|
||
// Only inject on first turn - check if there are no assistant messages yet
|
||
const hasAssistantMessage = messages.some(m => m.type === 'assistant')
|
||
if (hasAssistantMessage) {
|
||
return []
|
||
}
|
||
|
||
const configDir = getClaudeConfigHomeDir()
|
||
const teamConfigPath = `${configDir}/teams/${teamName}/config.json`
|
||
const taskListPath = `${configDir}/tasks/${teamName}/`
|
||
|
||
return [
|
||
{
|
||
type: 'team_context',
|
||
agentId,
|
||
agentName: agentName || agentId,
|
||
teamName,
|
||
teamConfigPath,
|
||
taskListPath,
|
||
},
|
||
]
|
||
}
|
||
|
||
function getTokenUsageAttachment(
|
||
messages: Message[],
|
||
model: string,
|
||
): Attachment[] {
|
||
if (!isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT)) {
|
||
return []
|
||
}
|
||
|
||
const contextWindow = getEffectiveContextWindowSize(model)
|
||
const usedTokens = tokenCountFromLastAPIResponse(messages)
|
||
|
||
return [
|
||
{
|
||
type: 'token_usage',
|
||
used: usedTokens,
|
||
total: contextWindow,
|
||
remaining: contextWindow - usedTokens,
|
||
},
|
||
]
|
||
}
|
||
|
||
function getOutputTokenUsageAttachment(): Attachment[] {
|
||
if (feature('TOKEN_BUDGET')) {
|
||
const budget = getCurrentTurnTokenBudget()
|
||
if (budget === null || budget <= 0) {
|
||
return []
|
||
}
|
||
return [
|
||
{
|
||
type: 'output_token_usage',
|
||
turn: getTurnOutputTokens(),
|
||
session: getTotalOutputTokens(),
|
||
budget,
|
||
},
|
||
]
|
||
}
|
||
return []
|
||
}
|
||
|
||
function getMaxBudgetUsdAttachment(maxBudgetUsd?: number): Attachment[] {
|
||
if (maxBudgetUsd === undefined) {
|
||
return []
|
||
}
|
||
|
||
const usedCost = getTotalCostUSD()
|
||
const remainingBudget = maxBudgetUsd - usedCost
|
||
|
||
return [
|
||
{
|
||
type: 'budget_usd',
|
||
used: usedCost,
|
||
total: maxBudgetUsd,
|
||
remaining: remainingBudget,
|
||
},
|
||
]
|
||
}
|
||
|
||
/**
|
||
* Count human turns since plan mode exit (plan_mode_exit attachment).
|
||
* Returns 0 if no plan_mode_exit attachment found.
|
||
*
|
||
* tool_result messages are type:'user' without isMeta, so filter by
|
||
* toolUseResult to avoid counting them — otherwise the 10-turn reminder
|
||
* interval fires every ~10 tool calls instead of ~10 human turns.
|
||
*/
|
||
export function getVerifyPlanReminderTurnCount(messages: Message[]): number {
|
||
let turnCount = 0
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
if (message && isHumanTurn(message)) {
|
||
turnCount++
|
||
}
|
||
// Stop counting at plan_mode_exit attachment (marks when implementation started)
|
||
if (
|
||
message?.type === 'attachment' &&
|
||
message.attachment.type === 'plan_mode_exit'
|
||
) {
|
||
return turnCount
|
||
}
|
||
}
|
||
// No plan_mode_exit found
|
||
return 0
|
||
}
|
||
|
||
/**
|
||
* Get verify plan reminder attachment if the model hasn't called VerifyPlanExecution yet.
|
||
*/
|
||
async function getVerifyPlanReminderAttachment(
|
||
messages: Message[] | undefined,
|
||
toolUseContext: ToolUseContext,
|
||
): Promise<Attachment[]> {
|
||
if (
|
||
process.env.USER_TYPE !== 'ant' ||
|
||
!isEnvTruthy(process.env.CLAUDE_CODE_VERIFY_PLAN)
|
||
) {
|
||
return []
|
||
}
|
||
|
||
const appState = toolUseContext.getAppState()
|
||
const pending = appState.pendingPlanVerification
|
||
|
||
// Only remind if plan exists and verification not started or completed
|
||
if (
|
||
!pending ||
|
||
pending.verificationStarted ||
|
||
pending.verificationCompleted
|
||
) {
|
||
return []
|
||
}
|
||
|
||
// Only remind every N turns
|
||
if (messages && messages.length > 0) {
|
||
const turnCount = getVerifyPlanReminderTurnCount(messages)
|
||
if (
|
||
turnCount === 0 ||
|
||
turnCount % VERIFY_PLAN_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS !== 0
|
||
) {
|
||
return []
|
||
}
|
||
}
|
||
|
||
return [{ type: 'verify_plan_reminder' }]
|
||
}
|
||
|
||
export function getCompactionReminderAttachment(
|
||
messages: Message[],
|
||
model: string,
|
||
): Attachment[] {
|
||
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_fox', false)) {
|
||
return []
|
||
}
|
||
|
||
if (!isAutoCompactEnabled()) {
|
||
return []
|
||
}
|
||
|
||
const contextWindow = getContextWindowForModel(model, getSdkBetas())
|
||
if (contextWindow < 1_000_000) {
|
||
return []
|
||
}
|
||
|
||
const effectiveWindow = getEffectiveContextWindowSize(model)
|
||
const usedTokens = tokenCountWithEstimation(messages)
|
||
if (usedTokens < effectiveWindow * 0.25) {
|
||
return []
|
||
}
|
||
|
||
return [{ type: 'compaction_reminder' }]
|
||
}
|
||
|
||
/**
|
||
* Context-efficiency nudge. Injected after every N tokens of growth without
|
||
* a snip. Pacing is handled entirely by shouldNudgeForSnips — the 10k
|
||
* interval resets on prior nudges, snip markers, snip boundaries, and
|
||
* compact boundaries.
|
||
*/
|
||
export function getContextEfficiencyAttachment(
|
||
messages: Message[],
|
||
): Attachment[] {
|
||
if (!feature('HISTORY_SNIP')) {
|
||
return []
|
||
}
|
||
// Gate must match SnipTool.isEnabled() — don't nudge toward a tool that
|
||
// isn't in the tool list. Lazy require keeps this file snip-string-free.
|
||
const { isSnipRuntimeEnabled, shouldNudgeForSnips } =
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js')
|
||
if (!isSnipRuntimeEnabled()) {
|
||
return []
|
||
}
|
||
|
||
if (!shouldNudgeForSnips(messages)) {
|
||
return []
|
||
}
|
||
|
||
return [{ type: 'context_efficiency' }]
|
||
}
|
||
|
||
|
||
function isFileReadDenied(
|
||
filePath: string,
|
||
toolPermissionContext: ToolPermissionContext,
|
||
): boolean {
|
||
const denyRule = matchingRuleForInput(
|
||
filePath,
|
||
toolPermissionContext,
|
||
'read',
|
||
'deny',
|
||
)
|
||
return denyRule !== null
|
||
}
|