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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 8 additions & 188 deletions src/backends/claude-code/contextFiles.ts
Original file line number Diff line number Diff line change
@@ -1,191 +1,11 @@
/**
* Context file offloading for Claude Code backend.
*
* When context injections are too large to embed inline in the prompt,
* this module writes them to files and generates instructions for Claude
* to read them on-demand using its built-in Read tool.
* Re-export shim — implementation moved to shared module.
* Kept for backward compatibility.
*/
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export {
buildInlineContextSection,
cleanupContextFiles,
offloadLargeContext,
} from '../shared/contextFiles.js';

import { CONTEXT_OFFLOAD_CONFIG } from '../../config/claudeCodeConfig.js';
import { estimateTokens } from '../../config/reviewConfig.js';
import { logger } from '../../utils/logging.js';
import type { ContextInjection } from '../types.js';

/**
* Metadata about an offloaded context file.
*/
export interface OffloadedFile {
/** Relative path from repo root, e.g. '.cascade/context/pr-diff.txt' */
relativePath: string;
/** Original description of this context */
description: string;
/** Estimated token count of the content */
tokens: number;
}

/**
* Result of context offloading.
*/
export interface ContextOffloadResult {
/** Context injections small enough to embed inline */
inlineInjections: ContextInjection[];
/** Files that were written for large context */
offloadedFiles: OffloadedFile[];
/** Instructions for Claude to read the offloaded files */
instructions: string;
}

/**
* Convert a description string into a safe filename.
* Includes index suffix to guarantee uniqueness within a batch.
*/
function slugify(description: string, index: number): string {
const base = description
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40); // Shorter to make room for index

// Always append index for guaranteed uniqueness within this batch
return `${base || 'context'}-${index}`;
}

/**
* Generate instructions for Claude to read offloaded context files.
*/
function generateReadInstructions(files: OffloadedFile[]): string {
if (files.length === 0) return '';

const lines = [
'## Context Files',
'',
'The following context has been saved to files to avoid exceeding prompt limits.',
'Use the Read tool to access them as needed:',
'',
];

for (const file of files) {
lines.push(
`- \`${file.relativePath}\` — ${file.description} (~${file.tokens.toLocaleString()} tokens)`,
);
}

lines.push('');
lines.push('Read these files as needed for your task. For review tasks, start with the PR diff.');

return lines.join('\n');
}

/**
* Offload large context injections to files.
*
* Small context (below threshold) is kept inline.
* Large context is written to .cascade/context/ and Claude is instructed to read it.
*
* @param repoDir - Repository directory where context files will be written
* @param injections - Context injections to process
* @returns Result with inline context, offloaded files, and instructions
*/
export async function offloadLargeContext(
repoDir: string,
injections: ContextInjection[],
): Promise<ContextOffloadResult> {
if (!CONTEXT_OFFLOAD_CONFIG.enabled) {
return {
inlineInjections: injections,
offloadedFiles: [],
instructions: '',
};
}

const inlineInjections: ContextInjection[] = [];
const offloadedFiles: OffloadedFile[] = [];
const contextDir = join(repoDir, CONTEXT_OFFLOAD_CONFIG.contextDir);
let dirCreated = false;

for (let i = 0; i < injections.length; i++) {
const injection = injections[i];
const tokens = estimateTokens(injection.result);

if (tokens < CONTEXT_OFFLOAD_CONFIG.inlineThreshold) {
inlineInjections.push(injection);
} else {
// Create context directory on first offload
if (!dirCreated) {
await mkdir(contextDir, { recursive: true });
dirCreated = true;
}

// Generate unique filename from description (with index for uniqueness)
const slug = slugify(injection.description, i);
const filename = `${slug}.txt`;
const filepath = join(contextDir, filename);
// Use forward slashes for consistent paths in instructions (works on all platforms)
const relativePath = `${CONTEXT_OFFLOAD_CONFIG.contextDir}/${filename}`;

await writeFile(filepath, injection.result, 'utf-8');

offloadedFiles.push({
relativePath,
description: injection.description,
tokens,
});

logger.info('Context offloaded to file', {
description: injection.description,
tokens,
path: relativePath,
});
}
}

const instructions = generateReadInstructions(offloadedFiles);

if (offloadedFiles.length > 0) {
logger.info('Context offload summary', {
inlineCount: inlineInjections.length,
offloadedCount: offloadedFiles.length,
totalOffloadedTokens: offloadedFiles.reduce((sum, f) => sum + f.tokens, 0),
});
}

return {
inlineInjections,
offloadedFiles,
instructions,
};
}

/**
* Clean up context files after agent execution.
*
* Removes the .cascade/context/ directory and all its contents.
*
* @param repoDir - Repository directory
*/
export async function cleanupContextFiles(repoDir: string): Promise<void> {
const contextDir = join(repoDir, CONTEXT_OFFLOAD_CONFIG.contextDir);
try {
await rm(contextDir, { recursive: true, force: true });
logger.debug('Cleaned up context files', { contextDir });
} catch {
// Ignore errors (directory might not exist)
}
}

/**
* Build the inline context section for the prompt.
*/
export function buildInlineContextSection(injections: ContextInjection[]): string {
if (injections.length === 0) return '';

let section = '\n\n## Pre-loaded Context\n';
for (const injection of injections) {
section += `\n### ${injection.description} (${injection.toolName})\n`;
section += `Parameters: ${JSON.stringify(injection.params)}\n`;
section += `\`\`\`\n${injection.result}\n\`\`\`\n`;
}
return section;
}
export type { ContextOffloadResult, OffloadedFile } from '../shared/contextFiles.js';
4 changes: 2 additions & 2 deletions src/backends/contextFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export {
buildInlineContextSection,
cleanupContextFiles,
offloadLargeContext,
} from './claude-code/contextFiles.js';
} from './shared/contextFiles.js';

