[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:
parent
2313b3985f
commit
0d1da61d14
158
src/__tests__/faceted-prompting/compose.test.ts
Normal file
158
src/__tests__/faceted-prompting/compose.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
174
src/__tests__/faceted-prompting/data-engine.test.ts
Normal file
174
src/__tests__/faceted-prompting/data-engine.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/__tests__/faceted-prompting/escape.test.ts
Normal file
30
src/__tests__/faceted-prompting/escape.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
287
src/__tests__/faceted-prompting/resolve.test.ts
Normal file
287
src/__tests__/faceted-prompting/resolve.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
108
src/__tests__/faceted-prompting/template.test.ts
Normal file
108
src/__tests__/faceted-prompting/template.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
100
src/__tests__/faceted-prompting/truncation.test.ts
Normal file
100
src/__tests__/faceted-prompting/truncation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
87
src/__tests__/faceted-prompting/types.test.ts
Normal file
87
src/__tests__/faceted-prompting/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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}`);
|
||||
|
||||
@ -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.
|
||||
|
||||
56
src/faceted-prompting/compose.ts
Normal file
56
src/faceted-prompting/compose.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
103
src/faceted-prompting/data-engine.ts
Normal file
103
src/faceted-prompting/data-engine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/faceted-prompting/escape.ts
Normal file
16
src/faceted-prompting/escape.ts
Normal 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');
|
||||
}
|
||||
51
src/faceted-prompting/index.ts
Normal file
51
src/faceted-prompting/index.ts
Normal 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';
|
||||
208
src/faceted-prompting/resolve.ts
Normal file
208
src/faceted-prompting/resolve.ts
Normal 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 };
|
||||
}
|
||||
65
src/faceted-prompting/template.ts
Normal file
65
src/faceted-prompting/template.ts
Normal 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);
|
||||
}
|
||||
88
src/faceted-prompting/truncation.ts
Normal file
88
src/faceted-prompting/truncation.ts
Normal 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');
|
||||
}
|
||||
53
src/faceted-prompting/types.ts
Normal file
53
src/faceted-prompting/types.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user