Skip to content

Commit a833cb2

Browse files
committed
refactor: unify instruction file reading with FileReader abstraction
- Created FileReader interface to eliminate duplication between fs and Runtime - Moved all instruction reading logic to instructionFiles.ts - Removed 117 lines from systemMessage.ts (-49%) - Simplified helper function structure with shared implementation - Removed redundant tests for private helpers (covered by public API) Net change: -113 lines across 3 files (-28%) All tests passing (16/16)
1 parent 77ae246 commit a833cb2

File tree

3 files changed

+104
-217
lines changed

3 files changed

+104
-217
lines changed

src/services/systemMessage.ts

Lines changed: 36 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as os from "os";
22
import * as path from "path";
33
import type { WorkspaceMetadata } from "@/types/workspace";
4-
import { readInstructionSet, INSTRUCTION_FILE_NAMES } from "@/utils/main/instructionFiles";
4+
import { readInstructionSet, readInstructionSetFromRuntime } from "@/utils/main/instructionFiles";
55
import { extractModeSection } from "@/utils/main/markdown";
66
import type { Runtime } from "@/runtime/Runtime";
7-
import { readFileString } from "@/utils/runtime/helpers";
87

98
// NOTE: keep this in sync with the docs/models.md file
109

@@ -30,6 +29,9 @@ Use GitHub-style \`<details>/<summary>\` tags to create collapsible sections for
3029
</prelude>
3130
`;
3231

