From 1df353148ee2dba86f67ed81928ee85d2814d987 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:28:43 +0900 Subject: [PATCH] =?UTF-8?q?=E6=97=A7=E4=BB=95=E6=A7=98=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/stance-persona.test.ts | 4 +- src/features/config/ejectBuiltin.ts | 2 +- src/infra/config/loaders/agentLoader.ts | 34 +-- src/infra/config/loaders/pieceParser.ts | 262 +++++++++--------------- src/infra/config/paths.ts | 5 - 5 files changed, 110 insertions(+), 197 deletions(-) diff --git a/src/__tests__/stance-persona.test.ts b/src/__tests__/stance-persona.test.ts index ac5eb7b..c94d677 100644 --- a/src/__tests__/stance-persona.test.ts +++ b/src/__tests__/stance-persona.test.ts @@ -292,7 +292,7 @@ describe('stances', () => { expect(config.movements[0]!.stanceContents).toBeUndefined(); }); - it('should leave stanceContents undefined for unknown stance names', () => { + it('should treat unknown stance names as inline content', () => { const raw = { name: 'test-piece', stances: { @@ -309,7 +309,7 @@ describe('stances', () => { }; const config = normalizePieceConfig(raw, testDir); - expect(config.movements[0]!.stanceContents).toBeUndefined(); + expect(config.movements[0]!.stanceContents).toEqual(['nonexistent']); }); it('should resolve stances in parallel sub-movements', () => { diff --git a/src/features/config/ejectBuiltin.ts b/src/features/config/ejectBuiltin.ts index 9bd9982..274e85a 100644 --- a/src/features/config/ejectBuiltin.ts +++ b/src/features/config/ejectBuiltin.ts @@ -71,7 +71,7 @@ export async function ejectBuiltin(name?: string, options: EjectOptions = {}): P success(`Ejected piece: ${pieceDest}`); } - // Copy related resource files (agents, personas, stances, instructions, report-formats) + // Copy related resource files (personas, stances, instructions, report-formats) const resourceRefs = extractResourceRelativePaths(builtinPath); let copiedCount = 0; diff --git a/src/infra/config/loaders/agentLoader.ts b/src/infra/config/loaders/agentLoader.ts index 6fe7504..0dabd8f 100644 --- a/src/infra/config/loaders/agentLoader.ts +++ b/src/infra/config/loaders/agentLoader.ts @@ -1,10 +1,9 @@ /** - * Agent and persona configuration loader + * Persona configuration loader * * Loads persona prompts with user → builtin fallback: - * 1. User personas: ~/.takt/personas/*.md (preferred) - * 2. User agents (legacy): ~/.takt/agents/*.md (backward compat) - * 3. Builtin personas: resources/global/{lang}/personas/*.md + * 1. User personas: ~/.takt/personas/*.md + * 2. Builtin personas: resources/global/{lang}/personas/*.md */ import { readFileSync, existsSync, readdirSync } from 'node:fs'; @@ -12,7 +11,6 @@ import { join, basename } from 'node:path'; import type { CustomAgentConfig } from '../../../core/models/index.js'; import { getGlobalPersonasDir, - getGlobalAgentsDir, getGlobalPiecesDir, getBuiltinPersonasDir, getBuiltinPiecesDir, @@ -20,12 +18,11 @@ import { } from '../paths.js'; import { getLanguage } from '../global/globalConfig.js'; -/** Get all allowed base directories for persona/agent prompt files */ +/** Get all allowed base directories for persona prompt files */ function getAllowedPromptBases(): string[] { const lang = getLanguage(); return [ getGlobalPersonasDir(), - getGlobalAgentsDir(), getGlobalPiecesDir(), getBuiltinPersonasDir(lang), getBuiltinPiecesDir(lang), @@ -51,20 +48,12 @@ export function loadAgentsFromDir(dirPath: string): CustomAgentConfig[] { return agents; } -/** Load all custom agents from global directories (~/.takt/personas/, ~/.takt/agents/) */ +/** Load all custom agents from ~/.takt/personas/ */ export function loadCustomAgents(): Map { const agents = new Map(); - - // Legacy: ~/.takt/agents/*.md (loaded first, overwritten by personas/) - for (const agent of loadAgentsFromDir(getGlobalAgentsDir())) { - agents.set(agent.name, agent); - } - - // Preferred: ~/.takt/personas/*.md (takes priority) for (const agent of loadAgentsFromDir(getGlobalPersonasDir())) { agents.set(agent.name, agent); } - return agents; } @@ -73,13 +62,7 @@ export function listCustomAgents(): string[] { return Array.from(loadCustomAgents().keys()).sort(); } -/** - * Load agent prompt content. - * Prompts can be loaded from: - * - ~/.takt/personas/*.md (preferred) - * - ~/.takt/agents/*.md (legacy) - * - ~/.takt/pieces/{piece}/*.md (piece-specific) - */ +/** Load agent prompt content. */ export function loadAgentPrompt(agent: CustomAgentConfig): string { if (agent.prompt) { return agent.prompt; @@ -102,10 +85,7 @@ export function loadAgentPrompt(agent: CustomAgentConfig): string { throw new Error(`Agent ${agent.name} has no prompt defined`); } -/** - * Load persona prompt from a resolved path. - * Used by piece engine when personaPath is already resolved. - */ +/** Load persona prompt from a resolved path. */ export function loadPersonaPromptFromPath(personaPath: string): string { const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, personaPath)); if (!isValid) { diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 29f6ed0..335dbba 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -6,87 +6,110 @@ */ import { readFileSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join, dirname, basename } from 'node:path'; import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, ReportConfig, ReportObjectConfig, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; -/** Parsed movement type from Zod schema (replaces `any`) */ type RawStep = z.output; -/** - * Resolve persona path from piece specification. - * - Relative path (./persona.md): relative to piece directory - * - Absolute path (/path/to/persona.md or ~/...): use as-is - */ -function resolvePersonaPathForPiece(personaSpec: string, pieceDir: string): string { - if (personaSpec.startsWith('./')) { - return join(pieceDir, personaSpec.slice(2)); - } - if (personaSpec.startsWith('~')) { - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - return join(homedir, personaSpec.slice(1)); - } - if (personaSpec.startsWith('/')) { - return personaSpec; - } - return join(pieceDir, personaSpec); +/** Resolve a resource spec to an absolute file path. */ +function resolveResourcePath(spec: string, pieceDir: string): string { + if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); + if (spec.startsWith('~')) return join(homedir(), spec.slice(1)); + if (spec.startsWith('/')) return spec; + return join(pieceDir, spec); } /** - * Extract display name from persona path. - * e.g., "~/.takt/personas/coder.md" -> "coder" + * Resolve a resource spec to its file content. + * If the spec ends with .md and the file exists, returns file content. + * Otherwise returns the spec as-is (treated as inline content). */ +function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined { + if (spec == null) return undefined; + if (spec.endsWith('.md')) { + const resolved = resolveResourcePath(spec, pieceDir); + if (existsSync(resolved)) return readFileSync(resolved, 'utf-8'); + } + return spec; +} + +/** + * Resolve a section reference to content. + * Looks up ref in resolvedMap first, then falls back to resolveResourceContent. + */ +function resolveRefToContent( + ref: string, + resolvedMap: Record | undefined, + pieceDir: string, +): string | undefined { + const mapped = resolvedMap?.[ref]; + if (mapped) return mapped; + return resolveResourceContent(ref, pieceDir); +} + +/** Resolve multiple references to content strings (for fields that accept string | string[]). */ +function resolveRefList( + refs: string | string[] | undefined, + resolvedMap: Record | undefined, + pieceDir: string, +): string[] | undefined { + if (refs == null) return undefined; + const list = Array.isArray(refs) ? refs : [refs]; + const contents: string[] = []; + for (const ref of list) { + const content = resolveRefToContent(ref, resolvedMap, pieceDir); + if (content) contents.push(content); + } + return contents.length > 0 ? contents : undefined; +} + +/** Resolve a piece-level section map (each value resolved to file content or inline). */ +function resolveSectionMap( + raw: Record | undefined, + pieceDir: string, +): Record | undefined { + if (!raw) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const content = resolveResourceContent(value, pieceDir); + if (content) resolved[name] = content; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Extract display name from persona path (e.g., "coder.md" → "coder"). */ function extractPersonaDisplayName(personaPath: string): string { return basename(personaPath, '.md'); } -/** - * Resolve a string value that may be a file path. - * If the value ends with .md and the file exists (resolved relative to pieceDir), - * read and return the file contents. Otherwise return the value as-is. - */ -function resolveContentPath(value: string | undefined, pieceDir: string): string | undefined { - if (value == null) return undefined; - if (value.endsWith('.md')) { - let resolvedPath = value; - if (value.startsWith('./')) { - resolvedPath = join(pieceDir, value.slice(2)); - } else if (value.startsWith('~')) { - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - resolvedPath = join(homedir, value.slice(1)); - } else if (!value.startsWith('/')) { - resolvedPath = join(pieceDir, value); - } - if (existsSync(resolvedPath)) { - return readFileSync(resolvedPath, 'utf-8'); - } - } - return value; +/** Resolve persona from YAML field to spec + absolute path. */ +function resolvePersona( + rawPersona: string | undefined, + sections: PieceSections, + pieceDir: string, +): { personaSpec?: string; personaPath?: string } { + if (!rawPersona) return {}; + const personaSpec = sections.personas?.[rawPersona] ?? rawPersona; + + const resolved = resolveResourcePath(personaSpec, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec, personaPath }; } -/** - * Resolve a value from a section map by key lookup. - * If the value matches a key in sectionMap, return the mapped value. - * Otherwise return the value as-is (treated as file path or inline content). - */ -function resolveSectionReference( - value: string, - sectionMap: Record | undefined, -): string { - const resolved = sectionMap?.[value]; - return resolved ?? value; -} - -/** Section maps parsed from piece YAML for section reference expansion */ +/** Pre-resolved section maps passed to movement normalization. */ interface PieceSections { + /** Persona name → file path (raw, not content-resolved) */ personas?: Record; - stances?: Record; - /** Stances resolved to file content (for backward-compat plain name lookup) */ + /** Stance name → resolved content */ resolvedStances?: Record; - instructions?: Record; - reportFormats?: Record; + /** Instruction name → resolved content */ + resolvedInstructions?: Record; + /** Report format name → resolved content */ + resolvedReportFormats?: Record; } /** Check if a raw report value is the object form (has 'name' property). */ @@ -94,24 +117,19 @@ function isReportObject(raw: unknown): raw is { name: string; order?: string; fo return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; } -/** - * Normalize the raw report field from YAML into internal format. - * Supports section references for format/order fields via rawReportFormats section. - */ +/** Normalize the raw report field from YAML into internal format. */ function normalizeReport( raw: string | Record[] | { name: string; order?: string; format?: string } | undefined, pieceDir: string, - rawReportFormats?: Record, + resolvedReportFormats?: Record, ): string | ReportConfig[] | ReportObjectConfig | undefined { if (raw == null) return undefined; if (typeof raw === 'string') return raw; if (isReportObject(raw)) { - const expandedFormat = raw.format ? resolveSectionReference(raw.format, rawReportFormats) : undefined; - const expandedOrder = raw.order ? resolveSectionReference(raw.order, rawReportFormats) : undefined; return { name: raw.name, - order: resolveContentPath(expandedOrder, pieceDir), - format: resolveContentPath(expandedFormat, pieceDir), + order: raw.order ? resolveRefToContent(raw.order, resolvedReportFormats, pieceDir) : undefined, + format: raw.format ? resolveRefToContent(raw.format, resolvedReportFormats, pieceDir) : undefined, }; } return (raw as Record[]).flatMap((entry) => @@ -197,34 +215,6 @@ function normalizeRule(r: { }; } -/** - * Resolve stance references for a movement. - * - * Resolution priority: - * 1. Section key → look up in resolvedStances (pre-resolved content) - * 2. File path (`./path`, `../path`, `*.md`) → resolve file directly - * 3. Unknown names are silently ignored - */ -function resolveStanceContents( - stanceRef: string | string[] | undefined, - sections: PieceSections, - pieceDir: string, -): string[] | undefined { - if (stanceRef == null) return undefined; - const refs = Array.isArray(stanceRef) ? stanceRef : [stanceRef]; - const contents: string[] = []; - for (const ref of refs) { - const sectionContent = sections.resolvedStances?.[ref]; - if (sectionContent) { - contents.push(sectionContent); - } else if (ref.endsWith('.md') || ref.startsWith('./') || ref.startsWith('../')) { - const content = resolveContentPath(ref, pieceDir); - if (content) contents.push(content); - } - } - return contents.length > 0 ? contents : undefined; -} - /** Normalize a raw step into internal PieceMovement format. */ function normalizeStepFromRaw( step: RawStep, @@ -233,31 +223,17 @@ function normalizeStepFromRaw( ): PieceMovement { const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule); - // Resolve persona via section reference expansion const rawPersona = (step as Record).persona as string | undefined; - const expandedPersona = rawPersona ? resolveSectionReference(rawPersona, sections.personas) : undefined; - const personaSpec: string | undefined = expandedPersona || undefined; - - // Resolve persona path: if the resolved path exists on disk, use it; otherwise leave personaPath undefined - // so that the runner treats personaSpec as an inline system prompt string. - let personaPath: string | undefined; - if (personaSpec) { - const resolved = resolvePersonaPathForPiece(personaSpec, pieceDir); - if (existsSync(resolved)) { - personaPath = resolved; - } - } + const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir); const displayName: string | undefined = (step as Record).persona_name as string || undefined; - // Resolve stance references (supports section key, file paths) const stanceRef = (step as Record).stance as string | string[] | undefined; - const stanceContents = resolveStanceContents(stanceRef, sections, pieceDir); + const stanceContents = resolveRefList(stanceRef, sections.resolvedStances, pieceDir); - // Resolve instruction: instruction_template > instruction (with section reference expansion) > default const expandedInstruction = step.instruction - ? resolveContentPath(resolveSectionReference(step.instruction, sections.instructions), pieceDir) + ? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir) : undefined; const result: PieceMovement = { @@ -272,9 +248,9 @@ function normalizeStepFromRaw( model: step.model, permissionMode: step.permission_mode, edit: step.edit, - instructionTemplate: resolveContentPath(step.instruction_template, pieceDir) || expandedInstruction || '{task}', + instructionTemplate: resolveResourceContent(step.instruction_template, pieceDir) || expandedInstruction || '{task}', rules, - report: normalizeReport(step.report, pieceDir, sections.reportFormats), + report: normalizeReport(step.report, pieceDir, sections.resolvedReportFormats), passPreviousResponse: step.pass_previous_response ?? true, stanceContents, }; @@ -286,31 +262,18 @@ function normalizeStepFromRaw( return result; } -/** - * Normalize a raw loop monitor judge from YAML into internal format. - * Resolves persona paths and instruction_template content paths. - */ +/** Normalize a raw loop monitor judge from YAML into internal format. */ function normalizeLoopMonitorJudge( raw: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> }, pieceDir: string, sections: PieceSections, ): LoopMonitorJudge { - const rawPersona = raw.persona || undefined; - const expandedPersona = rawPersona ? resolveSectionReference(rawPersona, sections.personas) : undefined; - const personaSpec = expandedPersona || undefined; - - let personaPath: string | undefined; - if (personaSpec) { - const resolved = resolvePersonaPathForPiece(personaSpec, pieceDir); - if (existsSync(resolved)) { - personaPath = resolved; - } - } + const { personaSpec, personaPath } = resolvePersona(raw.persona, sections, pieceDir); return { persona: personaSpec, personaPath, - instructionTemplate: resolveContentPath(raw.instruction_template, pieceDir), + instructionTemplate: resolveResourceContent(raw.instruction_template, pieceDir), rules: raw.rules.map((r) => ({ condition: r.condition, next: r.next })), }; } @@ -331,52 +294,27 @@ function normalizeLoopMonitors( })); } -/** - * Resolve a piece-level section map. - * Each value is resolved via resolveContentPath (supports .md file references). - * Used for stances, instructions, and report_formats. - */ -function resolveSectionMap( - raw: Record | undefined, - pieceDir: string, -): Record | undefined { - if (!raw) return undefined; - const resolved: Record = {}; - for (const [name, value] of Object.entries(raw)) { - const content = resolveContentPath(value, pieceDir); - if (content) { - resolved[name] = content; - } - } - return Object.keys(resolved).length > 0 ? resolved : undefined; -} - -/** - * Convert raw YAML piece config to internal format. - * Agent paths are resolved relative to the piece directory. - */ +/** Convert raw YAML piece config to internal format. */ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfig { const parsed = PieceConfigRawSchema.parse(raw); - // Resolve piece-level section maps const resolvedStances = resolveSectionMap(parsed.stances, pieceDir); const resolvedInstructions = resolveSectionMap(parsed.instructions, pieceDir); const resolvedReportFormats = resolveSectionMap(parsed.report_formats, pieceDir); - // Build sections for section reference expansion in movements const sections: PieceSections = { personas: parsed.personas, - stances: parsed.stances, resolvedStances, - instructions: parsed.instructions, - reportFormats: parsed.report_formats, + resolvedInstructions, + resolvedReportFormats, }; const movements: PieceMovement[] = parsed.movements.map((step) => normalizeStepFromRaw(step, pieceDir, sections), ); - const initialMovement = parsed.initial_movement ?? movements[0]?.name ?? ''; + // Schema guarantees movements.min(1) + const initialMovement = parsed.initial_movement ?? movements[0]!.name; return { name: parsed.name, diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index ad492b4..e78c602 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -21,11 +21,6 @@ export function getGlobalPersonasDir(): string { return join(getGlobalConfigDir(), 'personas'); } -/** @deprecated Use getGlobalPersonasDir(). Kept for backward compat with ~/.takt/agents/ */ -export function getGlobalAgentsDir(): string { - return join(getGlobalConfigDir(), 'agents'); -} - /** Get takt global pieces directory (~/.takt/pieces) */ export function getGlobalPiecesDir(): string { return join(getGlobalConfigDir(), 'pieces');