profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
generate.ts 3,059 bytes
1 import { mkdir, rename, stat, writeFile } from 'node:fs/promises'
2 import path from 'node:path'
3 import type { Session } from '../../shared/types'
4 import { SPEC_PACK_FILES, type InterviewLlm } from '../providers/types'
5 import { buildGeneratePrompt } from './prompts'
6 import { validateProvenance } from './provenance'
7
8 const SOURCES_FILE = 'sources.json'
9
10 export interface GenerateOptions {
11 overwrite?: boolean
12 }
13
14 export interface GenerateResult {
15 files: string[]
16 warnings: string[]
17 }
18
19 export class GenerateFilesExistError extends Error {
20 readonly existing: string[]
21
22 constructor(existing: string[]) {
23 super(`spec pack files already exist: ${existing.join(', ')}`)
24 this.existing = existing
25 }
26 }
27
28 async function pathExists(target: string): Promise<boolean> {
29 try {
30 await stat(target)
31 return true
32 } catch {
33 return false
34 }
35 }
36
37 async function backupExistingFiles(specDir: string, existing: string[]): Promise<void> {
38 const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
39 const backupDir = path.join(specDir, `backup-${timestamp}`)
40 await mkdir(backupDir, { recursive: true })
41 for (const file of existing) {
42 await rename(path.join(specDir, file), path.join(backupDir, file))
43 }
44 }
45
46 export async function generateSpecPack(
47 llm: InterviewLlm,
48 session: Session,
49 options: GenerateOptions = {},
50 ): Promise<GenerateResult> {
51 const targetStat = await stat(session.targetDir).catch(() => null)
52 if (!targetStat || !targetStat.isDirectory()) {
53 throw new Error(`target directory does not exist: ${session.targetDir}`)
54 }
55
56 const specDir = path.join(session.targetDir, 'spec')
57 await mkdir(specDir, { recursive: true })
58
59 const candidateFiles: string[] = [...SPEC_PACK_FILES, SOURCES_FILE]
60 const existing: string[] = []
61 for (const file of candidateFiles) {
62 if (await pathExists(path.join(specDir, file))) existing.push(file)
63 }
64
65 if (existing.length > 0) {
66 if (!options.overwrite) throw new GenerateFilesExistError(existing)
67 await backupExistingFiles(specDir, existing)
68 }
69
70 const validSegmentIds = new Set(session.segments.map((segment) => segment.id))
71 const warnings: string[] = []
72 const writtenFiles: string[] = []
73
74 for (const file of SPEC_PACK_FILES) {
75 const prompt = buildGeneratePrompt(file, session.name, session.segments)
76 const rawContent = await llm.generateFile({ file, prompt, segments: session.segments })
77 const { content, invalidMarkerCount } = validateProvenance(rawContent, validSegmentIds)
78 if (invalidMarkerCount > 0) {
79 warnings.push(`${file}: removed ${invalidMarkerCount} unverified provenance marker(s)`)
80 }
81 await writeFile(path.join(specDir, file), content, 'utf8')
82 writtenFiles.push(`spec/${file}`)
83 }
84
85 const sources = Object.fromEntries(
86 session.segments.map((segment) => [segment.id, { text: segment.text, ts: segment.ts }]),
87 )
88 await writeFile(path.join(specDir, SOURCES_FILE), JSON.stringify(sources, null, 2), 'utf8')
89 writtenFiles.push(`spec/${SOURCES_FILE}`)
90
91 return { files: writtenFiles, warnings }
92 }
93