document.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import * as fsSync from 'node:fs';
  2. import * as fs from 'node:fs/promises';
  3. import * as path from 'node:path';
  4. import { parseFrontmatter as sharedParseFrontmatter } from '../utils/frontmatter';
  5. import type {
  6. InterviewAnswer,
  7. InterviewQuestion,
  8. InterviewRecord,
  9. SpecBlock,
  10. } from './types';
  11. // ─── Path Utilities ──────────────────────────────────────────────────
  12. export const DEFAULT_OUTPUT_FOLDER = 'interview';
  13. export function normalizeOutputFolder(outputFolder: string): string {
  14. const normalized = outputFolder.trim().replace(/^\/+|\/+$/g, '');
  15. return normalized || DEFAULT_OUTPUT_FOLDER;
  16. }
  17. export function createInterviewDirectoryPath(
  18. directory: string,
  19. outputFolder: string,
  20. ): string {
  21. return path.join(directory, normalizeOutputFolder(outputFolder));
  22. }
  23. export function createInterviewFilePath(
  24. directory: string,
  25. outputFolder: string,
  26. idea: string,
  27. ): string {
  28. const fileName = `${slugify(idea) || 'interview'}.md`;
  29. return path.join(
  30. createInterviewDirectoryPath(directory, outputFolder),
  31. fileName,
  32. );
  33. }
  34. export function relativeInterviewPath(
  35. directory: string,
  36. filePath: string,
  37. ): string {
  38. return path.relative(directory, filePath) || path.basename(filePath);
  39. }
  40. /**
  41. * Resolve a user-provided value to an existing .md file path.
  42. * Checks absolute paths, relative paths, and output-folder-relative paths.
  43. * Returns null if no matching file is found.
  44. */
  45. export function resolveExistingInterviewPath(
  46. directory: string,
  47. outputFolder: string,
  48. value: string,
  49. ): string | null {
  50. const trimmed = value.trim();
  51. if (!trimmed) {
  52. return null;
  53. }
  54. const outputDir = createInterviewDirectoryPath(directory, outputFolder);
  55. const candidates = new Set<string>();
  56. const resolvedRoot = path.resolve(directory);
  57. if (path.isAbsolute(trimmed)) {
  58. candidates.add(trimmed);
  59. } else {
  60. candidates.add(path.resolve(directory, trimmed));
  61. candidates.add(path.join(outputDir, trimmed));
  62. if (!trimmed.endsWith('.md')) {
  63. candidates.add(path.join(outputDir, `${trimmed}.md`));
  64. }
  65. }
  66. for (const candidate of candidates) {
  67. if (path.extname(candidate) !== '.md') {
  68. continue;
  69. }
  70. const resolved = path.resolve(candidate);
  71. if (
  72. !resolved.startsWith(resolvedRoot + path.sep) &&
  73. resolved !== resolvedRoot
  74. ) {
  75. continue;
  76. }
  77. if (fsSync.existsSync(candidate)) {
  78. return candidate;
  79. }
  80. }
  81. return null;
  82. }
  83. // ─── String Utilities ────────────────────────────────────────────────
  84. export function slugify(value: string): string {
  85. return value
  86. .toLowerCase()
  87. .replace(/[^a-z0-9]+/g, '-')
  88. .replace(/^-+|-+$/g, '')
  89. .slice(0, 48);
  90. }
  91. // ─── Markdown Document Operations ────────────────────────────────────
  92. function extractHistorySection(document: string): string {
  93. const marker = /## Q&A history/i;
  94. const match = document.match(marker);
  95. if (!match || match.index === undefined) return '';
  96. return document.slice(match.index + match[0].length).trim();
  97. }
  98. export function extractSummarySection(document: string): string {
  99. const marker = '## Current spec\n\n';
  100. const start = document.indexOf(marker);
  101. if (start < 0) {
  102. return '';
  103. }
  104. const summaryStart = start + marker.length;
  105. const historyMarker = /\n\n## Q&A history/i;
  106. const historyMatch = document.slice(summaryStart).match(historyMarker);
  107. const summaryEnd =
  108. historyMatch?.index !== undefined
  109. ? summaryStart + historyMatch.index
  110. : undefined;
  111. return document.slice(summaryStart, summaryEnd).trim();
  112. }
  113. export function extractTitle(document: string): string {
  114. const match = document.match(/^#\s+(.+)$/m);
  115. return match?.[1]?.trim() ?? '';
  116. }
  117. export function buildInterviewDocument(
  118. idea: string,
  119. summary: string,
  120. history: string,
  121. meta?: {
  122. sessionID?: string;
  123. baseMessageCount?: number;
  124. owner?: string;
  125. tags?: string[];
  126. },
  127. ): string {
  128. const normalizedSummary = summary.trim() || 'Waiting for interview answers.';
  129. const normalizedHistory = history.trim() || 'No answers yet.';
  130. const now = new Date();
  131. const dateStr = now.toISOString().split('T')[0];
  132. const owner = meta?.owner ?? 'agent';
  133. const tags = meta?.tags ?? ['spec', 'diagnostic'];
  134. const frontmatter = meta?.sessionID
  135. ? [
  136. '---',
  137. `sessionID: ${meta.sessionID}`,
  138. `baseMessageCount: ${meta.baseMessageCount ?? 0}`,
  139. `updatedAt: ${now.toISOString()}`,
  140. `version: 1.0`,
  141. `date_created: ${dateStr}`,
  142. `owner: ${owner}`,
  143. `tags: [${tags.join(', ')}]`,
  144. '---',
  145. '',
  146. ].join('\n')
  147. : '';
  148. return [
  149. frontmatter,
  150. `# ${idea}`,
  151. '',
  152. '## Current spec',
  153. '',
  154. normalizedSummary,
  155. '',
  156. '## Q&A history',
  157. '',
  158. normalizedHistory,
  159. '',
  160. ].join('\n');
  161. }
  162. /** Parse frontmatter from a .md file. Returns null if no frontmatter. */
  163. export const parseFrontmatter = sharedParseFrontmatter;
  164. export async function ensureInterviewFile(
  165. record: InterviewRecord,
  166. ): Promise<void> {
  167. await fs.mkdir(path.dirname(record.markdownPath), { recursive: true });
  168. try {
  169. await fs.access(record.markdownPath);
  170. } catch {
  171. await fs.writeFile(
  172. record.markdownPath,
  173. buildInterviewDocument(record.idea, '', '', {
  174. sessionID: record.sessionID,
  175. baseMessageCount: record.baseMessageCount,
  176. }),
  177. 'utf8',
  178. );
  179. }
  180. }
  181. export async function readInterviewDocument(
  182. record: InterviewRecord,
  183. ): Promise<string> {
  184. try {
  185. return await fs.readFile(record.markdownPath, 'utf8');
  186. } catch {
  187. // File missing or unreadable - recreate it
  188. }
  189. await ensureInterviewFile(record);
  190. return fs.readFile(record.markdownPath, 'utf8');
  191. }
  192. export async function rewriteInterviewDocument(
  193. record: InterviewRecord,
  194. summary: string,
  195. ): Promise<string> {
  196. const existing = await readInterviewDocument(record);
  197. const history = extractHistorySection(existing);
  198. const next = buildInterviewDocument(record.idea, summary, history, {
  199. sessionID: record.sessionID,
  200. baseMessageCount: record.baseMessageCount,
  201. });
  202. await fs.writeFile(record.markdownPath, next, 'utf8');
  203. return next;
  204. }
  205. export async function appendInterviewAnswers(
  206. record: InterviewRecord,
  207. questions: InterviewQuestion[],
  208. answers: InterviewAnswer[],
  209. ): Promise<void> {
  210. const existing = await readInterviewDocument(record);
  211. const summary = extractSummarySection(existing);
  212. const history = extractHistorySection(existing);
  213. const questionMap = new Map(
  214. questions.map((question) => [question.id, question]),
  215. );
  216. const appended = answers
  217. .map((answer) => {
  218. const question = questionMap.get(answer.questionId);
  219. return question
  220. ? `Q: ${question.question}\nA: ${answer.answer.trim()}`
  221. : null;
  222. })
  223. .filter((value): value is string => value !== null)
  224. .join('\n\n');
  225. const nextHistory = [history === 'No answers yet.' ? '' : history, appended]
  226. .filter(Boolean)
  227. .join('\n\n');
  228. await fs.writeFile(
  229. record.markdownPath,
  230. buildInterviewDocument(record.idea, summary, nextHistory, {
  231. sessionID: record.sessionID,
  232. baseMessageCount: record.baseMessageCount,
  233. }),
  234. 'utf8',
  235. );
  236. }
  237. export function parseSpecBlocks(markdown: string): SpecBlock[] {
  238. const blocks: SpecBlock[] = [];
  239. const lines = markdown.split('\n');
  240. let currentBlockId: string | null = null;
  241. let currentBlockTitle: string | null = null;
  242. let currentBlockLines: string[] = [];
  243. const flush = () => {
  244. if (currentBlockId) {
  245. blocks.push({
  246. id: currentBlockId,
  247. title: currentBlockTitle || currentBlockId,
  248. content: currentBlockLines.join('\n').trim(),
  249. });
  250. }
  251. };
  252. for (const line of lines) {
  253. if (/^##\s+Q&A history\s*$/i.test(line)) {
  254. break;
  255. }
  256. const headerMatch = line.match(/^##\s+(\d+)\.\s+(.+)$/);
  257. if (headerMatch) {
  258. flush();
  259. const num = headerMatch[1];
  260. const name = headerMatch[2].trim();
  261. currentBlockId = `section-${num}`;
  262. currentBlockTitle = `${num}. ${name}`;
  263. currentBlockLines = [];
  264. } else if (line.startsWith('# ') && !line.startsWith('## ')) {
  265. // Intro section before ## 1.
  266. if (currentBlockId === null) {
  267. currentBlockId = 'section-0';
  268. currentBlockTitle = 'Introduction';
  269. currentBlockLines = [];
  270. }
  271. } else if (line.startsWith('## ') && !headerMatch) {
  272. // Any other H2
  273. flush();
  274. const name = line.replace(/^##\s+/, '').trim();
  275. currentBlockId = `section-${slugify(name)}`;
  276. currentBlockTitle = name;
  277. currentBlockLines = [];
  278. }
  279. if (currentBlockId !== null) {
  280. currentBlockLines.push(line);
  281. }
  282. }
  283. flush();
  284. return blocks;
  285. }