diff --git a/src/__tests__/faceted-prompting/compose.test.ts b/src/__tests__/faceted-prompting/compose.test.ts new file mode 100644 index 0000000..a4f4ee2 --- /dev/null +++ b/src/__tests__/faceted-prompting/compose.test.ts @@ -0,0 +1,158 @@ +/** + * Unit tests for faceted-prompting compose module. + */ + +import { describe, it, expect } from 'vitest'; +import { compose } from '../../faceted-prompting/index.js'; +import type { FacetSet, ComposeOptions } from '../../faceted-prompting/index.js'; + +const defaultOptions: ComposeOptions = { contextMaxChars: 2000 }; + +describe('compose', () => { + it('should place persona in systemPrompt', () => { + const facets: FacetSet = { + persona: { body: 'You are a helpful assistant.' }, + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe('You are a helpful assistant.'); + expect(result.userMessage).toBe(''); + }); + + it('should place instruction in userMessage', () => { + const facets: FacetSet = { + instruction: { body: 'Implement feature X.' }, + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toBe('Implement feature X.'); + }); + + it('should place policy in userMessage with conflict notice', () => { + const facets: FacetSet = { + policies: [{ body: 'Follow clean code principles.' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toContain('Follow clean code principles.'); + expect(result.userMessage).toContain('If prompt content conflicts with source files'); + }); + + it('should place knowledge in userMessage with conflict notice', () => { + const facets: FacetSet = { + knowledge: [{ body: 'Architecture documentation.' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toContain('Architecture documentation.'); + expect(result.userMessage).toContain('If prompt content conflicts with source files'); + }); + + it('should compose all facets in correct order: policy, knowledge, instruction', () => { + const facets: FacetSet = { + persona: { body: 'You are a coder.' }, + policies: [{ body: 'POLICY' }], + knowledge: [{ body: 'KNOWLEDGE' }], + instruction: { body: 'INSTRUCTION' }, + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe('You are a coder.'); + + const policyIdx = result.userMessage.indexOf('POLICY'); + const knowledgeIdx = result.userMessage.indexOf('KNOWLEDGE'); + const instructionIdx = result.userMessage.indexOf('INSTRUCTION'); + + expect(policyIdx).toBeLessThan(knowledgeIdx); + expect(knowledgeIdx).toBeLessThan(instructionIdx); + }); + + it('should join multiple policies with separator', () => { + const facets: FacetSet = { + policies: [ + { body: 'Policy A' }, + { body: 'Policy B' }, + ], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Policy A'); + expect(result.userMessage).toContain('---'); + expect(result.userMessage).toContain('Policy B'); + }); + + it('should join multiple knowledge items with separator', () => { + const facets: FacetSet = { + knowledge: [ + { body: 'Knowledge A' }, + { body: 'Knowledge B' }, + ], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Knowledge A'); + expect(result.userMessage).toContain('---'); + expect(result.userMessage).toContain('Knowledge B'); + }); + + it('should truncate policy content exceeding contextMaxChars', () => { + const longPolicy = 'x'.repeat(3000); + const facets: FacetSet = { + policies: [{ body: longPolicy, sourcePath: '/path/policy.md' }], + }; + + const result = compose(facets, { contextMaxChars: 2000 }); + + expect(result.userMessage).toContain('...TRUNCATED...'); + expect(result.userMessage).toContain('Policy is authoritative'); + }); + + it('should truncate knowledge content exceeding contextMaxChars', () => { + const longKnowledge = 'y'.repeat(3000); + const facets: FacetSet = { + knowledge: [{ body: longKnowledge, sourcePath: '/path/knowledge.md' }], + }; + + const result = compose(facets, { contextMaxChars: 2000 }); + + expect(result.userMessage).toContain('...TRUNCATED...'); + expect(result.userMessage).toContain('Knowledge is truncated'); + }); + + it('should handle empty facet set', () => { + const result = compose({}, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toBe(''); + }); + + it('should include source path for single policy', () => { + const facets: FacetSet = { + policies: [{ body: 'Policy text', sourcePath: '/policies/coding.md' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Policy Source: /policies/coding.md'); + }); + + it('should include source path for single knowledge', () => { + const facets: FacetSet = { + knowledge: [{ body: 'Knowledge text', sourcePath: '/knowledge/arch.md' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Knowledge Source: /knowledge/arch.md'); + }); +}); diff --git a/src/__tests__/faceted-prompting/data-engine.test.ts b/src/__tests__/faceted-prompting/data-engine.test.ts new file mode 100644 index 0000000..ac03efc --- /dev/null +++ b/src/__tests__/faceted-prompting/data-engine.test.ts @@ -0,0 +1,174 @@ +/** + * Unit tests for faceted-prompting DataEngine implementations. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { FileDataEngine, CompositeDataEngine } from '../../faceted-prompting/index.js'; +import type { DataEngine, FacetKind } from '../../faceted-prompting/index.js'; + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockReaddirSync = vi.mocked(readdirSync); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('FileDataEngine', () => { + const engine = new FileDataEngine('/root'); + + describe('resolve', () => { + it('should return FacetContent when file exists', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('persona body'); + + const result = await engine.resolve('personas', 'coder'); + expect(result).toEqual({ + body: 'persona body', + sourcePath: '/root/personas/coder.md', + }); + }); + + it('should return undefined when file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await engine.resolve('policies', 'missing'); + expect(result).toBeUndefined(); + }); + + it('should resolve correct directory for each facet kind', async () => { + const kinds: FacetKind[] = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts']; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('content'); + + for (const kind of kinds) { + const result = await engine.resolve(kind, 'test'); + expect(result?.sourcePath).toBe(`/root/${kind}/test.md`); + } + }); + }); + + describe('list', () => { + it('should return facet keys from directory', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue(['coder.md', 'architect.md', 'readme.txt'] as unknown as ReturnType); + + const result = await engine.list('personas'); + expect(result).toEqual(['coder', 'architect']); + }); + + it('should return empty array when directory does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await engine.list('policies'); + expect(result).toEqual([]); + }); + + it('should filter non-.md files', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue(['a.md', 'b.txt', 'c.md'] as unknown as ReturnType); + + const result = await engine.list('knowledge'); + expect(result).toEqual(['a', 'c']); + }); + }); +}); + +describe('CompositeDataEngine', () => { + it('should throw when constructed with empty engines array', () => { + expect(() => new CompositeDataEngine([])).toThrow( + 'CompositeDataEngine requires at least one engine', + ); + }); + + describe('resolve', () => { + it('should return result from first engine that resolves', async () => { + const engine1: DataEngine = { + resolve: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + }; + const engine2: DataEngine = { + resolve: vi.fn().mockResolvedValue({ body: 'from engine2', sourcePath: '/e2/p.md' }), + list: vi.fn().mockResolvedValue([]), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.resolve('personas', 'coder'); + + expect(result).toEqual({ body: 'from engine2', sourcePath: '/e2/p.md' }); + expect(engine1.resolve).toHaveBeenCalledWith('personas', 'coder'); + expect(engine2.resolve).toHaveBeenCalledWith('personas', 'coder'); + }); + + it('should return first match (first-wins)', async () => { + const engine1: DataEngine = { + resolve: vi.fn().mockResolvedValue({ body: 'from engine1' }), + list: vi.fn().mockResolvedValue([]), + }; + const engine2: DataEngine = { + resolve: vi.fn().mockResolvedValue({ body: 'from engine2' }), + list: vi.fn().mockResolvedValue([]), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.resolve('personas', 'coder'); + + expect(result?.body).toBe('from engine1'); + expect(engine2.resolve).not.toHaveBeenCalled(); + }); + + it('should return undefined when no engine resolves', async () => { + const engine1: DataEngine = { + resolve: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + }; + + const composite = new CompositeDataEngine([engine1]); + const result = await composite.resolve('policies', 'missing'); + + expect(result).toBeUndefined(); + }); + }); + + describe('list', () => { + it('should return deduplicated keys from all engines', async () => { + const engine1: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['a', 'b']), + }; + const engine2: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['b', 'c']), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.list('personas'); + + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should preserve order with first-seen priority', async () => { + const engine1: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['x', 'y']), + }; + const engine2: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['y', 'z']), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.list('knowledge'); + + expect(result).toEqual(['x', 'y', 'z']); + }); + }); +}); diff --git a/src/__tests__/faceted-prompting/escape.test.ts b/src/__tests__/faceted-prompting/escape.test.ts new file mode 100644 index 0000000..2fa6acd --- /dev/null +++ b/src/__tests__/faceted-prompting/escape.test.ts @@ -0,0 +1,30 @@ +/** + * Unit tests for faceted-prompting escape module. + */ + +import { describe, it, expect } from 'vitest'; +import { escapeTemplateChars } from '../../faceted-prompting/index.js'; + +describe('escapeTemplateChars', () => { + it('should replace curly braces with full-width equivalents', () => { + expect(escapeTemplateChars('{hello}')).toBe('\uff5bhello\uff5d'); + }); + + it('should handle multiple braces', () => { + expect(escapeTemplateChars('{{nested}}')).toBe('\uff5b\uff5bnested\uff5d\uff5d'); + }); + + it('should return unchanged string when no braces', () => { + expect(escapeTemplateChars('no braces here')).toBe('no braces here'); + }); + + it('should handle empty string', () => { + expect(escapeTemplateChars('')).toBe(''); + }); + + it('should handle braces in code snippets', () => { + const input = 'function foo() { return { a: 1 }; }'; + const expected = 'function foo() \uff5b return \uff5b a: 1 \uff5d; \uff5d'; + expect(escapeTemplateChars(input)).toBe(expected); + }); +}); diff --git a/src/__tests__/faceted-prompting/resolve.test.ts b/src/__tests__/faceted-prompting/resolve.test.ts new file mode 100644 index 0000000..13b4a08 --- /dev/null +++ b/src/__tests__/faceted-prompting/resolve.test.ts @@ -0,0 +1,287 @@ +/** + * Unit tests for faceted-prompting resolve module. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isResourcePath, + resolveFacetPath, + resolveFacetByName, + resolveResourcePath, + resolveResourceContent, + resolveRefToContent, + resolveRefList, + resolveSectionMap, + extractPersonaDisplayName, + resolvePersona, +} from '../../faceted-prompting/index.js'; + +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('isResourcePath', () => { + it('should return true for relative path with ./', () => { + expect(isResourcePath('./file.md')).toBe(true); + }); + + it('should return true for parent-relative path', () => { + expect(isResourcePath('../file.md')).toBe(true); + }); + + it('should return true for absolute path', () => { + expect(isResourcePath('/absolute/path.md')).toBe(true); + }); + + it('should return true for home-relative path', () => { + expect(isResourcePath('~/file.md')).toBe(true); + }); + + it('should return true for .md extension', () => { + expect(isResourcePath('some-file.md')).toBe(true); + }); + + it('should return false for a plain facet name', () => { + expect(isResourcePath('coding')).toBe(false); + }); + + it('should return false for a name with dots but not .md', () => { + expect(isResourcePath('my.config')).toBe(false); + }); +}); + +describe('resolveFacetPath', () => { + it('should return the first existing file path', () => { + mockExistsSync.mockImplementation((p) => p === '/dir1/coding.md'); + + const result = resolveFacetPath('coding', ['/dir1', '/dir2']); + expect(result).toBe('/dir1/coding.md'); + }); + + it('should skip non-existing directories and find in later ones', () => { + mockExistsSync.mockImplementation((p) => p === '/dir2/coding.md'); + + const result = resolveFacetPath('coding', ['/dir1', '/dir2']); + expect(result).toBe('/dir2/coding.md'); + }); + + it('should return undefined when not found in any directory', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveFacetPath('missing', ['/dir1', '/dir2']); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty candidate list', () => { + const result = resolveFacetPath('anything', []); + expect(result).toBeUndefined(); + }); +}); + +describe('resolveFacetByName', () => { + it('should return file content when facet exists', () => { + mockExistsSync.mockImplementation((p) => p === '/dir/coder.md'); + mockReadFileSync.mockReturnValue('You are a coder.'); + + const result = resolveFacetByName('coder', ['/dir']); + expect(result).toBe('You are a coder.'); + }); + + it('should return undefined when facet does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveFacetByName('missing', ['/dir']); + expect(result).toBeUndefined(); + }); +}); + +describe('resolveResourcePath', () => { + it('should resolve ./ relative to pieceDir', () => { + const result = resolveResourcePath('./policies/coding.md', '/project/pieces'); + expect(result).toBe(join('/project/pieces', 'policies/coding.md')); + }); + + it('should resolve ~ relative to homedir', () => { + const result = resolveResourcePath('~/policies/coding.md', '/project'); + expect(result).toBe(join(homedir(), 'policies/coding.md')); + }); + + it('should return absolute path unchanged', () => { + const result = resolveResourcePath('/absolute/path.md', '/project'); + expect(result).toBe('/absolute/path.md'); + }); + + it('should resolve plain name relative to pieceDir', () => { + const result = resolveResourcePath('coding.md', '/project/pieces'); + expect(result).toBe(join('/project/pieces', 'coding.md')); + }); +}); + +describe('resolveResourceContent', () => { + it('should return undefined for null/undefined spec', () => { + expect(resolveResourceContent(undefined, '/dir')).toBeUndefined(); + expect(resolveResourceContent(null as unknown as string | undefined, '/dir')).toBeUndefined(); + }); + + it('should read file content for .md spec when file exists', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('file content'); + + const result = resolveResourceContent('./policy.md', '/dir'); + expect(result).toBe('file content'); + }); + + it('should return spec as-is for .md spec when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveResourceContent('./policy.md', '/dir'); + expect(result).toBe('./policy.md'); + }); + + it('should return spec as-is for non-.md spec', () => { + const result = resolveResourceContent('inline content', '/dir'); + expect(result).toBe('inline content'); + }); +}); + +describe('resolveRefToContent', () => { + it('should return mapped content when found in resolvedMap', () => { + const result = resolveRefToContent('coding', { coding: 'mapped content' }, '/dir'); + expect(result).toBe('mapped content'); + }); + + it('should resolve resource path when ref is a resource path', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('file content'); + + const result = resolveRefToContent('./policy.md', undefined, '/dir'); + expect(result).toBe('file content'); + }); + + it('should try facet resolution via candidateDirs when ref is a name', () => { + mockExistsSync.mockImplementation((p) => p === '/facets/coding.md'); + mockReadFileSync.mockReturnValue('facet content'); + + const result = resolveRefToContent('coding', undefined, '/dir', ['/facets']); + expect(result).toBe('facet content'); + }); + + it('should fall back to resolveResourceContent when not found elsewhere', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveRefToContent('inline text', undefined, '/dir'); + expect(result).toBe('inline text'); + }); +}); + +describe('resolveRefList', () => { + it('should return undefined for null/undefined refs', () => { + expect(resolveRefList(undefined, undefined, '/dir')).toBeUndefined(); + }); + + it('should handle single string ref', () => { + const result = resolveRefList('inline', { inline: 'content' }, '/dir'); + expect(result).toEqual(['content']); + }); + + it('should handle array of refs', () => { + const result = resolveRefList( + ['a', 'b'], + { a: 'content A', b: 'content B' }, + '/dir', + ); + expect(result).toEqual(['content A', 'content B']); + }); + + it('should return undefined when no refs resolve', () => { + mockExistsSync.mockReturnValue(false); + const result = resolveRefList(['nonexistent.md'], undefined, '/dir'); + // 'nonexistent.md' ends with .md, file doesn't exist, falls back to spec + // But the spec is 'nonexistent.md' which is treated as inline + expect(result).toEqual(['nonexistent.md']); + }); +}); + +describe('resolveSectionMap', () => { + it('should return undefined for undefined input', () => { + expect(resolveSectionMap(undefined, '/dir')).toBeUndefined(); + }); + + it('should resolve each entry in the map', () => { + const result = resolveSectionMap( + { key1: 'inline value', key2: 'another value' }, + '/dir', + ); + expect(result).toEqual({ + key1: 'inline value', + key2: 'another value', + }); + }); +}); + +describe('extractPersonaDisplayName', () => { + it('should extract name from .md path', () => { + expect(extractPersonaDisplayName('coder.md')).toBe('coder'); + }); + + it('should extract name from full path', () => { + expect(extractPersonaDisplayName('/path/to/architect.md')).toBe('architect'); + }); + + it('should return name unchanged if no .md extension', () => { + expect(extractPersonaDisplayName('coder')).toBe('coder'); + }); +}); + +describe('resolvePersona', () => { + it('should return empty object for undefined persona', () => { + expect(resolvePersona(undefined, {}, '/dir')).toEqual({}); + }); + + it('should use section mapping when available', () => { + mockExistsSync.mockReturnValue(true); + + const result = resolvePersona( + 'coder', + { personas: { coder: './personas/coder.md' } }, + '/dir', + ); + expect(result.personaSpec).toBe('./personas/coder.md'); + expect(result.personaPath).toBeDefined(); + }); + + it('should resolve path-based persona directly', () => { + mockExistsSync.mockReturnValue(true); + + const result = resolvePersona('./coder.md', {}, '/dir'); + expect(result.personaSpec).toBe('./coder.md'); + expect(result.personaPath).toBeDefined(); + }); + + it('should try candidate directories for name-based persona', () => { + mockExistsSync.mockImplementation((p) => p === '/facets/coder.md'); + + const result = resolvePersona('coder', {}, '/dir', ['/facets']); + expect(result.personaSpec).toBe('coder'); + expect(result.personaPath).toBe('/facets/coder.md'); + }); + + it('should fall back to pieceDir resolution when no candidateDirs match', () => { + mockExistsSync.mockImplementation((p) => p === join('/dir', 'coder')); + + const result = resolvePersona('coder', {}, '/dir'); + expect(result.personaSpec).toBe('coder'); + }); +}); diff --git a/src/__tests__/faceted-prompting/template.test.ts b/src/__tests__/faceted-prompting/template.test.ts new file mode 100644 index 0000000..8e558a2 --- /dev/null +++ b/src/__tests__/faceted-prompting/template.test.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for faceted-prompting template engine. + */ + +import { describe, it, expect } from 'vitest'; +import { renderTemplate } from '../../faceted-prompting/index.js'; +import { + processConditionals, + substituteVariables, +} from '../../faceted-prompting/template.js'; + +describe('processConditionals', () => { + it('should include truthy block content', () => { + const template = '{{#if showGreeting}}Hello!{{/if}}'; + const result = processConditionals(template, { showGreeting: true }); + expect(result).toBe('Hello!'); + }); + + it('should exclude falsy block content', () => { + const template = '{{#if showGreeting}}Hello!{{/if}}'; + const result = processConditionals(template, { showGreeting: false }); + expect(result).toBe(''); + }); + + it('should handle else branch when truthy', () => { + const template = '{{#if isAdmin}}Admin panel{{else}}User panel{{/if}}'; + const result = processConditionals(template, { isAdmin: true }); + expect(result).toBe('Admin panel'); + }); + + it('should handle else branch when falsy', () => { + const template = '{{#if isAdmin}}Admin panel{{else}}User panel{{/if}}'; + const result = processConditionals(template, { isAdmin: false }); + expect(result).toBe('User panel'); + }); + + it('should treat non-empty string as truthy', () => { + const template = '{{#if name}}Name: provided{{/if}}'; + const result = processConditionals(template, { name: 'Alice' }); + expect(result).toBe('Name: provided'); + }); + + it('should treat empty string as falsy', () => { + const template = '{{#if name}}Name: provided{{/if}}'; + const result = processConditionals(template, { name: '' }); + expect(result).toBe(''); + }); + + it('should treat undefined variable as falsy', () => { + const template = '{{#if missing}}exists{{else}}missing{{/if}}'; + const result = processConditionals(template, {}); + expect(result).toBe('missing'); + }); + + it('should handle multiline content in blocks', () => { + const template = '{{#if hasContent}}line1\nline2\nline3{{/if}}'; + const result = processConditionals(template, { hasContent: true }); + expect(result).toBe('line1\nline2\nline3'); + }); +}); + +describe('substituteVariables', () => { + it('should replace variable with string value', () => { + const result = substituteVariables('Hello {{name}}!', { name: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('should replace true with string "true"', () => { + const result = substituteVariables('Value: {{flag}}', { flag: true }); + expect(result).toBe('Value: true'); + }); + + it('should replace false with empty string', () => { + const result = substituteVariables('Value: {{flag}}', { flag: false }); + expect(result).toBe('Value: '); + }); + + it('should replace undefined variable with empty string', () => { + const result = substituteVariables('Value: {{missing}}', {}); + expect(result).toBe('Value: '); + }); + + it('should handle multiple variables', () => { + const result = substituteVariables('{{greeting}} {{name}}!', { + greeting: 'Hello', + name: 'World', + }); + expect(result).toBe('Hello World!'); + }); +}); + +describe('renderTemplate', () => { + it('should process conditionals and then substitute variables', () => { + const template = '{{#if hasName}}Name: {{name}}{{else}}Anonymous{{/if}}'; + const result = renderTemplate(template, { hasName: true, name: 'Alice' }); + expect(result).toBe('Name: Alice'); + }); + + it('should handle template with no conditionals', () => { + const result = renderTemplate('Hello {{name}}!', { name: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('should handle template with no variables', () => { + const result = renderTemplate('Static text', {}); + expect(result).toBe('Static text'); + }); +}); diff --git a/src/__tests__/faceted-prompting/truncation.test.ts b/src/__tests__/faceted-prompting/truncation.test.ts new file mode 100644 index 0000000..9aa3a1a --- /dev/null +++ b/src/__tests__/faceted-prompting/truncation.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for faceted-prompting truncation module. + */ + +import { describe, it, expect } from 'vitest'; +import { + trimContextContent, + renderConflictNotice, + prepareKnowledgeContent, + preparePolicyContent, +} from '../../faceted-prompting/index.js'; + +describe('trimContextContent', () => { + it('should return content unchanged when under limit', () => { + const result = trimContextContent('short content', 100); + expect(result.content).toBe('short content'); + expect(result.truncated).toBe(false); + }); + + it('should truncate content exceeding limit', () => { + const longContent = 'a'.repeat(150); + const result = trimContextContent(longContent, 100); + expect(result.content).toBe('a'.repeat(100) + '\n...TRUNCATED...'); + expect(result.truncated).toBe(true); + }); + + it('should not truncate content at exact limit', () => { + const exactContent = 'b'.repeat(100); + const result = trimContextContent(exactContent, 100); + expect(result.content).toBe(exactContent); + expect(result.truncated).toBe(false); + }); +}); + +describe('renderConflictNotice', () => { + it('should return the standard conflict notice', () => { + const notice = renderConflictNotice(); + expect(notice).toBe('If prompt content conflicts with source files, source files take precedence.'); + }); +}); + +describe('prepareKnowledgeContent', () => { + it('should append conflict notice without sourcePath', () => { + const result = prepareKnowledgeContent('knowledge text', 2000); + expect(result).toContain('knowledge text'); + expect(result).toContain('If prompt content conflicts with source files'); + expect(result).not.toContain('Knowledge Source:'); + }); + + it('should append source path when provided', () => { + const result = prepareKnowledgeContent('knowledge text', 2000, '/path/to/knowledge.md'); + expect(result).toContain('Knowledge Source: /path/to/knowledge.md'); + expect(result).toContain('If prompt content conflicts with source files'); + }); + + it('should append truncation notice when truncated with sourcePath', () => { + const longContent = 'x'.repeat(3000); + const result = prepareKnowledgeContent(longContent, 2000, '/path/to/knowledge.md'); + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Knowledge is truncated. You MUST consult the source files before making decisions.'); + expect(result).toContain('Knowledge Source: /path/to/knowledge.md'); + }); + + it('should not include truncation notice when truncated without sourcePath', () => { + const longContent = 'x'.repeat(3000); + const result = prepareKnowledgeContent(longContent, 2000); + expect(result).toContain('...TRUNCATED...'); + expect(result).not.toContain('Knowledge is truncated'); + }); +}); + +describe('preparePolicyContent', () => { + it('should append conflict notice without sourcePath', () => { + const result = preparePolicyContent('policy text', 2000); + expect(result).toContain('policy text'); + expect(result).toContain('If prompt content conflicts with source files'); + expect(result).not.toContain('Policy Source:'); + }); + + it('should append source path when provided', () => { + const result = preparePolicyContent('policy text', 2000, '/path/to/policy.md'); + expect(result).toContain('Policy Source: /path/to/policy.md'); + expect(result).toContain('If prompt content conflicts with source files'); + }); + + it('should append authoritative notice when truncated with sourcePath', () => { + const longContent = 'y'.repeat(3000); + const result = preparePolicyContent(longContent, 2000, '/path/to/policy.md'); + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly.'); + expect(result).toContain('Policy Source: /path/to/policy.md'); + }); + + it('should not include authoritative notice when truncated without sourcePath', () => { + const longContent = 'y'.repeat(3000); + const result = preparePolicyContent(longContent, 2000); + expect(result).toContain('...TRUNCATED...'); + expect(result).not.toContain('Policy is authoritative'); + }); +}); diff --git a/src/__tests__/faceted-prompting/types.test.ts b/src/__tests__/faceted-prompting/types.test.ts new file mode 100644 index 0000000..6ad7c29 --- /dev/null +++ b/src/__tests__/faceted-prompting/types.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for faceted-prompting type definitions. + * + * Verifies that types are correctly exported and usable. + */ + +import { describe, it, expect } from 'vitest'; +import type { + FacetKind, + FacetContent, + FacetSet, + ComposedPrompt, + ComposeOptions, +} from '../../faceted-prompting/index.js'; + +describe('FacetKind type', () => { + it('should accept valid facet kinds', () => { + const kinds: FacetKind[] = [ + 'personas', + 'policies', + 'knowledge', + 'instructions', + 'output-contracts', + ]; + expect(kinds).toHaveLength(5); + }); +}); + +describe('FacetContent interface', () => { + it('should accept body with sourcePath', () => { + const content: FacetContent = { + body: 'You are a helpful assistant.', + sourcePath: '/path/to/persona.md', + }; + expect(content.body).toBe('You are a helpful assistant.'); + expect(content.sourcePath).toBe('/path/to/persona.md'); + }); + + it('should accept body without sourcePath', () => { + const content: FacetContent = { + body: 'Inline content', + }; + expect(content.body).toBe('Inline content'); + expect(content.sourcePath).toBeUndefined(); + }); +}); + +describe('FacetSet interface', () => { + it('should accept a complete facet set', () => { + const set: FacetSet = { + persona: { body: 'You are a coder.' }, + policies: [{ body: 'Follow clean code.' }], + knowledge: [{ body: 'Architecture docs.' }], + instruction: { body: 'Implement the feature.' }, + }; + expect(set.persona?.body).toBe('You are a coder.'); + expect(set.policies).toHaveLength(1); + }); + + it('should accept a partial facet set', () => { + const set: FacetSet = { + instruction: { body: 'Do the task.' }, + }; + expect(set.persona).toBeUndefined(); + expect(set.instruction?.body).toBe('Do the task.'); + }); +}); + +describe('ComposedPrompt interface', () => { + it('should hold systemPrompt and userMessage', () => { + const prompt: ComposedPrompt = { + systemPrompt: 'You are a coder.', + userMessage: 'Implement feature X.', + }; + expect(prompt.systemPrompt).toBe('You are a coder.'); + expect(prompt.userMessage).toBe('Implement feature X.'); + }); +}); + +describe('ComposeOptions interface', () => { + it('should hold contextMaxChars', () => { + const options: ComposeOptions = { + contextMaxChars: 2000, + }; + expect(options.contextMaxChars).toBe(2000); + }); +}); diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 1c39df9..4a9bd06 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -3,6 +3,9 @@ * * Builds the instruction string for main agent execution. * Assembles template variables and renders a single complete template. + * + * Truncation and context preparation are delegated to faceted-prompting. + * preparePreviousResponseContent is TAKT-specific and stays here. */ import type { PieceMovement, Language, OutputContractItem, OutputContractEntry } from '../../models/types.js'; @@ -10,62 +13,25 @@ import type { InstructionContext } from './instruction-context.js'; import { buildEditRule } from './instruction-context.js'; import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; +import { + trimContextContent, + renderConflictNotice, + prepareKnowledgeContent as prepareKnowledgeContentGeneric, + preparePolicyContent as preparePolicyContentGeneric, +} from '../../../faceted-prompting/index.js'; const CONTEXT_MAX_CHARS = 2000; -interface PreparedContextBlock { - readonly content: string; - readonly truncated: boolean; -} - -function trimContextContent(content: string): PreparedContextBlock { - if (content.length <= CONTEXT_MAX_CHARS) { - return { content, truncated: false }; - } - return { - content: `${content.slice(0, CONTEXT_MAX_CHARS)}\n...TRUNCATED...`, - truncated: true, - }; -} - -function renderConflictNotice(): string { - return 'If prompt content conflicts with source files, source files take precedence.'; -} - function prepareKnowledgeContent(content: string, sourcePath?: string): string { - const prepared = trimContextContent(content); - const lines: string[] = [prepared.content]; - if (prepared.truncated && sourcePath) { - lines.push( - '', - `Knowledge is truncated. You MUST consult the source files before making decisions. Source: ${sourcePath}`, - ); - } - if (sourcePath) { - lines.push('', `Knowledge Source: ${sourcePath}`); - } - lines.push('', renderConflictNotice()); - return lines.join('\n'); + return prepareKnowledgeContentGeneric(content, CONTEXT_MAX_CHARS, sourcePath); } function preparePolicyContent(content: string, sourcePath?: string): string { - const prepared = trimContextContent(content); - const lines: string[] = [prepared.content]; - if (prepared.truncated && sourcePath) { - lines.push( - '', - `Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly. Source: ${sourcePath}`, - ); - } - if (sourcePath) { - lines.push('', `Policy Source: ${sourcePath}`); - } - lines.push('', renderConflictNotice()); - return lines.join('\n'); + return preparePolicyContentGeneric(content, CONTEXT_MAX_CHARS, sourcePath); } function preparePreviousResponseContent(content: string, sourcePath?: string): string { - const prepared = trimContextContent(content); + const prepared = trimContextContent(content, CONTEXT_MAX_CHARS); const lines: string[] = [prepared.content]; if (prepared.truncated && sourcePath) { lines.push('', `Previous Response is truncated. Source: ${sourcePath}`); diff --git a/src/core/piece/instruction/escape.ts b/src/core/piece/instruction/escape.ts index 9b4fcdd..a796a90 100644 --- a/src/core/piece/instruction/escape.ts +++ b/src/core/piece/instruction/escape.ts @@ -2,17 +2,16 @@ * Template escaping and placeholder replacement utilities * * Used by instruction builders to process instruction_template content. + * + * escapeTemplateChars is re-exported from faceted-prompting. + * replaceTemplatePlaceholders is TAKT-specific and stays here. */ import type { PieceMovement } from '../../models/types.js'; import type { InstructionContext } from './instruction-context.js'; +import { escapeTemplateChars } from '../../../faceted-prompting/index.js'; -/** - * Escape special characters in dynamic content to prevent template injection. - */ -export function escapeTemplateChars(str: string): string { - return str.replace(/\{/g, '{').replace(/\}/g, '}'); -} +export { escapeTemplateChars } from '../../../faceted-prompting/index.js'; /** * Replace template placeholders in the instruction_template body. diff --git a/src/faceted-prompting/compose.ts b/src/faceted-prompting/compose.ts new file mode 100644 index 0000000..ae48359 --- /dev/null +++ b/src/faceted-prompting/compose.ts @@ -0,0 +1,56 @@ +/** + * Facet composition — the core placement rule. + * + * system prompt: persona only (WHO) + * user message: policy + knowledge + instruction (HOW / WHAT TO KNOW / WHAT TO DO) + * + * This module has ZERO dependencies on TAKT internals. + */ + +import type { FacetSet, ComposedPrompt, ComposeOptions } from './types.js'; +import { prepareKnowledgeContent, preparePolicyContent } from './truncation.js'; + +/** + * Compose facets into an LLM-ready prompt according to Faceted Prompting + * placement rules. + * + * - persona → systemPrompt + * - policy / knowledge / instruction → userMessage (in that order) + */ +export function compose(facets: FacetSet, options: ComposeOptions): ComposedPrompt { + const systemPrompt = facets.persona?.body ?? ''; + + const userParts: string[] = []; + + // Policy (HOW) + if (facets.policies && facets.policies.length > 0) { + const joined = facets.policies.map(p => p.body).join('\n\n---\n\n'); + const sourcePath = facets.policies.length === 1 + ? facets.policies[0]!.sourcePath + : undefined; + userParts.push( + preparePolicyContent(joined, options.contextMaxChars, sourcePath), + ); + } + + // Knowledge (WHAT TO KNOW) + if (facets.knowledge && facets.knowledge.length > 0) { + const joined = facets.knowledge.map(k => k.body).join('\n\n---\n\n'); + const sourcePath = facets.knowledge.length === 1 + ? facets.knowledge[0]!.sourcePath + : undefined; + userParts.push( + prepareKnowledgeContent(joined, options.contextMaxChars, sourcePath), + ); + } + + // Instruction (WHAT TO DO) + if (facets.instruction) { + userParts.push(facets.instruction.body); + } + + return { + systemPrompt, + userMessage: userParts.join('\n\n'), + }; +} diff --git a/src/faceted-prompting/data-engine.ts b/src/faceted-prompting/data-engine.ts new file mode 100644 index 0000000..40d07a5 --- /dev/null +++ b/src/faceted-prompting/data-engine.ts @@ -0,0 +1,103 @@ +/** + * DataEngine — abstract interface for facet data retrieval. + * + * Compose logic depends only on this interface; callers wire + * concrete implementations (FileDataEngine, SqliteDataEngine, etc.). + * + * This module depends only on node:fs, node:path. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { FacetKind, FacetContent } from './types.js'; + +/** Plural-kind to directory name mapping (identity for all current kinds). */ +const KIND_DIR: Record = { + personas: 'personas', + policies: 'policies', + knowledge: 'knowledge', + instructions: 'instructions', + 'output-contracts': 'output-contracts', +}; + +/** + * Abstract interface for facet data retrieval. + * + * Methods return Promises so that implementations backed by + * async I/O (database, network) can be used without changes. + */ +export interface DataEngine { + /** + * Resolve a single facet by kind and key (name without extension). + * Returns undefined if the facet does not exist. + */ + resolve(kind: FacetKind, key: string): Promise; + + /** List available facet keys for a given kind. */ + list(kind: FacetKind): Promise; +} + +/** + * File-system backed DataEngine. + * + * Resolves facets from a single root directory using the convention: + * {root}/{kind}/{key}.md + */ +export class FileDataEngine implements DataEngine { + constructor(private readonly root: string) {} + + async resolve(kind: FacetKind, key: string): Promise { + const dir = KIND_DIR[kind]; + const filePath = join(this.root, dir, `${key}.md`); + if (!existsSync(filePath)) return undefined; + const body = readFileSync(filePath, 'utf-8'); + return { body, sourcePath: filePath }; + } + + async list(kind: FacetKind): Promise { + const dir = KIND_DIR[kind]; + const dirPath = join(this.root, dir); + if (!existsSync(dirPath)) return []; + return readdirSync(dirPath) + .filter(f => f.endsWith('.md')) + .map(f => f.slice(0, -3)); + } +} + +/** + * Chains multiple DataEngines with first-match-wins resolution. + * + * resolve() returns the first non-undefined result. + * list() returns deduplicated keys from all engines. + */ +export class CompositeDataEngine implements DataEngine { + constructor(private readonly engines: readonly DataEngine[]) { + if (engines.length === 0) { + throw new Error('CompositeDataEngine requires at least one engine'); + } + } + + async resolve(kind: FacetKind, key: string): Promise { + for (const engine of this.engines) { + const result = await engine.resolve(kind, key); + if (result !== undefined) return result; + } + return undefined; + } + + async list(kind: FacetKind): Promise { + const seen = new Set(); + const result: string[] = []; + for (const engine of this.engines) { + const keys = await engine.list(kind); + for (const key of keys) { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + } + return result; + } +} diff --git a/src/faceted-prompting/escape.ts b/src/faceted-prompting/escape.ts new file mode 100644 index 0000000..61fec3b --- /dev/null +++ b/src/faceted-prompting/escape.ts @@ -0,0 +1,16 @@ +/** + * Template injection prevention. + * + * Escapes curly braces in dynamic content so they are not + * interpreted as template variables by the template engine. + * + * This module has ZERO dependencies on TAKT internals. + */ + +/** + * Replace ASCII curly braces with full-width equivalents + * to prevent template variable injection in user-supplied content. + */ +export function escapeTemplateChars(str: string): string { + return str.replace(/\{/g, '\uff5b').replace(/\}/g, '\uff5d'); +} diff --git a/src/faceted-prompting/index.ts b/src/faceted-prompting/index.ts new file mode 100644 index 0000000..c50353a --- /dev/null +++ b/src/faceted-prompting/index.ts @@ -0,0 +1,51 @@ +/** + * faceted-prompting — Public API + * + * Re-exports all public types, interfaces, and functions. + * Consumers should import from this module only. + */ + +// Types +export type { + FacetKind, + FacetContent, + FacetSet, + ComposedPrompt, + ComposeOptions, +} from './types.js'; + +// Compose +export { compose } from './compose.js'; + +// DataEngine +export type { DataEngine } from './data-engine.js'; +export { FileDataEngine, CompositeDataEngine } from './data-engine.js'; + +// Truncation +export { + trimContextContent, + renderConflictNotice, + prepareKnowledgeContent, + preparePolicyContent, +} from './truncation.js'; + +// Template engine +export { renderTemplate } from './template.js'; + +// Escape +export { escapeTemplateChars } from './escape.js'; + +// Resolve +export type { PieceSections } from './resolve.js'; +export { + isResourcePath, + resolveFacetPath, + resolveFacetByName, + resolveResourcePath, + resolveResourceContent, + resolveRefToContent, + resolveRefList, + resolveSectionMap, + extractPersonaDisplayName, + resolvePersona, +} from './resolve.js'; diff --git a/src/faceted-prompting/resolve.ts b/src/faceted-prompting/resolve.ts new file mode 100644 index 0000000..8ebeeb7 --- /dev/null +++ b/src/faceted-prompting/resolve.ts @@ -0,0 +1,208 @@ +/** + * Facet reference resolution utilities. + * + * Resolves facet names / paths / content from section maps + * and candidate directories. Directory construction is delegated + * to the caller (TAKT provides project/global/builtin dirs). + * + * This module depends only on node:fs, node:os, node:path. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, basename } from 'node:path'; + +/** Pre-resolved section maps passed to movement normalization. */ +export interface PieceSections { + /** Persona name -> file path (raw, not content-resolved) */ + personas?: Record; + /** Policy name -> resolved content */ + resolvedPolicies?: Record; + /** Knowledge name -> resolved content */ + resolvedKnowledge?: Record; + /** Instruction name -> resolved content */ + resolvedInstructions?: Record; + /** Report format name -> resolved content */ + resolvedReportFormats?: Record; +} + +/** + * Check if a spec looks like a resource path (vs. a facet name). + * Paths start with './', '../', '/', '~' or end with '.md'. + */ +export function isResourcePath(spec: string): boolean { + return ( + spec.startsWith('./') || + spec.startsWith('../') || + spec.startsWith('/') || + spec.startsWith('~') || + spec.endsWith('.md') + ); +} + +/** + * Resolve a facet name to its file path by scanning candidate directories. + * + * The caller builds the candidate list (e.g. project/.takt/{kind}, + * ~/.takt/{kind}, builtins/{lang}/{kind}) and passes it in. + * + * @returns Absolute file path if found, undefined otherwise. + */ +export function resolveFacetPath( + name: string, + candidateDirs: readonly string[], +): string | undefined { + for (const dir of candidateDirs) { + const filePath = join(dir, `${name}.md`); + if (existsSync(filePath)) { + return filePath; + } + } + return undefined; +} + +/** + * Resolve a facet name to its file content via candidate directories. + * + * @returns File content if found, undefined otherwise. + */ +export function resolveFacetByName( + name: string, + candidateDirs: readonly string[], +): string | undefined { + const filePath = resolveFacetPath(name, candidateDirs); + if (filePath) { + return readFileSync(filePath, 'utf-8'); + } + return undefined; +} + +/** Resolve a resource spec to an absolute file path. */ +export 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); +} + +/** + * 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). + */ +export 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 path resolution. + * If candidateDirs are provided and ref is a name (not a path), + * falls back to facet resolution via candidate directories. + */ +export function resolveRefToContent( + ref: string, + resolvedMap: Record | undefined, + pieceDir: string, + candidateDirs?: readonly string[], +): string | undefined { + const mapped = resolvedMap?.[ref]; + if (mapped) return mapped; + + if (isResourcePath(ref)) { + return resolveResourceContent(ref, pieceDir); + } + + if (candidateDirs) { + const facetContent = resolveFacetByName(ref, candidateDirs); + if (facetContent !== undefined) return facetContent; + } + + return resolveResourceContent(ref, pieceDir); +} + +/** Resolve multiple references to content strings (for fields that accept string | string[]). */ +export function resolveRefList( + refs: string | string[] | undefined, + resolvedMap: Record | undefined, + pieceDir: string, + candidateDirs?: readonly 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, candidateDirs); + 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). */ +export 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"). */ +export function extractPersonaDisplayName(personaPath: string): string { + return basename(personaPath, '.md'); +} + +/** + * Resolve persona from YAML field to spec + absolute path. + * + * Candidate directories for name-based lookup are provided by the caller. + */ +export function resolvePersona( + rawPersona: string | undefined, + sections: PieceSections, + pieceDir: string, + candidateDirs?: readonly string[], +): { personaSpec?: string; personaPath?: string } { + if (!rawPersona) return {}; + + // If section map has explicit mapping, use it (path-based) + const sectionMapping = sections.personas?.[rawPersona]; + if (sectionMapping) { + const resolved = resolveResourcePath(sectionMapping, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: sectionMapping, personaPath }; + } + + // If rawPersona is a path, resolve it directly + if (isResourcePath(rawPersona)) { + const resolved = resolveResourcePath(rawPersona, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: rawPersona, personaPath }; + } + + // Name-based: try candidate directories + if (candidateDirs) { + const filePath = resolveFacetPath(rawPersona, candidateDirs); + if (filePath) { + return { personaSpec: rawPersona, personaPath: filePath }; + } + } + + // Fallback: try as relative path from pieceDir + const resolved = resolveResourcePath(rawPersona, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: rawPersona, personaPath }; +} diff --git a/src/faceted-prompting/template.ts b/src/faceted-prompting/template.ts new file mode 100644 index 0000000..78d21f5 --- /dev/null +++ b/src/faceted-prompting/template.ts @@ -0,0 +1,65 @@ +/** + * Minimal template engine for Markdown prompt templates. + * + * Supports: + * - {{#if variable}}...{{else}}...{{/if}} conditional blocks (no nesting) + * - {{variableName}} substitution + * + * This module has ZERO dependencies on TAKT internals. + */ + +/** + * Process {{#if variable}}...{{else}}...{{/if}} conditional blocks. + * + * A variable is truthy when it is a non-empty string or boolean true. + * Nesting is NOT supported. + */ +export function processConditionals( + template: string, + vars: Record, +): string { + return template.replace( + /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, varName: string, body: string): string => { + const value = vars[varName]; + const isTruthy = value !== undefined && value !== false && value !== ''; + + const elseIndex = body.indexOf('{{else}}'); + if (isTruthy) { + return elseIndex >= 0 ? body.slice(0, elseIndex) : body; + } + return elseIndex >= 0 ? body.slice(elseIndex + '{{else}}'.length) : ''; + }, + ); +} + +/** + * Replace {{variableName}} placeholders with values from vars. + * Undefined or false variables are replaced with empty string. + * True is replaced with the string "true". + */ +export function substituteVariables( + template: string, + vars: Record, +): string { + return template.replace( + /\{\{(\w+)\}\}/g, + (_match, varName: string) => { + const value = vars[varName]; + if (value === undefined || value === false) return ''; + if (value === true) return 'true'; + return value; + }, + ); +} + +/** + * Render a template string by processing conditionals then substituting variables. + */ +export function renderTemplate( + template: string, + vars: Record, +): string { + const afterConditionals = processConditionals(template, vars); + return substituteVariables(afterConditionals, vars); +} diff --git a/src/faceted-prompting/truncation.ts b/src/faceted-prompting/truncation.ts new file mode 100644 index 0000000..ff2c655 --- /dev/null +++ b/src/faceted-prompting/truncation.ts @@ -0,0 +1,88 @@ +/** + * Context truncation for knowledge and policy facets. + * + * When facet content exceeds a character limit, it is trimmed and + * annotated with source-path metadata so the LLM can consult the + * original file. + * + * This module has ZERO dependencies on TAKT internals. + */ + +interface PreparedContextBlock { + readonly content: string; + readonly truncated: boolean; +} + +/** + * Trim content to a maximum character length, appending a + * "...TRUNCATED..." marker when truncation occurs. + */ +export function trimContextContent( + content: string, + maxChars: number, +): PreparedContextBlock { + if (content.length <= maxChars) { + return { content, truncated: false }; + } + return { + content: `${content.slice(0, maxChars)}\n...TRUNCATED...`, + truncated: true, + }; +} + +/** + * Standard notice appended to knowledge and policy blocks. + */ +export function renderConflictNotice(): string { + return 'If prompt content conflicts with source files, source files take precedence.'; +} + +/** + * Prepare a knowledge facet for inclusion in a prompt. + * + * Trims to maxChars, appends truncation notice and source path if available. + */ +export function prepareKnowledgeContent( + content: string, + maxChars: number, + sourcePath?: string, +): string { + const prepared = trimContextContent(content, maxChars); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Knowledge is truncated. You MUST consult the source files before making decisions. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Knowledge Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + +/** + * Prepare a policy facet for inclusion in a prompt. + * + * Trims to maxChars, appends authoritative-source notice and source path if available. + */ +export function preparePolicyContent( + content: string, + maxChars: number, + sourcePath?: string, +): string { + const prepared = trimContextContent(content, maxChars); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Policy Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} diff --git a/src/faceted-prompting/types.ts b/src/faceted-prompting/types.ts new file mode 100644 index 0000000..ff9cb17 --- /dev/null +++ b/src/faceted-prompting/types.ts @@ -0,0 +1,53 @@ +/** + * Core type definitions for Faceted Prompting. + * + * Defines the vocabulary of facets (persona, policy, knowledge, instruction, + * output-contract) and the structures used by compose() and DataEngine. + * + * This module has ZERO dependencies on TAKT internals. + */ + +/** Plural directory names used in facet resolution. */ +export type FacetKind = + | 'personas' + | 'policies' + | 'knowledge' + | 'instructions' + | 'output-contracts'; + +/** A single piece of facet content with optional metadata. */ +export interface FacetContent { + /** Raw text body of the facet. */ + readonly body: string; + /** Filesystem path the content was loaded from, if applicable. */ + readonly sourcePath?: string; +} + +/** + * A complete set of resolved facet contents to be composed. + * + * All fields are optional — a FacetSet may contain only a subset of facets. + */ +export interface FacetSet { + readonly persona?: FacetContent; + readonly policies?: readonly FacetContent[]; + readonly knowledge?: readonly FacetContent[]; + readonly instruction?: FacetContent; +} + +/** + * The output of compose(): facet content assigned to LLM message slots. + * + * persona → systemPrompt + * policy + knowledge + instruction → userMessage + */ +export interface ComposedPrompt { + readonly systemPrompt: string; + readonly userMessage: string; +} + +/** Options controlling compose() behaviour. */ +export interface ComposeOptions { + /** Maximum character length for knowledge/policy content before truncation. */ + readonly contextMaxChars: number; +} diff --git a/src/infra/config/loaders/resource-resolver.ts b/src/infra/config/loaders/resource-resolver.ts index 05a2765..f819944 100644 --- a/src/infra/config/loaders/resource-resolver.ts +++ b/src/infra/config/loaders/resource-resolver.ts @@ -1,50 +1,54 @@ /** * Resource resolution helpers for piece YAML parsing. * - * Resolves file paths, content references, and persona specs - * from piece-level section maps. Supports 3-layer facet resolution - * (project → user → builtin). + * Facade: delegates to faceted-prompting/resolve.ts and re-exports + * its types/functions. resolveFacetPath and resolveFacetByName build + * TAKT-specific candidate directories then delegate to the generic + * implementation. */ -import { readFileSync, existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join, basename } from 'node:path'; import type { Language } from '../../../core/models/index.js'; import type { FacetType } from '../paths.js'; import { getProjectFacetDir, getGlobalFacetDir, getBuiltinFacetDir } from '../paths.js'; -/** Context for 3-layer facet resolution. */ +import { + resolveFacetPath as resolveFacetPathGeneric, + resolveFacetByName as resolveFacetByNameGeneric, + resolveRefToContent as resolveRefToContentGeneric, + resolveRefList as resolveRefListGeneric, + resolvePersona as resolvePersonaGeneric, +} from '../../../faceted-prompting/index.js'; + +// Re-export types and pure functions that need no TAKT wrapping +export type { PieceSections } from '../../../faceted-prompting/index.js'; +export { + isResourcePath, + resolveResourcePath, + resolveResourceContent, + resolveSectionMap, + extractPersonaDisplayName, +} from '../../../faceted-prompting/index.js'; + +/** Context for 3-layer facet resolution (TAKT-specific). */ export interface FacetResolutionContext { projectDir?: string; lang: Language; } -/** Pre-resolved section maps passed to movement normalization. */ -export interface PieceSections { - /** Persona name → file path (raw, not content-resolved) */ - personas?: Record; - /** Policy name → resolved content */ - resolvedPolicies?: Record; - /** Knowledge name → resolved content */ - resolvedKnowledge?: Record; - /** Instruction name → resolved content */ - resolvedInstructions?: Record; - /** Report format name → resolved content */ - resolvedReportFormats?: Record; -} - /** - * Check if a spec looks like a resource path (vs. a facet name). - * Paths start with './', '../', '/', '~' or end with '.md'. + * Build TAKT-specific candidate directories for a facet type. */ -export function isResourcePath(spec: string): boolean { - return ( - spec.startsWith('./') || - spec.startsWith('../') || - spec.startsWith('/') || - spec.startsWith('~') || - spec.endsWith('.md') - ); +function buildCandidateDirs( + facetType: FacetType, + context: FacetResolutionContext, +): string[] { + const dirs: string[] = []; + if (context.projectDir) { + dirs.push(getProjectFacetDir(context.projectDir, facetType)); + } + dirs.push(getGlobalFacetDir(facetType)); + dirs.push(getBuiltinFacetDir(context.lang, facetType)); + return dirs; } /** @@ -62,20 +66,7 @@ export function resolveFacetPath( facetType: FacetType, context: FacetResolutionContext, ): string | undefined { - const candidateDirs = [ - ...(context.projectDir ? [getProjectFacetDir(context.projectDir, facetType)] : []), - getGlobalFacetDir(facetType), - getBuiltinFacetDir(context.lang, facetType), - ]; - - for (const dir of candidateDirs) { - const filePath = join(dir, `${name}.md`); - if (existsSync(filePath)) { - return filePath; - } - } - - return undefined; + return resolveFacetPathGeneric(name, buildCandidateDirs(facetType, context)); } /** @@ -88,33 +79,7 @@ export function resolveFacetByName( facetType: FacetType, context: FacetResolutionContext, ): string | undefined { - const filePath = resolveFacetPath(name, facetType, context); - if (filePath) { - return readFileSync(filePath, 'utf-8'); - } - return undefined; -} - -/** Resolve a resource spec to an absolute file path. */ -export 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); -} - -/** - * 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). - */ -export 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; + return resolveFacetByNameGeneric(name, buildCandidateDirs(facetType, context)); } /** @@ -130,19 +95,10 @@ export function resolveRefToContent( facetType?: FacetType, context?: FacetResolutionContext, ): string | undefined { - const mapped = resolvedMap?.[ref]; - if (mapped) return mapped; - - if (isResourcePath(ref)) { - return resolveResourceContent(ref, pieceDir); - } - - if (facetType && context) { - const facetContent = resolveFacetByName(ref, facetType, context); - if (facetContent !== undefined) return facetContent; - } - - return resolveResourceContent(ref, pieceDir); + const candidateDirs = facetType && context + ? buildCandidateDirs(facetType, context) + : undefined; + return resolveRefToContentGeneric(ref, resolvedMap, pieceDir, candidateDirs); } /** Resolve multiple references to content strings (for fields that accept string | string[]). */ @@ -153,69 +109,21 @@ export function resolveRefList( facetType?: FacetType, context?: FacetResolutionContext, ): 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, facetType, context); - 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). */ -export 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"). */ -export function extractPersonaDisplayName(personaPath: string): string { - return basename(personaPath, '.md'); + const candidateDirs = facetType && context + ? buildCandidateDirs(facetType, context) + : undefined; + return resolveRefListGeneric(refs, resolvedMap, pieceDir, candidateDirs); } /** Resolve persona from YAML field to spec + absolute path. */ export function resolvePersona( rawPersona: string | undefined, - sections: PieceSections, + sections: import('../../../faceted-prompting/index.js').PieceSections, pieceDir: string, context?: FacetResolutionContext, ): { personaSpec?: string; personaPath?: string } { - if (!rawPersona) return {}; - - // If section map has explicit mapping, use it (path-based) - const sectionMapping = sections.personas?.[rawPersona]; - if (sectionMapping) { - const resolved = resolveResourcePath(sectionMapping, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec: sectionMapping, personaPath }; - } - - // If rawPersona is a path, resolve it directly - if (isResourcePath(rawPersona)) { - const resolved = resolveResourcePath(rawPersona, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec: rawPersona, personaPath }; - } - - // Name-based: try 3-layer resolution to find the persona file - if (context) { - const filePath = resolveFacetPath(rawPersona, 'personas', context); - if (filePath) { - return { personaSpec: rawPersona, personaPath: filePath }; - } - } - - // Fallback: try as relative path from pieceDir (backward compat) - const resolved = resolveResourcePath(rawPersona, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec: rawPersona, personaPath }; + const candidateDirs = context + ? buildCandidateDirs('personas', context) + : undefined; + return resolvePersonaGeneric(rawPersona, sections, pieceDir, candidateDirs); } diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index c806d25..ec29c58 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -11,8 +11,12 @@ import { existsSync, mkdirSync } from 'node:fs'; import type { Language } from '../../core/models/index.js'; import { getLanguageResourcesDir } from '../resources/index.js'; +import type { FacetKind } from '../../faceted-prompting/index.js'; + /** Facet types used in layer resolution */ -export type FacetType = 'personas' | 'policies' | 'knowledge' | 'instructions' | 'output-contracts'; +export type { FacetKind as FacetType } from '../../faceted-prompting/index.js'; + +type FacetType = FacetKind; /** Get takt global config directory (~/.takt or TAKT_CONFIG_DIR) */ export function getGlobalConfigDir(): string { diff --git a/src/shared/prompts/index.ts b/src/shared/prompts/index.ts index 696fe67..7d1e857 100644 --- a/src/shared/prompts/index.ts +++ b/src/shared/prompts/index.ts @@ -7,12 +7,18 @@ * * Templates are organized in language subdirectories: * {lang}/{name}.md — localized templates + * + * Template engine functions (processConditionals, substituteVariables, + * renderTemplate) are delegated to faceted-prompting. */ import { existsSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Language } from '../../core/models/types.js'; +import { renderTemplate } from '../../faceted-prompting/index.js'; + +export { renderTemplate } from '../../faceted-prompting/index.js'; /** Cached raw template text (before variable substitution) */ const templateCache = new Map(); @@ -56,66 +62,6 @@ function readTemplate(filePath: string): string { return content; } -/** - * Process {{#if variable}}...{{else}}...{{/if}} conditional blocks. - * - * A variable is truthy when: - * - It is a non-empty string - * - It is boolean true - * - * Nesting is NOT supported (per architecture decision). - */ -function processConditionals( - template: string, - vars: Record, -): string { - // Pattern: {{#if varName}}...content...{{else}}...altContent...{{/if}} - // or: {{#if varName}}...content...{{/if}} - return template.replace( - /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, - (_match, varName: string, body: string): string => { - const value = vars[varName]; - const isTruthy = value !== undefined && value !== false && value !== ''; - - const elseIndex = body.indexOf('{{else}}'); - if (isTruthy) { - return elseIndex >= 0 ? body.slice(0, elseIndex) : body; - } - return elseIndex >= 0 ? body.slice(elseIndex + '{{else}}'.length) : ''; - }, - ); -} - -/** - * Replace {{variableName}} placeholders with values from vars. - * Undefined variables are replaced with empty string. - */ -function substituteVariables( - template: string, - vars: Record, -): string { - return template.replace( - /\{\{(\w+)\}\}/g, - (_match, varName: string) => { - const value = vars[varName]; - if (value === undefined || value === false) return ''; - if (value === true) return 'true'; - return value; - }, - ); -} - -/** - * Render a template string by processing conditionals then substituting variables. - */ -export function renderTemplate( - template: string, - vars: Record, -): string { - const afterConditionals = processConditionals(template, vars); - return substituteVariables(afterConditionals, vars); -} - /** * Load a Markdown template, apply variable substitution and conditional blocks. *