Skip to content
Closed
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
53 changes: 53 additions & 0 deletions container/agent-runner/src/providers/claude-md.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as fs from 'fs';
import * as path from 'path';

export function resolveClaudeMdIncludes(
content: string,
baseDir: string,
rootDir = baseDir,
seen = new Set<string>(),
): string {
const root = path.resolve(rootDir);

return content
.split(/\r?\n/)
.map((line) => {
const match = /^\s*@(.+?)\s*$/.exec(line);
if (!match) {
return line;
}

const includePath = match[1];
if (!includePath.startsWith('./') && !includePath.startsWith('../')) {
return line;
}

const resolvedPath = path.resolve(baseDir, includePath);
const relativeToRoot = path.relative(root, resolvedPath);
const isInsideRoot =
relativeToRoot === '' || (!relativeToRoot.startsWith('..') && !path.isAbsolute(relativeToRoot));

if (!isInsideRoot) {
return line;
}

if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
return line;
}

if (seen.has(resolvedPath)) {
return line;
}

const nextSeen = new Set(seen);
nextSeen.add(resolvedPath);

return resolveClaudeMdIncludes(
fs.readFileSync(resolvedPath, 'utf-8'),
path.dirname(resolvedPath),
root,
nextSeen,
).replace(/\r?\n$/, '');
})
.join('\n');
}
34 changes: 26 additions & 8 deletions container/agent-runner/src/providers/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from 'child_process';
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';

import { registerProvider } from './provider-registry.js';
import { resolveClaudeMdIncludes } from './claude-md.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';

Expand All @@ -17,7 +18,10 @@ const SESSION_STATUS_RETRY_ERROR_AFTER = 3;
const STALE_SESSION_RE =
/no conversation found|ENOENT.*\.jsonl|session.*not found|NotFoundError|connection reset|ECONNRESET|404|event timeout/i;

