[draft] takt/284/implement-using-only-the-files (#296)

* feat: track project-level .takt/pieces in version control

* feat: track project-level takt facets for customizable resources

* chore: include project .takt/config.yaml in git-tracked subset

* takt: github-issue-284-faceted-prompting
This commit is contained in:
nrs 2026-02-18 23:21:09 +09:00 committed by GitHub
parent 2313b3985f
commit 0d1da61d14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1662 additions and 255 deletions

View File

@ -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');
});
});

View File

@ -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<typeof readdirSync>);
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<typeof readdirSync>);
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']);
});
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -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}`);

View File

@ -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.

View File

@ -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'),
};
}

View File

@ -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<FacetKind, string> = {
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<FacetContent | undefined>;
/** List available facet keys for a given kind. */
list(kind: FacetKind): Promise<string[]>;
}
/**
* 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<FacetContent | undefined> {
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<string[]> {
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<FacetContent | undefined> {
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<string[]> {
const seen = new Set<string>();
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;
}
}

View File

@ -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');
}

View File

@ -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';

View File

@ -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<string, string>;
/** Policy name -> resolved content */
resolvedPolicies?: Record<string, string>;
/** Knowledge name -> resolved content */
resolvedKnowledge?: Record<string, string>;
/** Instruction name -> resolved content */
resolvedInstructions?: Record<string, string>;
/** Report format name -> resolved content */
resolvedReportFormats?: Record<string, string>;
}
/**
* 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<string, string> | 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<string, string> | 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<string, string> | undefined,
pieceDir: string,
): Record<string, string> | undefined {
if (!raw) return undefined;
const resolved: Record<string, string> = {};
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 };
}

View File

@ -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, string | boolean>,
): 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, string | boolean>,
): 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, string | boolean>,
): string {
const afterConditionals = processConditionals(template, vars);
return substituteVariables(afterConditionals, vars);
}

View File

@ -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');
}

View File

@ -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;
}

View File

@ -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<string, string>;
/** Policy name → resolved content */
resolvedPolicies?: Record<string, string>;
/** Knowledge name → resolved content */
resolvedKnowledge?: Record<string, string>;
/** Instruction name → resolved content */
resolvedInstructions?: Record<string, string>;
/** Report format name → resolved content */
resolvedReportFormats?: Record<string, string>;
}
/**
* 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<string, string> | undefined,
pieceDir: string,
): Record<string, string> | undefined {
if (!raw) return undefined;
const resolved: Record<string, string> = {};
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);
}

View File

@ -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 {

View File

@ -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<string, string>();
@ -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, string | boolean>,
): 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, string | boolean>,
): 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, string | boolean>,
): string {
const afterConditionals = processConditionals(template, vars);
return substituteVariables(afterConditionals, vars);
}
/**
* Load a Markdown template, apply variable substitution and conditional blocks.
*