32+
/**
33+
* Build environment context XML block describing the workspace.
34+
*/
3335
function buildEnvironmentContext(workspacePath: string): string {
3436
return `
3537
<environment>
@@ -44,121 +46,30 @@ You are in a git worktree at ${workspacePath}
4446
}
4547

4648
/**
47-
* The system directory where global cmux configuration lives.
48-
* This is where users can place global AGENTS.md and .cmux/PLAN.md files
49-
* that apply to all workspaces.
49+
* Get the system directory where global cmux configuration lives.
50+
* Users can place global AGENTS.md and .cmux/PLAN.md files here.
5051
*/
5152
function getSystemDirectory(): string {
5253
return path.join(os.homedir(), ".cmux");
5354
}
5455

5556
/**
56-
* Read the first available file from a list using runtime.
57+
* Builds a system message for the AI model by combining instruction sources.
5758
*
58-
* @param runtime - Runtime instance (may be local or SSH)
59-
* @param directory - Directory to search in
60-
* @param filenames - List of filenames to try, in priority order
61-
* @returns Content of the first file found, or null if none exist
62-
*/
63-
async function readFirstAvailableFileFromRuntime(
64-
runtime: Runtime,
65-
directory: string,
66-
filenames: readonly string[]
67-
): Promise<string | null> {
68-
for (const filename of filenames) {
69-
try {
70-
const filePath = path.join(directory, filename);
71-
return await readFileString(runtime, filePath);
72-
} catch {
73-
// File doesn't exist or can't be read, try next
74-
continue;
75-
}
76-
}
77-
return null;
78-
}
79-
80-
/**
81-
* Read a file with optional local variant using runtime.
82-
* Follows the same pattern as readFileWithLocalVariant but uses Runtime.
59+
* Instruction layers:
60+
* 1. Global: ~/.cmux/AGENTS.md (always included)
61+
* 2. Context: workspace/AGENTS.md OR project/AGENTS.md (workspace takes precedence)
62+
* 3. Mode: Extracts "Mode: <mode>" section from context then global (if mode provided)
8363
*
84-
* @param runtime - Runtime instance (may be local or SSH)
85-
* @param directory - Directory to search
86-
* @param baseFilenames - Base filenames to try in priority order
87-
* @param localFilename - Optional local filename to append if present
88-
* @returns Combined content or null if no base file exists
89-
*/
90-
async function readFileWithLocalVariantFromRuntime(
91-
runtime: Runtime,
92-
directory: string,
93-
baseFilenames: readonly string[],
94-
localFilename?: string
95-
): Promise<string | null> {
96-
const baseContent = await readFirstAvailableFileFromRuntime(runtime, directory, baseFilenames);
97-
98-
if (!baseContent) {
99-
return null;
100-
}
101-
102-
if (!localFilename) {
103-
return baseContent;
104-
}
105-
106-
try {
107-
const localFilePath = path.join(directory, localFilename);
108-
const localContent = await readFileString(runtime, localFilePath);
109-
return `${baseContent}\n\n${localContent}`;
110-
} catch {
111-
return baseContent;
112-
}
113-
}
114-
115-
/**
116-
* Read instruction set from a workspace using the runtime abstraction.
117-
* This supports both local workspaces and remote SSH workspaces.
64+
* File search order: AGENTS.md → AGENT.md → CLAUDE.md
65+
* Local variants: AGENTS.local.md appended if found (for .gitignored personal preferences)
11866
*
119-
* @param runtime - Runtime instance (may be local or SSH)
120-
* @param workspacePath - Path to workspace directory
121-
* @returns Combined instruction content, or null if no base file exists
122-
*/
123-
async function readInstructionSetFromRuntime(
124-
runtime: Runtime,
125-
workspacePath: string
126-
): Promise<string | null> {
127-
const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md";
128-
return readFileWithLocalVariantFromRuntime(
129-
runtime,
130-
workspacePath,
131-
INSTRUCTION_FILE_NAMES,
132-
LOCAL_INSTRUCTION_FILENAME
133-
);
134-
}
135-
136-
/**
137-
* Builds a system message for the AI model by combining multiple instruction sources.
138-
*
139-
* Instruction sources are layered as follows:
140-
* 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md) - always included
141-
* 2. Context instructions: EITHER workspace OR project AGENTS.md (not both)
142-
* - Workspace: <workspacePath>/AGENTS.md (+ AGENTS.local.md) - if exists (read via runtime)
143-
* - Project: <projectPath>/AGENTS.md (+ AGENTS.local.md) - fallback if workspace doesn't exist
144-
* 3. Mode-specific context (if mode provided): Extract a section titled "Mode: <mode>"
145-
* (case-insensitive) from the instruction file. Priority: context instructions, then global.
146-
*
147-
* Each instruction file location is searched for in priority order:
148-
* - AGENTS.md
149-
* - AGENT.md
150-
* - CLAUDE.md
151-
*
152-
* If a base instruction file is found, its corresponding .local.md variant is also
153-
* checked and appended when building the instruction set (useful for personal preferences not committed to git).
154-
*
155-
* @param metadata - Workspace metadata (contains projectPath for reading AGENTS.md)
156-
* @param runtime - Runtime instance for reading workspace files (may be remote)
157-
* @param workspacePath - Absolute path to the workspace directory (for environment context)
158-
* @param mode - Optional mode name (e.g., "plan", "exec") - looks for {MODE}.md files if provided
159-
* @param additionalSystemInstructions - Optional additional system instructions to append at the end
160-
* @returns System message string with all instruction sources combined
161-
* @throws Error if metadata is invalid
67+
* @param metadata - Workspace metadata (contains projectPath)
68+
* @param runtime - Runtime for reading workspace files (supports SSH)
69+
* @param workspacePath - Workspace directory path
70+
* @param mode - Optional mode name (e.g., "plan", "exec")
71+
* @param additionalSystemInstructions - Optional instructions appended last
72+
* @throws Error if metadata or workspacePath invalid
16273
*/
16374
export async function buildSystemMessage(
16475
metadata: WorkspaceMetadata,
@@ -167,67 +78,40 @@ export async function buildSystemMessage(
16778
mode?: string,
16879
additionalSystemInstructions?: string
16980
): Promise<string> {
170-
// Validate inputs
171-
if (!metadata) {
172-
throw new Error("Invalid workspace metadata: metadata is required");
173-
}
174-
if (!workspacePath) {
175-
throw new Error("Invalid workspace path: workspacePath is required");
176-
}
81+
if (!metadata) throw new Error("Invalid workspace metadata: metadata is required");
82+
if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required");
17783

178-
const systemDir = getSystemDirectory();
179-
const projectDir = metadata.projectPath;
84+
// Read instruction sets
85+
const globalInstructions = await readInstructionSet(getSystemDirectory());
86+
const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath);
87+
const contextInstructions = workspaceInstructions ?? (await readInstructionSet(metadata.projectPath));
18088

181-
// Layer 1: Global instructions (always included)
182-
const globalInstructions = await readInstructionSet(systemDir);
89+
// Combine: global + context (workspace takes precedence over project)
90+
const customInstructions = [globalInstructions, contextInstructions]
91+
.filter(Boolean)
92+
.join("\n\n");
18393

184-
// Layer 2: Workspace OR Project instructions (not both)
185-
// Try workspace first (via runtime, may be remote for SSH)
186-
// Fall back to project if workspace doesn't have AGENTS.md
187-
const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath);
188-
const projectInstructions = workspaceInstructions ? null : await readInstructionSet(projectDir);
189-
190-
// Combine instruction sources
191-
// Result: global + (workspace OR project)
192-
const instructionSegments = [
193-
globalInstructions,
194-
workspaceInstructions ?? projectInstructions,
195-
].filter(Boolean);
196-
const customInstructions = instructionSegments.join("\n\n");
197-
198-
// Look for a "Mode: <mode>" section inside instruction sets
199-
// Priority: workspace (or project fallback), then global
200-
// We only check the workspace OR project instructions, not both
201-
// This behavior is documented in docs/instruction-files.md - keep both in sync when changing.
94+
// Extract mode-specific section (context first, then global fallback)
20295
let modeContent: string | null = null;
20396
if (mode) {
204-
const contextInstructions = workspaceInstructions ?? projectInstructions;
205-
if (contextInstructions) {
206-
modeContent = extractModeSection(contextInstructions, mode);
207-
}
208-
if (!modeContent && globalInstructions) {
209-
modeContent = extractModeSection(globalInstructions, mode);
210-
}
97+
modeContent =
98+
(contextInstructions && extractModeSection(contextInstructions, mode)) ||
99+
(globalInstructions && extractModeSection(globalInstructions, mode)) ||
100+
null;
211101
}
212102

213-
// Build the final system message
214-
// Use workspacePath for environment context (where code actually executes)
215-
const environmentContext = buildEnvironmentContext(workspacePath);
216-
const trimmedPrelude = PRELUDE.trim();
217-
let systemMessage = `${trimmedPrelude}\n\n${environmentContext}`;
103+
// Build system message
104+
let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath)}`;
218105

219-
// Add custom instructions if found
220106
if (customInstructions) {
221107
systemMessage += `\n<custom-instructions>\n${customInstructions}\n</custom-instructions>`;
222108
}
223109

224-
// Add mode-specific content if found
225110
if (modeContent) {
226111
const tag = (mode ?? "mode").toLowerCase().replace(/[^a-z0-9_-]/gi, "-");
227112
systemMessage += `\n\n<${tag}>\n${modeContent}\n</${tag}>`;
228113
}
229114

230-
// Add additional system instructions at the end (highest priority)
231115
if (additionalSystemInstructions) {
232116
systemMessage += `\n\n<additional-instructions>\n${additionalSystemInstructions}\n</additional-instructions>`;
233117
}

src/utils/main/instructionFiles.test.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import * as fs from "fs/promises";
22
import * as path from "path";
33
import * as os from "os";
44
import {
5-
readFirstAvailableFile,
65
readInstructionSet,
76
gatherInstructionSets,
8-
INSTRUCTION_FILE_NAMES,
97
} from "./instructionFiles";
108

119
describe("instructionFiles", () => {
@@ -19,36 +17,6 @@ describe("instructionFiles", () => {
1917
await fs.rm(tempDir, { recursive: true, force: true });
2018
});
2119

22-
describe("readFirstAvailableFile", () => {
23-
it("should return null when no files exist", async () => {
24-
const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES);
25-
expect(result).toBeNull();
26-
});
27-
28-
it("should return content of first available file in priority order", async () => {
29-
await fs.writeFile(path.join(tempDir, "AGENT.md"), "agent content");
30-
await fs.writeFile(path.join(tempDir, "CLAUDE.md"), "claude content");
31-
32-
const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES);
33-
expect(result).toBe("agent content");
34-
});
35-
36-
it("should prefer AGENTS.md over AGENT.md", async () => {
37-
await fs.writeFile(path.join(tempDir, "AGENTS.md"), "agents content");
38-
await fs.writeFile(path.join(tempDir, "AGENT.md"), "agent content");
39-
40-
const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES);
41-
expect(result).toBe("agents content");
42-
});
43-
44-
it("should fall back to lower priority files", async () => {
45-
await fs.writeFile(path.join(tempDir, "CLAUDE.md"), "claude content");
46-
47-
const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES);
48-
expect(result).toBe("claude content");
49-
});
50-
});
51-
5220
describe("readInstructionSet", () => {
5321
it("should return null when no instruction files exist", async () => {
5422
const result = await readInstructionSet(tempDir);

0 commit comments

Comments
 (0)