mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: add phase 3 prompt customization backend
- parse prompt model and argument-hint metadata and persist model on prompt invocations - expand markdown-linked file context in scanned prompt and instruction bodies - discover .claude/rules instructions and support Claude-style paths metadata - discover customization roots up to the nearest parent repository and watch them for changes - apply per-turn prompt model overrides before sidecar execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
import {
|
||||
buildAvailableModelCatalog,
|
||||
findModel,
|
||||
findModelByReference,
|
||||
normalizePatternModels,
|
||||
resolveReasoningEffort,
|
||||
} from '@shared/domain/models';
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
resolveProjectInstructionsContent,
|
||||
setProjectAgentProfileEnabled,
|
||||
type ProjectAgentProfile,
|
||||
type ProjectPromptFile,
|
||||
type ProjectPromptInvocation,
|
||||
type ProjectCustomizationState,
|
||||
} from '@shared/domain/projectCustomization';
|
||||
@@ -211,6 +213,40 @@ function buildPromptInvocationFallbackContent(promptInvocation?: ProjectPromptIn
|
||||
return `Run prompt file: ${promptInvocation.name}`;
|
||||
}
|
||||
|
||||
function hydratePromptInvocationMetadata(
|
||||
promptInvocation: ProjectPromptInvocation | undefined,
|
||||
projectCustomization: ProjectCustomizationState | undefined,
|
||||
): ProjectPromptInvocation | undefined {
|
||||
if (!promptInvocation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchingPromptFile = findMatchingPromptFile(projectCustomization?.promptFiles, promptInvocation);
|
||||
if (!matchingPromptFile) {
|
||||
return promptInvocation;
|
||||
}
|
||||
|
||||
return normalizeProjectPromptInvocation({
|
||||
...promptInvocation,
|
||||
description: promptInvocation.description ?? matchingPromptFile.description,
|
||||
agent: promptInvocation.agent ?? matchingPromptFile.agent,
|
||||
model: promptInvocation.model ?? matchingPromptFile.model,
|
||||
tools: promptInvocation.tools ?? matchingPromptFile.tools,
|
||||
});
|
||||
}
|
||||
|
||||
function findMatchingPromptFile(
|
||||
promptFiles: ReadonlyArray<ProjectPromptFile> | undefined,
|
||||
promptInvocation: ProjectPromptInvocation,
|
||||
): ProjectPromptFile | undefined {
|
||||
if (!promptFiles || promptFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return promptFiles.find((promptFile) => promptFile.id === promptInvocation.id)
|
||||
?? promptFiles.find((promptFile) => promptFile.sourcePath === promptInvocation.sourcePath);
|
||||
}
|
||||
|
||||
function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean {
|
||||
return promptInvocation?.agent?.trim().toLowerCase() === 'plan';
|
||||
}
|
||||
@@ -1037,7 +1073,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
);
|
||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||
|
||||
const normalizedPromptInvocation = normalizeProjectPromptInvocation(promptInvocation);
|
||||
const normalizedPromptInvocation = hydratePromptInvocationMetadata(
|
||||
normalizeProjectPromptInvocation(promptInvocation),
|
||||
project.customization,
|
||||
);
|
||||
const preparedContent = prepareChatMessageContent(content)
|
||||
?? buildPromptInvocationFallbackContent(normalizedPromptInvocation);
|
||||
if (!preparedContent) {
|
||||
@@ -1422,6 +1461,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
|
||||
const patternForTurn = await this.applyPromptInvocationToPattern(effectivePattern, promptInvocation);
|
||||
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
|
||||
? 'plan'
|
||||
: session.interactionMode ?? 'interactive';
|
||||
@@ -1436,7 +1476,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
|
||||
}
|
||||
|
||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||
session.title = resolveSessionTitle(session, patternForTurn, session.messages);
|
||||
session.status = 'running';
|
||||
session.lastError = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
@@ -1448,7 +1488,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
project,
|
||||
workingDirectory: runWorkingDirectory,
|
||||
workspaceKind,
|
||||
pattern: effectivePattern,
|
||||
pattern: patternForTurn,
|
||||
triggerMessageId,
|
||||
startedAt: occurredAt,
|
||||
preRunGitSnapshot,
|
||||
@@ -1477,7 +1517,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
mode: interactionMode,
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: effectivePattern,
|
||||
pattern: patternForTurn,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
promptInvocation,
|
||||
@@ -2889,6 +2929,40 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
|
||||
}
|
||||
|
||||
private async applyPromptInvocationToPattern(
|
||||
pattern: PatternDefinition,
|
||||
promptInvocation?: ProjectPromptInvocation,
|
||||
): Promise<PatternDefinition> {
|
||||
const requestedModel = promptInvocation?.model?.trim();
|
||||
if (!requestedModel) {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||
const resolvedModel = findModelByReference(requestedModel, modelCatalog);
|
||||
const effectiveModelId = resolvedModel?.id ?? requestedModel;
|
||||
|
||||
let didChange = false;
|
||||
const agents = pattern.agents.map((agent) => {
|
||||
const reasoningEffort = resolvedModel
|
||||
? resolveReasoningEffort(resolvedModel, agent.reasoningEffort)
|
||||
: agent.reasoningEffort;
|
||||
|
||||
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
didChange = true;
|
||||
return {
|
||||
...agent,
|
||||
model: effectiveModelId,
|
||||
reasoningEffort,
|
||||
};
|
||||
});
|
||||
|
||||
return didChange ? { ...pattern, agents } : pattern;
|
||||
}
|
||||
|
||||
private applyProjectCustomizationToPattern(
|
||||
pattern: PatternDefinition,
|
||||
project: ProjectRecord,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type ProjectPromptVariable,
|
||||
} from '@shared/domain/projectCustomization';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
import { expandMarkdownFileLinks } from '@main/services/projectCustomizationLinkResolver';
|
||||
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
|
||||
|
||||
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
|
||||
|
||||
@@ -23,9 +25,21 @@ export class ProjectCustomizationScanner {
|
||||
current?: ProjectCustomizationState,
|
||||
): Promise<ProjectCustomizationState> {
|
||||
const previous = normalizeProjectCustomizationState(current);
|
||||
const instructions = await this.scanInstructionFiles(projectPath, previous);
|
||||
const agentProfiles = await this.scanAgentProfiles(projectPath, previous);
|
||||
const promptFiles = await this.scanPromptFiles(projectPath, previous);
|
||||
const customizationRoots = await resolveProjectCustomizationRoots(projectPath);
|
||||
const allowedRootPath = customizationRoots.at(-1) ?? projectPath;
|
||||
const instructions = await this.scanInstructionFiles(
|
||||
projectPath,
|
||||
customizationRoots,
|
||||
allowedRootPath,
|
||||
previous,
|
||||
);
|
||||
const agentProfiles = await this.scanAgentProfiles(projectPath, customizationRoots, previous);
|
||||
const promptFiles = await this.scanPromptFiles(
|
||||
projectPath,
|
||||
customizationRoots,
|
||||
allowedRootPath,
|
||||
previous,
|
||||
);
|
||||
|
||||
return mergeProjectCustomizationState(
|
||||
previous,
|
||||
@@ -40,31 +54,60 @@ export class ProjectCustomizationScanner {
|
||||
|
||||
private async scanInstructionFiles(
|
||||
projectPath: string,
|
||||
customizationRoots: ReadonlyArray<string>,
|
||||
allowedRootPath: string,
|
||||
previous: ProjectCustomizationState,
|
||||
): Promise<ProjectInstructionFile[]> {
|
||||
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
|
||||
const sourcePaths = [
|
||||
'.github\\copilot-instructions.md',
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
'.claude\\CLAUDE.md',
|
||||
] as const;
|
||||
const instructions: ProjectInstructionFile[] = [];
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const filePath = join(projectPath, ...sourcePath.split('\\'));
|
||||
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, 'always');
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
}
|
||||
for (const customizationRoot of customizationRoots) {
|
||||
const alwaysOnSourcePaths = [
|
||||
'.github\\copilot-instructions.md',
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
'.claude\\CLAUDE.md',
|
||||
] as const;
|
||||
|
||||
const instructionFilePaths = await this.listProjectFiles(join(projectPath, '.github', 'instructions'), '.instructions.md');
|
||||
for (const filePath of instructionFilePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath);
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
for (const sourcePath of alwaysOnSourcePaths) {
|
||||
const filePath = join(customizationRoot, ...sourcePath.split('\\'));
|
||||
const normalizedSourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, normalizedSourcePath, previousByPath, {
|
||||
applicationMode: 'always',
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
});
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
}
|
||||
|
||||
const instructionFilePaths = await this.listProjectFiles(
|
||||
join(customizationRoot, '.github', 'instructions'),
|
||||
'.instructions.md',
|
||||
);
|
||||
for (const filePath of instructionFilePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
});
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
}
|
||||
|
||||
const claudeRuleFilePaths = await this.listProjectFiles(join(customizationRoot, '.claude', 'rules'), '.md');
|
||||
for (const filePath of claudeRuleFilePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
usesClaudeRulePaths: true,
|
||||
});
|
||||
if (instruction) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,55 +116,58 @@ export class ProjectCustomizationScanner {
|
||||
|
||||
private async scanAgentProfiles(
|
||||
projectPath: string,
|
||||
customizationRoots: ReadonlyArray<string>,
|
||||
previous: ProjectCustomizationState,
|
||||
): Promise<ProjectAgentProfile[]> {
|
||||
const previousByPath = new Map(previous.agentProfiles.map((profile) => [profile.sourcePath, profile]));
|
||||
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'agents'), '.agent.md');
|
||||
const profiles: ProjectAgentProfile[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
for (const customizationRoot of customizationRoots) {
|
||||
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'agents'), '.agent.md');
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = readOptionalString(parsedFile.attributes, ['name'])
|
||||
?? basename(filePath, '.agent.md');
|
||||
const prompt = parsedFile.body.trim();
|
||||
if (!name || !prompt) {
|
||||
continue;
|
||||
}
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
id: buildProjectCustomizationItemId('agent', sourcePath),
|
||||
name,
|
||||
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
prompt,
|
||||
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
|
||||
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
|
||||
sourcePath,
|
||||
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
|
||||
});
|
||||
const name = readOptionalString(parsedFile.attributes, ['name'])
|
||||
?? basename(filePath, '.agent.md');
|
||||
const prompt = parsedFile.body.trim();
|
||||
if (!name || !prompt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
id: buildProjectCustomizationItemId('agent', sourcePath),
|
||||
name,
|
||||
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
prompt,
|
||||
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
|
||||
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
|
||||
sourcePath,
|
||||
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
@@ -129,51 +175,61 @@ export class ProjectCustomizationScanner {
|
||||
|
||||
private async scanPromptFiles(
|
||||
projectPath: string,
|
||||
customizationRoots: ReadonlyArray<string>,
|
||||
allowedRootPath: string,
|
||||
previous: ProjectCustomizationState,
|
||||
): Promise<ProjectPromptFile[]> {
|
||||
const previousByPath = new Map(previous.promptFiles.map((promptFile) => [promptFile.sourcePath, promptFile]));
|
||||
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'prompts'), '.prompt.md');
|
||||
const promptFiles: ProjectPromptFile[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
for (const customizationRoot of customizationRoots) {
|
||||
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'prompts'), '.prompt.md');
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = toProjectSourcePath(projectPath, filePath);
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const template = parsedFile.body.trim();
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
promptFiles.push({
|
||||
id: buildProjectCustomizationItemId('prompt', sourcePath),
|
||||
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
agent: readOptionalString(parsedFile.attributes, ['agent']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
template,
|
||||
variables: extractPromptVariables(template),
|
||||
sourcePath,
|
||||
});
|
||||
const template = await expandMarkdownFileLinks(parsedFile.body, {
|
||||
sourceFilePath: filePath,
|
||||
projectPath,
|
||||
allowedRootPath,
|
||||
});
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
|
||||
promptFiles.push({
|
||||
id: buildProjectCustomizationItemId('prompt', sourcePath),
|
||||
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
argumentHint: readOptionalString(parsedFile.attributes, ['argument-hint', 'argumentHint']),
|
||||
agent: readOptionalString(parsedFile.attributes, ['agent']),
|
||||
model: readOptionalString(parsedFile.attributes, ['model']),
|
||||
tools: readOptionalStringArray(parsedFile.attributes.tools),
|
||||
template,
|
||||
variables: extractPromptVariables(template),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return promptFiles;
|
||||
@@ -232,7 +288,12 @@ export class ProjectCustomizationScanner {
|
||||
filePath: string,
|
||||
sourcePath: string,
|
||||
previousByPath: ReadonlyMap<string, ProjectInstructionFile>,
|
||||
applicationMode?: ProjectInstructionApplicationMode,
|
||||
options: {
|
||||
applicationMode?: ProjectInstructionApplicationMode;
|
||||
projectPath: string;
|
||||
allowedRootPath: string;
|
||||
usesClaudeRulePaths?: boolean;
|
||||
},
|
||||
): Promise<ProjectInstructionFile | undefined> {
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'missing') {
|
||||
@@ -248,18 +309,22 @@ export class ProjectCustomizationScanner {
|
||||
return previousByPath.get(sourcePath);
|
||||
}
|
||||
|
||||
const content = parsedFile.body.trim();
|
||||
const content = await expandMarkdownFileLinks(parsedFile.body, {
|
||||
sourceFilePath: filePath,
|
||||
projectPath: options.projectPath,
|
||||
allowedRootPath: options.allowedRootPath,
|
||||
});
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const description = readOptionalString(parsedFile.attributes, ['description']);
|
||||
const applyTo = readOptionalString(parsedFile.attributes, ['applyTo']);
|
||||
const applyTo = readInstructionApplyTo(parsedFile.attributes, options.usesClaudeRulePaths === true);
|
||||
const instruction: ProjectInstructionFile = {
|
||||
id: buildProjectCustomizationItemId('instruction', sourcePath),
|
||||
sourcePath,
|
||||
content,
|
||||
applicationMode: applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
|
||||
applicationMode: options.applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
|
||||
};
|
||||
|
||||
const name = readOptionalString(parsedFile.attributes, ['name']);
|
||||
@@ -368,6 +433,24 @@ function readOptionalString(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readInstructionApplyTo(
|
||||
record: Record<string, unknown>,
|
||||
usesClaudeRulePaths: boolean,
|
||||
): string | undefined {
|
||||
const applyTo = readOptionalString(record, ['applyTo']);
|
||||
const paths = readOptionalStringArray(record.paths);
|
||||
|
||||
if (paths && paths.length > 0) {
|
||||
return paths.join(',');
|
||||
}
|
||||
|
||||
if (applyTo) {
|
||||
return applyTo;
|
||||
}
|
||||
|
||||
return usesClaudeRulePaths ? '**' : undefined;
|
||||
}
|
||||
|
||||
function resolveInstructionApplicationMode(
|
||||
applyTo: string | undefined,
|
||||
description: string | undefined,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
||||
|
||||
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
|
||||
|
||||
type MarkdownLinkResolutionContext = {
|
||||
projectPath: string;
|
||||
allowedRootPath: string;
|
||||
sourceFilePath: string;
|
||||
seenPaths: Set<string>;
|
||||
ancestry: readonly string[];
|
||||
};
|
||||
|
||||
export async function expandMarkdownFileLinks(
|
||||
content: string,
|
||||
options: {
|
||||
projectPath: string;
|
||||
allowedRootPath: string;
|
||||
sourceFilePath: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent) {
|
||||
return trimmedContent;
|
||||
}
|
||||
|
||||
return expandMarkdownFileLinksRecursive(trimmedContent, {
|
||||
...options,
|
||||
seenPaths: new Set<string>(),
|
||||
ancestry: [options.sourceFilePath],
|
||||
});
|
||||
}
|
||||
|
||||
async function expandMarkdownFileLinksRecursive(
|
||||
content: string,
|
||||
context: MarkdownLinkResolutionContext,
|
||||
): Promise<string> {
|
||||
const referencedBlocks: string[] = [];
|
||||
|
||||
for (const linkTarget of collectLocalMarkdownLinkTargets(content)) {
|
||||
const resolvedPath = resolveMarkdownLinkTarget(context.sourceFilePath, linkTarget);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isPathInsideRoot(resolvedPath, context.allowedRootPath)) {
|
||||
console.warn(
|
||||
`[aryx customization] Ignoring linked file outside the allowed customization root: ${resolvedPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.seenPaths.has(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.ancestry.includes(resolvedPath)) {
|
||||
console.warn(`[aryx customization] Ignoring circular Markdown link reference to ${resolvedPath}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedContent = await readLinkedFile(resolvedPath);
|
||||
if (linkedContent === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
context.seenPaths.add(resolvedPath);
|
||||
const expandedLinkedContent = isMarkdownLikePath(resolvedPath)
|
||||
? await expandMarkdownFileLinksRecursive(linkedContent, {
|
||||
...context,
|
||||
sourceFilePath: resolvedPath,
|
||||
ancestry: [...context.ancestry, resolvedPath],
|
||||
})
|
||||
: linkedContent.trim();
|
||||
|
||||
referencedBlocks.push(
|
||||
formatReferencedFileBlock(
|
||||
toProjectSourcePath(context.projectPath, resolvedPath),
|
||||
expandedLinkedContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (referencedBlocks.length === 0) {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
return `${content.trim()}\n\nReferenced file context:\n\n${referencedBlocks.join('\n\n')}`.trim();
|
||||
}
|
||||
|
||||
function collectLocalMarkdownLinkTargets(content: string): string[] {
|
||||
const targets: string[] = [];
|
||||
const seenTargets = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = markdownLinkPattern.exec(content))) {
|
||||
const target = match[1]?.trim();
|
||||
if (!target || seenTargets.has(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenTargets.add(target);
|
||||
targets.push(target);
|
||||
}
|
||||
|
||||
markdownLinkPattern.lastIndex = 0;
|
||||
return targets;
|
||||
}
|
||||
|
||||
function resolveMarkdownLinkTarget(sourceFilePath: string, rawTarget: string): string | undefined {
|
||||
const target = extractMarkdownLinkDestination(rawTarget);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolve(dirname(sourceFilePath), target);
|
||||
}
|
||||
|
||||
function extractMarkdownLinkDestination(rawTarget: string): string | undefined {
|
||||
let target = rawTarget.trim();
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (target.startsWith('<') && target.endsWith('>')) {
|
||||
target = target.slice(1, -1).trim();
|
||||
} else {
|
||||
const whitespaceIndex = target.search(/\s/);
|
||||
if (whitespaceIndex >= 0) {
|
||||
target = target.slice(0, whitespaceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const hashIndex = target.indexOf('#');
|
||||
if (hashIndex >= 0) {
|
||||
target = target.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedTarget = target.toLowerCase();
|
||||
if (
|
||||
target.startsWith('#')
|
||||
|| isAbsolute(target)
|
||||
|| normalizedTarget.startsWith('http://')
|
||||
|| normalizedTarget.startsWith('https://')
|
||||
|| normalizedTarget.startsWith('mailto:')
|
||||
|| normalizedTarget.startsWith('vscode:')
|
||||
|| normalizedTarget.startsWith('command:')
|
||||
|| normalizedTarget.startsWith('data:')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
async function readLinkedFile(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
if (content.includes('\0')) {
|
||||
console.warn(`[aryx customization] Ignoring binary-linked file ${filePath}.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.warn(`[aryx customization] Linked file not found: ${filePath}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to read linked file ${filePath}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReferencedFileBlock(sourcePath: string, content: string): string {
|
||||
return [
|
||||
`Source: ${sourcePath}`,
|
||||
'Contents:',
|
||||
content.trim() || '[empty file]',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function isMarkdownLikePath(filePath: string): boolean {
|
||||
const normalizedPath = filePath.toLowerCase();
|
||||
return normalizedPath.endsWith('.md') || normalizedPath.endsWith('.markdown');
|
||||
}
|
||||
|
||||
function isPathInsideRoot(filePath: string, rootPath: string): boolean {
|
||||
const relativePath = relative(rootPath, filePath);
|
||||
return relativePath.length === 0
|
||||
|| (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function toProjectSourcePath(projectPath: string, filePath: string): string {
|
||||
const relativePath = relative(projectPath, filePath).trim();
|
||||
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { access } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export async function resolveProjectCustomizationRoots(projectPath: string): Promise<string[]> {
|
||||
if (await hasGitEntry(projectPath)) {
|
||||
return [projectPath];
|
||||
}
|
||||
|
||||
const ancestorPaths: string[] = [];
|
||||
let currentPath = projectPath;
|
||||
while (true) {
|
||||
const parentPath = dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
return [projectPath];
|
||||
}
|
||||
|
||||
ancestorPaths.push(parentPath);
|
||||
if (await hasGitEntry(parentPath)) {
|
||||
return [projectPath, ...ancestorPaths];
|
||||
}
|
||||
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitEntry(directoryPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(join(directoryPath, '.git'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to inspect ${join(directoryPath, '.git')}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { watch } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
|
||||
|
||||
export interface ProjectCustomizationWatchTarget {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -128,11 +130,15 @@ export class ProjectCustomizationWatcher {
|
||||
}
|
||||
|
||||
export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise<string[]> {
|
||||
const paths = new Set<string>([projectPath]);
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const relativeRoot of ['.github', '.claude']) {
|
||||
for (const directoryPath of await collectExistingDirectories(join(projectPath, relativeRoot))) {
|
||||
paths.add(directoryPath);
|
||||
for (const customizationRoot of await resolveProjectCustomizationRoots(projectPath)) {
|
||||
paths.add(customizationRoot);
|
||||
|
||||
for (const relativeRoot of ['.github', '.claude']) {
|
||||
for (const directoryPath of await collectExistingDirectories(join(customizationRoot, relativeRoot))) {
|
||||
paths.add(directoryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,20 @@ export function findModel(
|
||||
return models.find((model) => model.id === id);
|
||||
}
|
||||
|
||||
export function findModelByReference(
|
||||
reference: string,
|
||||
models: ReadonlyArray<ModelDefinition> = modelCatalog,
|
||||
): ModelDefinition | undefined {
|
||||
const trimmedReference = reference.trim();
|
||||
if (!trimmedReference) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return models.find((model) =>
|
||||
model.id === trimmedReference
|
||||
|| model.name.localeCompare(trimmedReference, undefined, { sensitivity: 'accent' }) === 0);
|
||||
}
|
||||
|
||||
export function inferProvider(modelId: string): ModelProvider | undefined {
|
||||
if (modelId.startsWith('gpt-')) return 'openai';
|
||||
if (modelId.startsWith('claude-')) return 'anthropic';
|
||||
|
||||
@@ -36,7 +36,9 @@ export interface ProjectPromptFile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
argumentHint?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
template: string;
|
||||
variables: ProjectPromptVariable[];
|
||||
@@ -50,6 +52,7 @@ export interface ProjectPromptInvocation {
|
||||
resolvedPrompt: string;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
@@ -268,11 +271,21 @@ function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromp
|
||||
normalizedPromptFile.description = description;
|
||||
}
|
||||
|
||||
const argumentHint = normalizeOptionalString(promptFile.argumentHint);
|
||||
if (argumentHint) {
|
||||
normalizedPromptFile.argumentHint = argumentHint;
|
||||
}
|
||||
|
||||
const agent = normalizeOptionalString(promptFile.agent);
|
||||
if (agent) {
|
||||
normalizedPromptFile.agent = agent;
|
||||
}
|
||||
|
||||
const model = normalizeOptionalString(promptFile.model);
|
||||
if (model) {
|
||||
normalizedPromptFile.model = model;
|
||||
}
|
||||
|
||||
const tools = normalizeOptionalStringArray(promptFile.tools);
|
||||
if (tools) {
|
||||
normalizedPromptFile.tools = tools;
|
||||
@@ -313,6 +326,11 @@ export function normalizeProjectPromptInvocation(
|
||||
normalizedPromptInvocation.agent = agent;
|
||||
}
|
||||
|
||||
const model = normalizeOptionalString(promptInvocation.model);
|
||||
if (model) {
|
||||
normalizedPromptInvocation.model = model;
|
||||
}
|
||||
|
||||
const tools = normalizeOptionalStringArray(promptInvocation.tools);
|
||||
if (tools) {
|
||||
normalizedPromptInvocation.tools = tools;
|
||||
|
||||
Reference in New Issue
Block a user