function spawnOpencodeServer(config: Record<string, unknown>, timeoutMs = 10_000): Promise<{ url: string; proc: ChildProcess }> {
function spawnOpencodeServer(
config: Record<string, unknown>,
timeoutMs = 10_000,
): Promise<{ url: string; proc: ChildProcess }> {
return new Promise((resolve, reject) => {
const hostname = '127.0.0.1';
const port = 4096;
Expand Down Expand Up @@ -62,19 +66,33 @@ function spawnOpencodeServer(config: Record<string, unknown>, timeoutMs = 10_000
});
}

function readClaudeMdFileForPrompt(filePath: string): string | undefined {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return undefined;
}

return resolveClaudeMdIncludes(fs.readFileSync(filePath, 'utf-8'), path.dirname(filePath));
}

function readClaudeMdForPrompt(): string | undefined {
const groupPath = '/workspace/agent/CLAUDE.md';
const globalPath = '/workspace/global/CLAUDE.md';
let content = '';
if (fs.existsSync(groupPath)) {
content += fs.readFileSync(groupPath, 'utf-8');
const chunks: string[] = [];

const groupContent = readClaudeMdFileForPrompt(groupPath);
if (groupContent) {
chunks.push(groupContent);
}

const isMain = process.env.NANOCLAW_IS_MAIN === '1';
if (!isMain && fs.existsSync(globalPath)) {
if (content) content += '\n\n---\n\n';
content += fs.readFileSync(globalPath, 'utf-8');
if (!isMain) {
const globalContent = readClaudeMdFileForPrompt(globalPath);
if (globalContent) {
chunks.push(globalContent);
}
}
return content || undefined;

return chunks.length > 0 ? chunks.join('\n\n---\n\n') : undefined;
}

function wrapPromptWithContext(text: string, systemInstructions?: string): string {
Expand Down
109 changes: 109 additions & 0 deletions src/opencode-provider-includes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { describe, expect, it } from 'vitest';

type ResolveClaudeMdIncludes = (content: string, baseDir: string, rootDir?: string, seen?: Set<string>) => string;

type ClaudeMdModule = {
resolveClaudeMdIncludes: ResolveClaudeMdIncludes;
};

async function loadResolveClaudeMdIncludes(): Promise<ResolveClaudeMdIncludes> {
const modulePath = pathToFileURL(path.resolve('container/agent-runner/src/providers/claude-md.ts')).href;

const module = (await import(modulePath)) as ClaudeMdModule;
return module.resolveClaudeMdIncludes;
}

function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-opencode-'));
}

describe('OpenCode provider CLAUDE.md include resolution', () => {
it('expands whole-line local Claude-style includes', async () => {
const resolveClaudeMdIncludes = await loadResolveClaudeMdIncludes();
const root = makeTempDir();
fs.mkdirSync(path.join(root, '.claude-fragments'), { recursive: true });

fs.writeFileSync(
path.join(root, 'CLAUDE.md'),
['before', '@./.claude-shared.md', '@./.claude-fragments/module-self-mod.md', 'after', ''].join('\n'),
);

fs.writeFileSync(
path.join(root, '.claude-shared.md'),
'SHARED_SENTINEL: this base instruction should reach the model.\n',
);

fs.writeFileSync(
path.join(root, '.claude-fragments/module-self-mod.md'),
'FRAGMENT_SENTINEL: OneCLI agent vault instructions should reach the model.\n',
);

const resolved = resolveClaudeMdIncludes(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf-8'), root);

expect(resolved).toContain('before');
expect(resolved).toContain('SHARED_SENTINEL');
expect(resolved).toContain('FRAGMENT_SENTINEL');
expect(resolved).toContain('after');
expect(resolved).not.toContain('@./.claude-shared.md');
expect(resolved).not.toContain('@./.claude-fragments/module-self-mod.md');
});

it('expands nested local includes relative to the included file', async () => {
const resolveClaudeMdIncludes = await loadResolveClaudeMdIncludes();
const root = makeTempDir();
fs.mkdirSync(path.join(root, 'fragments', 'nested'), { recursive: true });

fs.writeFileSync(path.join(root, 'CLAUDE.md'), '@./fragments/outer.md\n');
fs.writeFileSync(path.join(root, 'fragments', 'outer.md'), 'outer\n@./nested/inner.md\n');
fs.writeFileSync(path.join(root, 'fragments', 'nested', 'inner.md'), 'INNER_SENTINEL\n');

const resolved = resolveClaudeMdIncludes(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf-8'), root);

expect(resolved).toContain('outer');
expect(resolved).toContain('INNER_SENTINEL');
expect(resolved).not.toContain('@./fragments/outer.md');
expect(resolved).not.toContain('@./nested/inner.md');
});

it('leaves missing includes literal instead of silently deleting them', async () => {
const resolveClaudeMdIncludes = await loadResolveClaudeMdIncludes();
const root = makeTempDir();

const resolved = resolveClaudeMdIncludes('@./missing.md\n', root);

expect(resolved).toContain('@./missing.md');
});

it('does not expand includes that escape the instruction root', async () => {
const resolveClaudeMdIncludes = await loadResolveClaudeMdIncludes();
const root = makeTempDir();
const outside = makeTempDir();

fs.writeFileSync(path.join(outside, 'secret.md'), 'SHOULD_NOT_BE_INCLUDED\n');

const resolved = resolveClaudeMdIncludes(`@../${path.basename(outside)}/secret.md\n`, root);

expect(resolved).not.toContain('SHOULD_NOT_BE_INCLUDED');
expect(resolved).toContain('@../');
});

it('keeps cyclic includes literal at the cycle point', async () => {
const resolveClaudeMdIncludes = await loadResolveClaudeMdIncludes();
const root = makeTempDir();

fs.writeFileSync(path.join(root, 'CLAUDE.md'), 'root\n@./a.md\n');
fs.writeFileSync(path.join(root, 'a.md'), 'a\n@./b.md\n');
fs.writeFileSync(path.join(root, 'b.md'), 'b\n@./a.md\n');

const resolved = resolveClaudeMdIncludes(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf-8'), root);

expect(resolved).toContain('root');
expect(resolved).toContain('a');
expect(resolved).toContain('b');
expect(resolved).toContain('@./a.md');
});
});
Loading