service.ts 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. import { spawn } from 'node:child_process';
  2. import * as fs from 'node:fs/promises';
  3. import * as path from 'node:path';
  4. import type { PluginInput } from '@opencode-ai/plugin';
  5. import type { InterviewConfig } from '../config';
  6. import {
  7. createInternalAgentTextPart,
  8. isInternalInitiatorPart,
  9. log,
  10. } from '../utils';
  11. import { getClient } from '../utils/opencode-client';
  12. import { parseModelReference } from '../utils/session';
  13. import {
  14. appendInterviewAnswers,
  15. createInterviewDirectoryPath,
  16. createInterviewFilePath,
  17. DEFAULT_OUTPUT_FOLDER,
  18. ensureInterviewFile,
  19. extractSummarySection,
  20. extractTitle,
  21. normalizeOutputFolder,
  22. parseSpecBlocks,
  23. readInterviewDocument,
  24. relativeInterviewPath,
  25. resolveExistingInterviewPath,
  26. rewriteInterviewDocument,
  27. slugify,
  28. } from './document';
  29. import { buildFallbackState, findLatestAssistantState } from './parser';
  30. import {
  31. buildAnswerPrompt,
  32. buildKickoffPrompt,
  33. buildResumePrompt,
  34. } from './prompts';
  35. import type {
  36. InterviewAnswer,
  37. InterviewFileItem,
  38. InterviewListItem,
  39. InterviewMessage,
  40. InterviewRecord,
  41. InterviewState,
  42. } from './types';
  43. const COMMAND_NAME = 'interview';
  44. const DEFAULT_MAX_QUESTIONS = 2;
  45. /**
  46. * Cap on retained abandoned interview records. Abandoned interviews are kept
  47. * briefly so a still-open browser tab can render their final state, but
  48. * without a bound the `interviewsById` and `browserOpened` collections grow
  49. * for the life of a long-running session/dashboard process.
  50. */
  51. export const MAX_RETAINED_ABANDONED = 50;
  52. function isTruthyEnvFlag(value: string | undefined): boolean {
  53. if (!value) {
  54. return false;
  55. }
  56. return value !== '0' && value.toLowerCase() !== 'false';
  57. }
  58. function isAutomatedRuntime(env: NodeJS.ProcessEnv): boolean {
  59. return (
  60. env.NODE_ENV === 'test' ||
  61. isTruthyEnvFlag(env.CI) ||
  62. isTruthyEnvFlag(env.BUN_TEST) ||
  63. isTruthyEnvFlag(env.VITEST) ||
  64. env.JEST_WORKER_ID !== undefined
  65. );
  66. }
  67. function shouldAutoOpenBrowser(
  68. config: InterviewConfig | undefined,
  69. env: NodeJS.ProcessEnv,
  70. ): boolean {
  71. const requested = config?.autoOpenBrowser ?? true;
  72. return requested && !isAutomatedRuntime(env);
  73. }
  74. /**
  75. * Open a URL in the default browser.
  76. * Supports macOS, Linux, and Windows. Failures are logged but not thrown.
  77. */
  78. function openBrowser(url: string): void {
  79. const platform = process.platform;
  80. let command: string;
  81. let args: string[];
  82. if (platform === 'darwin') {
  83. command = 'open';
  84. args = [url];
  85. } else if (platform === 'win32') {
  86. command = 'cmd';
  87. args = ['/c', 'start', '', url];
  88. } else {
  89. // Linux and other Unix-like systems
  90. command = 'xdg-open';
  91. args = [url];
  92. }
  93. try {
  94. const child = spawn(command, args, { detached: true, stdio: 'ignore' });
  95. child.on('error', (error) => {
  96. log('[interview] failed to open browser:', { error: error.message, url });
  97. });
  98. child.unref();
  99. } catch (error) {
  100. log('[interview] failed to spawn browser opener:', {
  101. error: error instanceof Error ? error.message : String(error),
  102. url,
  103. });
  104. }
  105. }
  106. function nowIso(): string {
  107. return new Date().toISOString();
  108. }
  109. export function createInterviewService(
  110. ctx: PluginInput,
  111. config?: InterviewConfig,
  112. deps?: {
  113. openBrowser?: (url: string) => void;
  114. env?: NodeJS.ProcessEnv;
  115. },
  116. ): {
  117. setBaseUrlResolver: (resolver: () => Promise<string>) => void;
  118. setStatePushCallback: (
  119. callback: (interviewId: string, state: InterviewState) => void,
  120. ) => void;
  121. setOnInterviewCreated: (
  122. callback: (interview: InterviewRecord) => void,
  123. ) => void;
  124. getActiveInterviewId: (sessionID: string) => string | null;
  125. registerCommand: (config: Record<string, unknown>) => void;
  126. handleCommandExecuteBefore: (
  127. input: { command: string; sessionID: string; arguments: string },
  128. output: {
  129. parts: Array<{
  130. type: string;
  131. text?: string;
  132. synthetic?: boolean;
  133. metadata?: Record<string, unknown>;
  134. }>;
  135. },
  136. ) => Promise<void>;
  137. handleEvent: (input: {
  138. event: { type: string; properties?: Record<string, unknown> };
  139. }) => Promise<void>;
  140. getInterviewState: (interviewId: string) => Promise<InterviewState>;
  141. listInterviewFiles: () => Promise<InterviewFileItem[]>;
  142. listInterviews: () => InterviewListItem[];
  143. submitAnswers: (
  144. interviewId: string,
  145. answers: InterviewAnswer[],
  146. ) => Promise<void>;
  147. submitBlockComment: (
  148. interviewId: string,
  149. section: string,
  150. comment: string,
  151. ) => Promise<void>;
  152. submitChat: (interviewId: string, message: string) => Promise<void>;
  153. handleNudgeAction: (
  154. interviewId: string,
  155. action: 'more-questions' | 'confirm-complete',
  156. ) => Promise<void>;
  157. } {
  158. const maxQuestions = config?.maxQuestions ?? DEFAULT_MAX_QUESTIONS;
  159. const outputFolder = normalizeOutputFolder(
  160. config?.outputFolder ?? DEFAULT_OUTPUT_FOLDER,
  161. );
  162. const autoOpenBrowser = shouldAutoOpenBrowser(
  163. config,
  164. deps?.env ?? process.env,
  165. );
  166. const browserOpener = deps?.openBrowser ?? openBrowser;
  167. const activeInterviewIds = new Map<string, string>();
  168. const interviewsById = new Map<string, InterviewRecord>();
  169. const activeSyncs = new Map<string, Promise<InterviewState>>();
  170. const sessionBusy = new Map<string, boolean>();
  171. const sessionModel = new Map<string, string>();
  172. const browserOpened = new Set<string>(); // Track interviews that have opened browser
  173. let resolveBaseUrl: (() => Promise<string>) | null = null;
  174. let onStateChange:
  175. | ((interviewId: string, state: InterviewState) => void)
  176. | null = null;
  177. let onInterviewCreated: ((interview: InterviewRecord) => void) | null = null;
  178. let idCounter = 0;
  179. let abandonedOrderCounter = 0;
  180. function setBaseUrlResolver(resolver: () => Promise<string>): void {
  181. resolveBaseUrl = resolver;
  182. }
  183. function setStatePushCallback(
  184. callback: (interviewId: string, state: InterviewState) => void,
  185. ): void {
  186. onStateChange = callback;
  187. }
  188. function setOnInterviewCreated(
  189. callback: (interview: InterviewRecord) => void,
  190. ): void {
  191. onInterviewCreated = callback;
  192. }
  193. function getActiveInterviewId(sessionID: string): string | null {
  194. return activeInterviewIds.get(sessionID) ?? null;
  195. }
  196. async function ensureServer(): Promise<string> {
  197. if (!resolveBaseUrl) {
  198. throw new Error('Interview server is not attached');
  199. }
  200. return resolveBaseUrl();
  201. }
  202. function maybeOpenBrowser(interviewId: string, url: string): void {
  203. if (!autoOpenBrowser) {
  204. return;
  205. }
  206. if (browserOpened.has(interviewId)) {
  207. return;
  208. }
  209. browserOpened.add(interviewId);
  210. browserOpener(url);
  211. }
  212. async function maybeRenameWithTitle(
  213. interview: InterviewRecord,
  214. assistantTitle: string | undefined,
  215. ): Promise<void> {
  216. if (!assistantTitle) {
  217. return;
  218. }
  219. const newSlug = slugify(assistantTitle);
  220. if (!newSlug) {
  221. return;
  222. }
  223. const currentFileName = path.basename(interview.markdownPath, '.md');
  224. // If already matches (or user-provided idea matches), skip
  225. if (currentFileName === newSlug) {
  226. return;
  227. }
  228. const dir = path.dirname(interview.markdownPath);
  229. const newPath = path.join(dir, `${newSlug}.md`);
  230. // Don't overwrite existing files
  231. try {
  232. await fs.access(newPath);
  233. // File exists, don't rename
  234. return;
  235. } catch {
  236. // File doesn't exist, safe to rename
  237. }
  238. try {
  239. await fs.rename(interview.markdownPath, newPath);
  240. interview.markdownPath = newPath;
  241. log('[interview] renamed file with assistant title:', {
  242. from: currentFileName,
  243. to: newSlug,
  244. });
  245. } catch (error) {
  246. log('[interview] failed to rename file:', {
  247. error: error instanceof Error ? error.message : String(error),
  248. });
  249. }
  250. }
  251. async function loadMessages(sessionID: string): Promise<InterviewMessage[]> {
  252. const result = await getClient(ctx).session.messages({
  253. sessionID,
  254. });
  255. return result.data as InterviewMessage[];
  256. }
  257. async function loadMessagesWithRetry(
  258. sessionID: string,
  259. ): Promise<InterviewMessage[]> {
  260. for (let i = 0; i < 8; i++) {
  261. const messages = await loadMessages(sessionID);
  262. if (messages.length > 0) {
  263. const last = messages[messages.length - 1];
  264. if (last?.info?.role === 'assistant') {
  265. return messages;
  266. }
  267. }
  268. await new Promise((resolve) => setTimeout(resolve, 250));
  269. }
  270. return loadMessages(sessionID);
  271. }
  272. function isUserVisibleMessage(message: InterviewMessage): boolean {
  273. return !(message.parts ?? []).some((part) => isInternalInitiatorPart(part));
  274. }
  275. function getInterviewById(interviewId: string): InterviewRecord | null {
  276. return interviewsById.get(interviewId) ?? null;
  277. }
  278. /**
  279. * Mark an interview abandoned and prune the oldest abandoned records so the
  280. * in-memory registry (and its browser-open tracking) stays bounded.
  281. */
  282. function abandonInterview(interview: InterviewRecord): void {
  283. if (interview.status !== 'abandoned') {
  284. interview.abandonedAt = nowIso();
  285. interview.abandonedOrder = ++abandonedOrderCounter;
  286. }
  287. interview.status = 'abandoned';
  288. pruneAbandonedInterviews();
  289. }
  290. function pruneAbandonedInterviews(): void {
  291. const abandoned = [...interviewsById.values()].filter(
  292. (record) => record.status === 'abandoned',
  293. );
  294. const overflow = abandoned.length - MAX_RETAINED_ABANDONED;
  295. if (overflow <= 0) return;
  296. abandoned
  297. .sort((a, b) => {
  298. const timeDelta =
  299. new Date(a.abandonedAt ?? a.createdAt).getTime() -
  300. new Date(b.abandonedAt ?? b.createdAt).getTime();
  301. if (timeDelta !== 0) return timeDelta;
  302. return (a.abandonedOrder ?? 0) - (b.abandonedOrder ?? 0);
  303. })
  304. .slice(0, overflow)
  305. .forEach((record) => {
  306. interviewsById.delete(record.id);
  307. browserOpened.delete(record.id);
  308. });
  309. }
  310. async function createInterview(
  311. sessionID: string,
  312. idea: string,
  313. ): Promise<InterviewRecord> {
  314. const normalizedIdea = idea.trim();
  315. const activeId = activeInterviewIds.get(sessionID);
  316. if (activeId) {
  317. const active = interviewsById.get(activeId);
  318. if (active && active.status === 'active') {
  319. if (active.idea === normalizedIdea) {
  320. return active;
  321. }
  322. abandonInterview(active);
  323. }
  324. }
  325. const messages = await loadMessages(sessionID);
  326. const record: InterviewRecord = {
  327. id: `${Date.now()}-${++idCounter}-${slugify(idea) || 'interview'}`,
  328. sessionID,
  329. idea: normalizedIdea,
  330. markdownPath: createInterviewFilePath(ctx.directory, outputFolder, idea),
  331. createdAt: nowIso(),
  332. status: 'active',
  333. baseMessageCount: messages.length,
  334. };
  335. await ensureInterviewFile(record);
  336. activeInterviewIds.set(sessionID, record.id);
  337. interviewsById.set(record.id, record);
  338. fileCache = null;
  339. if (onInterviewCreated) {
  340. onInterviewCreated(record);
  341. }
  342. return record;
  343. }
  344. async function resumeInterview(
  345. sessionID: string,
  346. markdownPath: string,
  347. ): Promise<InterviewRecord> {
  348. const activeId = activeInterviewIds.get(sessionID);
  349. if (activeId) {
  350. const active = interviewsById.get(activeId);
  351. if (active && active.status === 'active') {
  352. if (active.markdownPath === markdownPath) {
  353. return active;
  354. }
  355. abandonInterview(active);
  356. }
  357. }
  358. const document = await fs.readFile(markdownPath, 'utf8');
  359. const messages = await loadMessages(sessionID);
  360. const title = extractTitle(document);
  361. const record: InterviewRecord = {
  362. id: `${Date.now()}-${++idCounter}-${slugify(path.basename(markdownPath, '.md')) || 'interview'}`,
  363. sessionID,
  364. idea: title || path.basename(markdownPath, '.md'),
  365. markdownPath,
  366. createdAt: nowIso(),
  367. status: 'active',
  368. baseMessageCount: messages.length,
  369. };
  370. activeInterviewIds.set(sessionID, record.id);
  371. interviewsById.set(record.id, record);
  372. fileCache = null;
  373. if (onInterviewCreated) {
  374. onInterviewCreated(record);
  375. }
  376. return record;
  377. }
  378. function syncInterview(interview: InterviewRecord): Promise<InterviewState> {
  379. const existing = activeSyncs.get(interview.id);
  380. if (existing) {
  381. return existing;
  382. }
  383. const sync = performSyncInterview(interview).finally(() => {
  384. activeSyncs.delete(interview.id);
  385. });
  386. activeSyncs.set(interview.id, sync);
  387. return sync;
  388. }
  389. async function performSyncInterview(
  390. interview: InterviewRecord,
  391. ): Promise<InterviewState> {
  392. const allMessages = await loadMessagesWithRetry(interview.sessionID);
  393. const interviewMessages = allMessages
  394. .slice(interview.baseMessageCount)
  395. .filter(isUserVisibleMessage);
  396. const parsed = findLatestAssistantState(interviewMessages, maxQuestions);
  397. const existingDocument = await readInterviewDocument(interview);
  398. const fallbackState = buildFallbackState(interviewMessages);
  399. const state = parsed.state ?? {
  400. ...fallbackState,
  401. summary: extractSummarySection(existingDocument) || fallbackState.summary,
  402. };
  403. // Rename file if assistant provided a title (and file hasn't been renamed yet)
  404. await maybeRenameWithTitle(interview, state.title);
  405. // Skip rewrite when parsed.state is null - agent already wrote the final spec
  406. let document: string;
  407. if (parsed.state) {
  408. document = await rewriteInterviewDocument(interview, state.summary);
  409. } else {
  410. document = await readInterviewDocument(interview);
  411. }
  412. const blocks = parseSpecBlocks(document);
  413. const interviewState: InterviewState = {
  414. interview,
  415. url: `${await ensureServer()}/interview/${interview.id}`,
  416. markdownPath: relativeInterviewPath(
  417. ctx.directory,
  418. interview.markdownPath,
  419. ),
  420. mode:
  421. interview.status === 'abandoned'
  422. ? 'abandoned'
  423. : parsed.state && state.questions.length === 0
  424. ? 'completed'
  425. : sessionBusy.get(interview.sessionID) === true
  426. ? 'awaiting-agent'
  427. : state.questions.length > 0
  428. ? 'awaiting-user'
  429. : parsed.latestAssistantError
  430. ? 'error'
  431. : !parsed.state &&
  432. sessionBusy.get(interview.sessionID) === false
  433. ? 'completed'
  434. : 'awaiting-agent',
  435. lastParseError: parsed.latestAssistantError,
  436. isBusy: sessionBusy.get(interview.sessionID) === true,
  437. summary: state.summary,
  438. questions: state.questions,
  439. document,
  440. blocks,
  441. };
  442. // Push state to dashboard if callback is set (dashboard mode)
  443. if (onStateChange) {
  444. onStateChange(interview.id, interviewState);
  445. }
  446. return interviewState;
  447. }
  448. async function notifyInterviewUrl(
  449. sessionID: string,
  450. interview: InterviewRecord,
  451. ): Promise<void> {
  452. const baseUrl = await ensureServer();
  453. const url = `${baseUrl}/interview/${interview.id}`;
  454. // Auto-open browser on initial creation (not on every poll/refresh)
  455. maybeOpenBrowser(interview.id, url);
  456. await getClient(ctx).session.prompt({
  457. sessionID,
  458. noReply: true,
  459. parts: [
  460. {
  461. type: 'text',
  462. text: [
  463. '⎔ Interview UI ready',
  464. '',
  465. `Open: ${url}`,
  466. `Document: ${relativeInterviewPath(ctx.directory, interview.markdownPath)}`,
  467. '',
  468. '[system status: continue without acknowledging this notification]',
  469. ].join('\n'),
  470. },
  471. ],
  472. });
  473. }
  474. function registerCommand(opencodeConfig: Record<string, unknown>): void {
  475. const configCommand = opencodeConfig.command as
  476. | Record<string, unknown>
  477. | undefined;
  478. if (!configCommand?.[COMMAND_NAME]) {
  479. if (!opencodeConfig.command) {
  480. opencodeConfig.command = {};
  481. }
  482. (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
  483. template: 'Start an interview and write a live markdown spec',
  484. description:
  485. 'Open a localhost interview UI linked to the current OpenCode session',
  486. };
  487. }
  488. }
  489. async function getInterviewState(
  490. interviewId: string,
  491. ): Promise<InterviewState> {
  492. const interview = getInterviewById(interviewId);
  493. if (!interview) {
  494. throw new Error('Interview not found');
  495. }
  496. return syncInterview(interview);
  497. }
  498. function listInterviews(): InterviewListItem[] {
  499. const result: InterviewListItem[] = [];
  500. for (const interview of interviewsById.values()) {
  501. if (interview.status !== 'active') continue;
  502. result.push({
  503. id: interview.id,
  504. idea: interview.idea,
  505. status: interview.status,
  506. createdAt: interview.createdAt,
  507. });
  508. }
  509. return result.sort(
  510. (a, b) =>
  511. new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
  512. );
  513. }
  514. async function submitAnswers(
  515. interviewId: string,
  516. answers: InterviewAnswer[],
  517. ): Promise<void> {
  518. const interview = getInterviewById(interviewId);
  519. if (!interview) {
  520. throw new Error('Interview not found');
  521. }
  522. if (interview.status === 'abandoned') {
  523. throw new Error('Interview session is no longer active.');
  524. }
  525. if (sessionBusy.get(interview.sessionID) === true) {
  526. throw new Error(
  527. 'Interview session is busy. Wait for the current response.',
  528. );
  529. }
  530. // Acquire busy lock immediately before any async operations to prevent race
  531. sessionBusy.set(interview.sessionID, true);
  532. let promptSent = false;
  533. try {
  534. const state = await getInterviewState(interviewId);
  535. if (state.mode === 'error') {
  536. throw new Error('Interview is waiting for a valid agent update.');
  537. }
  538. const activeQuestionIds = new Set(
  539. state.questions.map((question) => question.id),
  540. );
  541. if (activeQuestionIds.size === 0) {
  542. throw new Error('There are no active interview questions to answer.');
  543. }
  544. if (answers.length !== activeQuestionIds.size) {
  545. throw new Error(
  546. 'Answer every active interview question before submitting.',
  547. );
  548. }
  549. const invalidAnswer = answers.find(
  550. (answer) =>
  551. !activeQuestionIds.has(answer.questionId) || !answer.answer.trim(),
  552. );
  553. if (invalidAnswer) {
  554. throw new Error(
  555. 'Answers do not match the current interview questions.',
  556. );
  557. }
  558. await appendInterviewAnswers(interview, state.questions, answers);
  559. const prompt = buildAnswerPrompt(answers, state.questions, maxQuestions);
  560. // Use promptAsync for non-blocking - returns immediately, LLM
  561. // processes in background. State push updates dashboard when done.
  562. const model = sessionModel.get(interview.sessionID);
  563. await getClient(ctx).session.promptAsync({
  564. sessionID: interview.sessionID,
  565. agent: 'orchestrator',
  566. parts: [createInternalAgentTextPart(prompt)],
  567. ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
  568. });
  569. promptSent = true;
  570. } finally {
  571. if (!promptSent) {
  572. sessionBusy.set(interview.sessionID, false);
  573. }
  574. }
  575. }
  576. async function handleCommandExecuteBefore(
  577. input: { command: string; sessionID: string; arguments: string },
  578. output: { parts: Array<{ type: string; text?: string }> },
  579. ): Promise<void> {
  580. if (input.command !== COMMAND_NAME) {
  581. return;
  582. }
  583. const idea = input.arguments.trim();
  584. output.parts.length = 0;
  585. if (!idea) {
  586. const activeId = activeInterviewIds.get(input.sessionID);
  587. const interview = activeId ? interviewsById.get(activeId) : null;
  588. if (interview?.status !== 'active') {
  589. output.parts.push(
  590. createInternalAgentTextPart(
  591. 'The user ran /interview without an idea. Ask them for the product idea in one sentence.',
  592. ),
  593. );
  594. return;
  595. }
  596. await notifyInterviewUrl(input.sessionID, interview);
  597. output.parts.push(
  598. createInternalAgentTextPart(
  599. `The interview UI was reopened for the current session. If your latest interview turn already contains unanswered questions, do not repeat them. Otherwise continue the interview with up to ${maxQuestions} clarifying questions and include the structured <interview_state> block.`,
  600. ),
  601. );
  602. return;
  603. }
  604. const resumePath = resolveExistingInterviewPath(
  605. ctx.directory,
  606. outputFolder,
  607. idea,
  608. );
  609. if (resumePath) {
  610. const interview = await resumeInterview(input.sessionID, resumePath);
  611. const document = await fs.readFile(interview.markdownPath, 'utf8');
  612. await notifyInterviewUrl(input.sessionID, interview);
  613. output.parts.push(
  614. createInternalAgentTextPart(buildResumePrompt(document, maxQuestions)),
  615. );
  616. return;
  617. }
  618. const interview = await createInterview(input.sessionID, idea);
  619. await notifyInterviewUrl(input.sessionID, interview);
  620. output.parts.push(
  621. createInternalAgentTextPart(buildKickoffPrompt(idea, maxQuestions)),
  622. );
  623. // best-effort: rename the session so it's identifiable in the session list.
  624. // never block interview creation if the rename fails.
  625. let sessionTitle = `Interview: ${idea}`;
  626. if (sessionTitle.length > 50) {
  627. sessionTitle = `${sessionTitle.slice(0, 49)}…`;
  628. }
  629. getClient(ctx)
  630. .session.update({
  631. sessionID: input.sessionID,
  632. title: sessionTitle,
  633. })
  634. .catch(() => {});
  635. }
  636. async function handleEvent(input: {
  637. event: { type: string; properties?: Record<string, unknown> };
  638. }): Promise<void> {
  639. const { event } = input;
  640. const properties = event.properties ?? {};
  641. if (event.type === 'session.status') {
  642. const sessionID = properties.sessionID as string | undefined;
  643. const status = properties.status as { type?: string } | undefined;
  644. if (sessionID) {
  645. sessionBusy.set(sessionID, status?.type === 'busy');
  646. }
  647. return;
  648. }
  649. if (event.type === 'message.updated') {
  650. const info = properties as
  651. | {
  652. info?: {
  653. sessionID?: string;
  654. providerID?: string;
  655. modelID?: string;
  656. };
  657. }
  658. | undefined;
  659. const sessionID = info?.info?.sessionID;
  660. const providerID = info?.info?.providerID;
  661. const modelID = info?.info?.modelID;
  662. if (sessionID && providerID && modelID) {
  663. sessionModel.set(sessionID, `${providerID}/${modelID}`);
  664. }
  665. return;
  666. }
  667. if (event.type === 'session.deleted') {
  668. const deletedSessionId =
  669. ((properties.info as { id?: string } | undefined)?.id ??
  670. (properties.sessionID as string | undefined)) ||
  671. null;
  672. if (!deletedSessionId) {
  673. return;
  674. }
  675. sessionBusy.delete(deletedSessionId);
  676. sessionModel.delete(deletedSessionId);
  677. const interviewId = activeInterviewIds.get(deletedSessionId);
  678. if (!interviewId) {
  679. return;
  680. }
  681. const interview = interviewsById.get(interviewId);
  682. if (!interview) {
  683. return;
  684. }
  685. abandonInterview(interview);
  686. fileCache = null;
  687. activeInterviewIds.delete(deletedSessionId);
  688. log('[interview] session deleted, interview marked abandoned', {
  689. sessionID: deletedSessionId,
  690. interviewId,
  691. });
  692. }
  693. }
  694. let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
  695. const FILE_CACHE_TTL = 10_000;
  696. async function listInterviewFiles(): Promise<InterviewFileItem[]> {
  697. if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
  698. return fileCache.items;
  699. }
  700. const outputDir = createInterviewDirectoryPath(ctx.directory, outputFolder);
  701. const activePaths = new Set(
  702. [...interviewsById.values()]
  703. .filter((i) => i.status === 'active')
  704. .map((i) => path.resolve(i.markdownPath)),
  705. );
  706. let entries: string[];
  707. try {
  708. entries = await fs.readdir(outputDir);
  709. } catch {
  710. return [];
  711. }
  712. const items: InterviewFileItem[] = [];
  713. for (const entry of entries) {
  714. if (!entry.endsWith('.md')) continue;
  715. const fullPath = path.join(outputDir, entry);
  716. if (activePaths.has(path.resolve(fullPath))) continue;
  717. let content: string;
  718. try {
  719. content = await fs.readFile(fullPath, 'utf8');
  720. } catch {
  721. continue;
  722. }
  723. const title = extractTitle(content) || entry.replace(/\.md$/, '');
  724. const summary = extractSummarySection(content) || '';
  725. const baseName = entry.replace(/\.md$/, '');
  726. items.push({
  727. fileName: entry,
  728. resumeCommand: `/interview ${baseName}`,
  729. title,
  730. summary:
  731. summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
  732. });
  733. }
  734. const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
  735. fileCache = { items: sorted, at: Date.now() };
  736. return sorted;
  737. }
  738. async function submitBlockComment(
  739. interviewId: string,
  740. sectionTitle: string,
  741. comment: string,
  742. ): Promise<void> {
  743. const interview = getInterviewById(interviewId);
  744. if (!interview) {
  745. throw new Error('Interview not found');
  746. }
  747. if (interview.status === 'abandoned') {
  748. throw new Error('Interview session is no longer active.');
  749. }
  750. if (sessionBusy.get(interview.sessionID) === true) {
  751. throw new Error(
  752. 'Interview session is busy. Wait for the current response.',
  753. );
  754. }
  755. sessionBusy.set(interview.sessionID, true);
  756. let promptSent = false;
  757. try {
  758. const state = await getInterviewState(interviewId);
  759. if (state.mode === 'error') {
  760. throw new Error('Interview is waiting for a valid agent update.');
  761. }
  762. const relativePath = relativeInterviewPath(
  763. ctx.directory,
  764. interview.markdownPath,
  765. );
  766. const prompt = [
  767. `You are updating the active interview specification document at "${relativePath}".`,
  768. `The current document content on disk is:`,
  769. `\`\`\`markdown`,
  770. state.document,
  771. `\`\`\``,
  772. ``,
  773. `The user submitted specific feedback/comments for the section "${sectionTitle}".`,
  774. `Feedback: ${comment}`,
  775. ``,
  776. `Update the specification summary (focusing heavily on making changes to the "${sectionTitle}" section) to address this feedback.`,
  777. `If this feedback implies other parts of the spec should change, update them too.`,
  778. `Include the updated 11-section specification and ask the next highest-value clarifying questions as questions (up to ${maxQuestions} questions) if needed.`,
  779. `Return the same <interview_state> JSON block format as before.`,
  780. ].join('\n');
  781. const model = sessionModel.get(interview.sessionID);
  782. await getClient(ctx).session.promptAsync({
  783. sessionID: interview.sessionID,
  784. agent: 'orchestrator',
  785. parts: [createInternalAgentTextPart(prompt)],
  786. ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
  787. });
  788. promptSent = true;
  789. } finally {
  790. if (!promptSent) {
  791. sessionBusy.set(interview.sessionID, false);
  792. }
  793. }
  794. }
  795. async function submitChat(
  796. interviewId: string,
  797. message: string,
  798. ): Promise<void> {
  799. const interview = getInterviewById(interviewId);
  800. if (!interview) {
  801. throw new Error('Interview not found');
  802. }
  803. if (interview.status === 'abandoned') {
  804. throw new Error('Interview session is no longer active.');
  805. }
  806. if (sessionBusy.get(interview.sessionID) === true) {
  807. throw new Error(
  808. 'Interview session is busy. Wait for the current response.',
  809. );
  810. }
  811. sessionBusy.set(interview.sessionID, true);
  812. let promptSent = false;
  813. try {
  814. const state = await getInterviewState(interviewId);
  815. if (state.mode === 'error') {
  816. throw new Error('Interview is waiting for a valid agent update.');
  817. }
  818. const relativePath = relativeInterviewPath(
  819. ctx.directory,
  820. interview.markdownPath,
  821. );
  822. const prompt = [
  823. `You are continuing the interview for the specification document at "${relativePath}".`,
  824. `The current document content on disk is:`,
  825. `\`\`\`markdown`,
  826. state.document,
  827. `\`\`\``,
  828. ``,
  829. `The user sent a freeform message via the dashboard chat panel:`,
  830. `${message}`,
  831. ``,
  832. `Process this request - it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
  833. `Update the specification document accordingly and include the updated 11-section specification.`,
  834. `Ask up to ${maxQuestions} clarifying questions if needed using the same <interview_state> JSON block format as before.`,
  835. ].join('\n');
  836. const model = sessionModel.get(interview.sessionID);
  837. await getClient(ctx).session.promptAsync({
  838. sessionID: interview.sessionID,
  839. agent: 'orchestrator',
  840. parts: [createInternalAgentTextPart(prompt)],
  841. ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
  842. });
  843. promptSent = true;
  844. } finally {
  845. if (!promptSent) {
  846. sessionBusy.set(interview.sessionID, false);
  847. }
  848. }
  849. }
  850. async function handleNudgeAction(
  851. interviewId: string,
  852. action: 'more-questions' | 'confirm-complete',
  853. ): Promise<void> {
  854. const interview = getInterviewById(interviewId);
  855. if (!interview) {
  856. throw new Error('Interview not found');
  857. }
  858. if (interview.status === 'abandoned') {
  859. throw new Error('Interview session is no longer active.');
  860. }
  861. if (sessionBusy.get(interview.sessionID) === true) {
  862. throw new Error(
  863. 'Interview session is busy. Wait for the current response.',
  864. );
  865. }
  866. sessionBusy.set(interview.sessionID, true);
  867. let promptSent = false;
  868. try {
  869. const state = await getInterviewState(interviewId);
  870. const relativePath = relativeInterviewPath(
  871. ctx.directory,
  872. interview.markdownPath,
  873. );
  874. let prompt: string;
  875. if (action === 'more-questions') {
  876. prompt = [
  877. `You are continuing the interview for the specification document at "${relativePath}".`,
  878. `The current document content on disk is:`,
  879. `\`\`\`markdown`,
  880. state.document,
  881. `\`\`\``,
  882. ``,
  883. `The user reviewed the completed interview spec and wants you to continue.`,
  884. ``,
  885. `Ask up to ${maxQuestions} new clarifying questions about aspects that are still unclear or underspecified.`,
  886. `Include the structured <interview_state> block with new questions.`,
  887. ].join('\n');
  888. } else {
  889. prompt = [
  890. `You are finishing the interview for the specification document at "${relativePath}".`,
  891. `The current document content on disk is:`,
  892. `\`\`\`markdown`,
  893. state.document,
  894. `\`\`\``,
  895. ``,
  896. `The user confirmed the interview spec is complete.`,
  897. ``,
  898. `Produce a final, polished version of the full spec document.`,
  899. `Do NOT include any <interview_state> block - just output the final spec as clean markdown.`,
  900. `The spec should be comprehensive, well-structured, and ready for implementation.`,
  901. ].join('\n');
  902. }
  903. const model = sessionModel.get(interview.sessionID);
  904. await getClient(ctx).session.promptAsync({
  905. sessionID: interview.sessionID,
  906. agent: 'orchestrator',
  907. parts: [createInternalAgentTextPart(prompt)],
  908. ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
  909. });
  910. promptSent = true;
  911. } finally {
  912. if (!promptSent) {
  913. sessionBusy.set(interview.sessionID, false);
  914. }
  915. }
  916. }
  917. return {
  918. setBaseUrlResolver,
  919. setStatePushCallback,
  920. setOnInterviewCreated,
  921. getActiveInterviewId,
  922. registerCommand,
  923. handleCommandExecuteBefore,
  924. handleEvent,
  925. getInterviewState,
  926. listInterviewFiles,
  927. listInterviews,
  928. submitAnswers,
  929. submitBlockComment,
  930. submitChat,
  931. handleNudgeAction,
  932. };
  933. }