export type { ContextOffloadResult, OffloadedFile } from './claude-code/contextFiles.js';
export type { ContextOffloadResult, OffloadedFile } from './shared/contextFiles.js';
120 changes: 8 additions & 112 deletions src/backends/nativeTools.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,11 @@
import { buildInlineContextSection, offloadLargeContext } from './contextFiles.js';
import type { ContextInjection, ToolManifest } from './types.js';

const NATIVE_TOOL_EXECUTION_RULES = `## Native Tool Execution Rules

You are operating in a native-tool environment, not a gadget/function-call environment.

- Never write pseudo tool calls such as \`[tool_call: ...]\`, \`ReadFile(...)\`, \`RipGrep(...)\`, \`Tmux(...)\`, \`CreatePR(...)\`, or similar function-call text in your assistant response.
- Use actual OpenCode/Codex tool invocations instead:
- use built-in file/search tools or the shell tool for repository exploration
- use the edit tool for file modifications
- use the shell tool for all \`cascade-tools ...\`, \`git ...\`, \`rg ...\`, \`fd ...\`, test, lint, and build commands
- When the task instructions mention gadget names like \`CreatePR\`, \`PostComment\`, \`UpdateChecklistItem\`, \`Finish\`, \`ReadWorkItem\`, \`TodoUpsert\`, or \`TodoUpdateStatus\`, treat that as a request to run the equivalent real command or tool action, not to print the gadget name.
- If you catch yourself composing a pseudo tool call in plain text, stop and use the real tool instead.`;

/**
* Format a single CLI parameter for tool guidance documentation.
*/
function formatParam(
key: string,
schema: { type: string; required?: boolean; default?: unknown; description?: string },
): string {
let result: string;
if (schema.type === 'array') {
const singular = key.replace(/s$/, '');
result = schema.required
? ` --${singular} <string> (repeatable)`
: ` [--${singular} <string> (repeatable)]`;
} else if (schema.type === 'boolean') {
result = schema.default === true ? ` [--no-${key}]` : ` [--${key}]`;
} else {
result = schema.required ? ` --${key} <${schema.type}>` : ` [--${key} <${schema.type}>]`;
}
if (schema.description) {
result += ` # ${schema.description}`;
}
return result;
}

/**
* Build prompt guidance for CASCADE-specific CLI tools.
* Native-tool engines invoke these via shell commands.
* Re-export shim — implementation moved to shared module.
* Kept for backward compatibility.
*/
export function buildToolGuidance(tools: ToolManifest[]): string {
if (tools.length === 0) return '';

let guidance = '## CASCADE Tools\n\n';
guidance += 'Use the shell tool to invoke these CASCADE-specific commands.\n';
guidance += 'All commands output JSON. Parse the output to extract results.\n\n';
guidance +=
'**CRITICAL**: You MUST use these cascade-tools commands for all PM (Trello/JIRA), SCM (GitHub), and session operations. ' +
'Do NOT use `gh` CLI or other tools directly — native-tool engine runs block `gh`, and cascade-tools handle authentication, push, and ' +
'state tracking that raw CLI tools do not. For example, `cascade-tools scm create-pr` pushes ' +
'the branch AND creates the PR atomically.\n\n';

for (const tool of tools) {
guidance += `### ${tool.name}\n`;
guidance += `${tool.description}\n`;
guidance += `\`\`\`bash\n${tool.cliCommand}`;

for (const [key, schema] of Object.entries(tool.parameters)) {
guidance += formatParam(key, schema as { type: string; required?: boolean });
}

guidance += '\n```\n\n';
}

return guidance;
}
export {
buildSystemPrompt,
buildTaskPrompt,
buildToolGuidance,
} from './shared/nativeToolPrompts.js';

export interface BuildTaskPromptResult {
prompt: string;
hasOffloadedContext: boolean;
}

/**
* Build the task prompt with pre-fetched context injections.
* Large context is offloaded to files that the engine can read on demand.
*/
export async function buildTaskPrompt(
taskPrompt: string,
contextInjections: ContextInjection[],
repoDir: string,
): Promise<BuildTaskPromptResult> {
let prompt = taskPrompt;

if (contextInjections.length === 0) {
return { prompt, hasOffloadedContext: false };
}

const { inlineInjections, offloadedFiles, instructions } = await offloadLargeContext(
repoDir,
contextInjections,
);

prompt += buildInlineContextSection(inlineInjections);

if (instructions) {
prompt += `\n\n${instructions}`;
}

return {
prompt,
hasOffloadedContext: offloadedFiles.length > 0,
};
}

/**
* Build the system prompt by combining CASCADE's agent prompt with tool guidance.
*/
export function buildSystemPrompt(systemPrompt: string, tools: ToolManifest[]): string {
const toolGuidance = buildToolGuidance(tools);
const promptWithRules = `${NATIVE_TOOL_EXECUTION_RULES}\n\n${systemPrompt}`;
return toolGuidance ? `${promptWithRules}\n\n${toolGuidance}` : promptWithRules;
}
export type { BuildTaskPromptResult } from './shared/nativeToolPrompts.js';
Loading
Loading