| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033 |
- import { spawn } from 'node:child_process';
- import * as fs from 'node:fs/promises';
- import * as path from 'node:path';
- import type { PluginInput } from '@opencode-ai/plugin';
- import type { InterviewConfig } from '../config';
- import {
- createInternalAgentTextPart,
- isInternalInitiatorPart,
- log,
- } from '../utils';
- import { getClient } from '../utils/opencode-client';
- import { parseModelReference } from '../utils/session';
- import {
- appendInterviewAnswers,
- createInterviewDirectoryPath,
- createInterviewFilePath,
- DEFAULT_OUTPUT_FOLDER,
- ensureInterviewFile,
- extractSummarySection,
- extractTitle,
- normalizeOutputFolder,
- parseSpecBlocks,
- readInterviewDocument,
- relativeInterviewPath,
- resolveExistingInterviewPath,
- rewriteInterviewDocument,
- slugify,
- } from './document';
- import { buildFallbackState, findLatestAssistantState } from './parser';
- import {
- buildAnswerPrompt,
- buildKickoffPrompt,
- buildResumePrompt,
- } from './prompts';
- import type {
- InterviewAnswer,
- InterviewFileItem,
- InterviewListItem,
- InterviewMessage,
- InterviewRecord,
- InterviewState,
- } from './types';
- const COMMAND_NAME = 'interview';
- const DEFAULT_MAX_QUESTIONS = 2;
- /**
- * Cap on retained abandoned interview records. Abandoned interviews are kept
- * briefly so a still-open browser tab can render their final state, but
- * without a bound the `interviewsById` and `browserOpened` collections grow
- * for the life of a long-running session/dashboard process.
- */
- export const MAX_RETAINED_ABANDONED = 50;
- function isTruthyEnvFlag(value: string | undefined): boolean {
- if (!value) {
- return false;
- }
- return value !== '0' && value.toLowerCase() !== 'false';
- }
- function isAutomatedRuntime(env: NodeJS.ProcessEnv): boolean {
- return (
- env.NODE_ENV === 'test' ||
- isTruthyEnvFlag(env.CI) ||
- isTruthyEnvFlag(env.BUN_TEST) ||
- isTruthyEnvFlag(env.VITEST) ||
- env.JEST_WORKER_ID !== undefined
- );
- }
- function shouldAutoOpenBrowser(
- config: InterviewConfig | undefined,
- env: NodeJS.ProcessEnv,
- ): boolean {
- const requested = config?.autoOpenBrowser ?? true;
- return requested && !isAutomatedRuntime(env);
- }
- /**
- * Open a URL in the default browser.
- * Supports macOS, Linux, and Windows. Failures are logged but not thrown.
- */
- function openBrowser(url: string): void {
- const platform = process.platform;
- let command: string;
- let args: string[];
- if (platform === 'darwin') {
- command = 'open';
- args = [url];
- } else if (platform === 'win32') {
- command = 'cmd';
- args = ['/c', 'start', '', url];
- } else {
- // Linux and other Unix-like systems
- command = 'xdg-open';
- args = [url];
- }
- try {
- const child = spawn(command, args, { detached: true, stdio: 'ignore' });
- child.on('error', (error) => {
- log('[interview] failed to open browser:', { error: error.message, url });
- });
- child.unref();
- } catch (error) {
- log('[interview] failed to spawn browser opener:', {
- error: error instanceof Error ? error.message : String(error),
- url,
- });
- }
- }
- function nowIso(): string {
- return new Date().toISOString();
- }
- export function createInterviewService(
- ctx: PluginInput,
- config?: InterviewConfig,
- deps?: {
- openBrowser?: (url: string) => void;
- env?: NodeJS.ProcessEnv;
- },
- ): {
- setBaseUrlResolver: (resolver: () => Promise<string>) => void;
- setStatePushCallback: (
- callback: (interviewId: string, state: InterviewState) => void,
- ) => void;
- setOnInterviewCreated: (
- callback: (interview: InterviewRecord) => void,
- ) => void;
- getActiveInterviewId: (sessionID: string) => string | null;
- registerCommand: (config: Record<string, unknown>) => void;
- handleCommandExecuteBefore: (
- input: { command: string; sessionID: string; arguments: string },
- output: {
- parts: Array<{
- type: string;
- text?: string;
- synthetic?: boolean;
- metadata?: Record<string, unknown>;
- }>;
- },
- ) => Promise<void>;
- handleEvent: (input: {
- event: { type: string; properties?: Record<string, unknown> };
- }) => Promise<void>;
- getInterviewState: (interviewId: string) => Promise<InterviewState>;
- listInterviewFiles: () => Promise<InterviewFileItem[]>;
- listInterviews: () => InterviewListItem[];
- submitAnswers: (
- interviewId: string,
- answers: InterviewAnswer[],
- ) => Promise<void>;
- submitBlockComment: (
- interviewId: string,
- section: string,
- comment: string,
- ) => Promise<void>;
- submitChat: (interviewId: string, message: string) => Promise<void>;
- handleNudgeAction: (
- interviewId: string,
- action: 'more-questions' | 'confirm-complete',
- ) => Promise<void>;
- } {
- const maxQuestions = config?.maxQuestions ?? DEFAULT_MAX_QUESTIONS;
- const outputFolder = normalizeOutputFolder(
- config?.outputFolder ?? DEFAULT_OUTPUT_FOLDER,
- );
- const autoOpenBrowser = shouldAutoOpenBrowser(
- config,
- deps?.env ?? process.env,
- );
- const browserOpener = deps?.openBrowser ?? openBrowser;
- const activeInterviewIds = new Map<string, string>();
- const interviewsById = new Map<string, InterviewRecord>();
- const activeSyncs = new Map<string, Promise<InterviewState>>();
- const sessionBusy = new Map<string, boolean>();
- const sessionModel = new Map<string, string>();
- const browserOpened = new Set<string>(); // Track interviews that have opened browser
- let resolveBaseUrl: (() => Promise<string>) | null = null;
- let onStateChange:
- | ((interviewId: string, state: InterviewState) => void)
- | null = null;
- let onInterviewCreated: ((interview: InterviewRecord) => void) | null = null;
- let idCounter = 0;
- let abandonedOrderCounter = 0;
- function setBaseUrlResolver(resolver: () => Promise<string>): void {
- resolveBaseUrl = resolver;
- }
- function setStatePushCallback(
- callback: (interviewId: string, state: InterviewState) => void,
- ): void {
- onStateChange = callback;
- }
- function setOnInterviewCreated(
- callback: (interview: InterviewRecord) => void,
- ): void {
- onInterviewCreated = callback;
- }
- function getActiveInterviewId(sessionID: string): string | null {
- return activeInterviewIds.get(sessionID) ?? null;
- }
- async function ensureServer(): Promise<string> {
- if (!resolveBaseUrl) {
- throw new Error('Interview server is not attached');
- }
- return resolveBaseUrl();
- }
- function maybeOpenBrowser(interviewId: string, url: string): void {
- if (!autoOpenBrowser) {
- return;
- }
- if (browserOpened.has(interviewId)) {
- return;
- }
- browserOpened.add(interviewId);
- browserOpener(url);
- }
- async function maybeRenameWithTitle(
- interview: InterviewRecord,
- assistantTitle: string | undefined,
- ): Promise<void> {
- if (!assistantTitle) {
- return;
- }
- const newSlug = slugify(assistantTitle);
- if (!newSlug) {
- return;
- }
- const currentFileName = path.basename(interview.markdownPath, '.md');
- // If already matches (or user-provided idea matches), skip
- if (currentFileName === newSlug) {
- return;
- }
- const dir = path.dirname(interview.markdownPath);
- const newPath = path.join(dir, `${newSlug}.md`);
- // Don't overwrite existing files
- try {
- await fs.access(newPath);
- // File exists, don't rename
- return;
- } catch {
- // File doesn't exist, safe to rename
- }
- try {
- await fs.rename(interview.markdownPath, newPath);
- interview.markdownPath = newPath;
- log('[interview] renamed file with assistant title:', {
- from: currentFileName,
- to: newSlug,
- });
- } catch (error) {
- log('[interview] failed to rename file:', {
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
- async function loadMessages(sessionID: string): Promise<InterviewMessage[]> {
- const result = await getClient(ctx).session.messages({
- sessionID,
- });
- return result.data as InterviewMessage[];
- }
- async function loadMessagesWithRetry(
- sessionID: string,
- ): Promise<InterviewMessage[]> {
- for (let i = 0; i < 8; i++) {
- const messages = await loadMessages(sessionID);
- if (messages.length > 0) {
- const last = messages[messages.length - 1];
- if (last?.info?.role === 'assistant') {
- return messages;
- }
- }
- await new Promise((resolve) => setTimeout(resolve, 250));
- }
- return loadMessages(sessionID);
- }
- function isUserVisibleMessage(message: InterviewMessage): boolean {
- return !(message.parts ?? []).some((part) => isInternalInitiatorPart(part));
- }
- function getInterviewById(interviewId: string): InterviewRecord | null {
- return interviewsById.get(interviewId) ?? null;
- }
- /**
- * Mark an interview abandoned and prune the oldest abandoned records so the
- * in-memory registry (and its browser-open tracking) stays bounded.
- */
- function abandonInterview(interview: InterviewRecord): void {
- if (interview.status !== 'abandoned') {
- interview.abandonedAt = nowIso();
- interview.abandonedOrder = ++abandonedOrderCounter;
- }
- interview.status = 'abandoned';
- pruneAbandonedInterviews();
- }
- function pruneAbandonedInterviews(): void {
- const abandoned = [...interviewsById.values()].filter(
- (record) => record.status === 'abandoned',
- );
- const overflow = abandoned.length - MAX_RETAINED_ABANDONED;
- if (overflow <= 0) return;
- abandoned
- .sort((a, b) => {
- const timeDelta =
- new Date(a.abandonedAt ?? a.createdAt).getTime() -
- new Date(b.abandonedAt ?? b.createdAt).getTime();
- if (timeDelta !== 0) return timeDelta;
- return (a.abandonedOrder ?? 0) - (b.abandonedOrder ?? 0);
- })
- .slice(0, overflow)
- .forEach((record) => {
- interviewsById.delete(record.id);
- browserOpened.delete(record.id);
- });
- }
- async function createInterview(
- sessionID: string,
- idea: string,
- ): Promise<InterviewRecord> {
- const normalizedIdea = idea.trim();
- const activeId = activeInterviewIds.get(sessionID);
- if (activeId) {
- const active = interviewsById.get(activeId);
- if (active && active.status === 'active') {
- if (active.idea === normalizedIdea) {
- return active;
- }
- abandonInterview(active);
- }
- }
- const messages = await loadMessages(sessionID);
- const record: InterviewRecord = {
- id: `${Date.now()}-${++idCounter}-${slugify(idea) || 'interview'}`,
- sessionID,
- idea: normalizedIdea,
- markdownPath: createInterviewFilePath(ctx.directory, outputFolder, idea),
- createdAt: nowIso(),
- status: 'active',
- baseMessageCount: messages.length,
- };
- await ensureInterviewFile(record);
- activeInterviewIds.set(sessionID, record.id);
- interviewsById.set(record.id, record);
- fileCache = null;
- if (onInterviewCreated) {
- onInterviewCreated(record);
- }
- return record;
- }
- async function resumeInterview(
- sessionID: string,
- markdownPath: string,
- ): Promise<InterviewRecord> {
- const activeId = activeInterviewIds.get(sessionID);
- if (activeId) {
- const active = interviewsById.get(activeId);
- if (active && active.status === 'active') {
- if (active.markdownPath === markdownPath) {
- return active;
- }
- abandonInterview(active);
- }
- }
- const document = await fs.readFile(markdownPath, 'utf8');
- const messages = await loadMessages(sessionID);
- const title = extractTitle(document);
- const record: InterviewRecord = {
- id: `${Date.now()}-${++idCounter}-${slugify(path.basename(markdownPath, '.md')) || 'interview'}`,
- sessionID,
- idea: title || path.basename(markdownPath, '.md'),
- markdownPath,
- createdAt: nowIso(),
- status: 'active',
- baseMessageCount: messages.length,
- };
- activeInterviewIds.set(sessionID, record.id);
- interviewsById.set(record.id, record);
- fileCache = null;
- if (onInterviewCreated) {
- onInterviewCreated(record);
- }
- return record;
- }
- function syncInterview(interview: InterviewRecord): Promise<InterviewState> {
- const existing = activeSyncs.get(interview.id);
- if (existing) {
- return existing;
- }
- const sync = performSyncInterview(interview).finally(() => {
- activeSyncs.delete(interview.id);
- });
- activeSyncs.set(interview.id, sync);
- return sync;
- }
- async function performSyncInterview(
- interview: InterviewRecord,
- ): Promise<InterviewState> {
- const allMessages = await loadMessagesWithRetry(interview.sessionID);
- const interviewMessages = allMessages
- .slice(interview.baseMessageCount)
- .filter(isUserVisibleMessage);
- const parsed = findLatestAssistantState(interviewMessages, maxQuestions);
- const existingDocument = await readInterviewDocument(interview);
- const fallbackState = buildFallbackState(interviewMessages);
- const state = parsed.state ?? {
- ...fallbackState,
- summary: extractSummarySection(existingDocument) || fallbackState.summary,
- };
- // Rename file if assistant provided a title (and file hasn't been renamed yet)
- await maybeRenameWithTitle(interview, state.title);
- // Skip rewrite when parsed.state is null - agent already wrote the final spec
- let document: string;
- if (parsed.state) {
- document = await rewriteInterviewDocument(interview, state.summary);
- } else {
- document = await readInterviewDocument(interview);
- }
- const blocks = parseSpecBlocks(document);
- const interviewState: InterviewState = {
- interview,
- url: `${await ensureServer()}/interview/${interview.id}`,
- markdownPath: relativeInterviewPath(
- ctx.directory,
- interview.markdownPath,
- ),
- mode:
- interview.status === 'abandoned'
- ? 'abandoned'
- : parsed.state && state.questions.length === 0
- ? 'completed'
- : sessionBusy.get(interview.sessionID) === true
- ? 'awaiting-agent'
- : state.questions.length > 0
- ? 'awaiting-user'
- : parsed.latestAssistantError
- ? 'error'
- : !parsed.state &&
- sessionBusy.get(interview.sessionID) === false
- ? 'completed'
- : 'awaiting-agent',
- lastParseError: parsed.latestAssistantError,
- isBusy: sessionBusy.get(interview.sessionID) === true,
- summary: state.summary,
- questions: state.questions,
- document,
- blocks,
- };
- // Push state to dashboard if callback is set (dashboard mode)
- if (onStateChange) {
- onStateChange(interview.id, interviewState);
- }
- return interviewState;
- }
- async function notifyInterviewUrl(
- sessionID: string,
- interview: InterviewRecord,
- ): Promise<void> {
- const baseUrl = await ensureServer();
- const url = `${baseUrl}/interview/${interview.id}`;
- // Auto-open browser on initial creation (not on every poll/refresh)
- maybeOpenBrowser(interview.id, url);
- await getClient(ctx).session.prompt({
- sessionID,
- noReply: true,
- parts: [
- {
- type: 'text',
- text: [
- '⎔ Interview UI ready',
- '',
- `Open: ${url}`,
- `Document: ${relativeInterviewPath(ctx.directory, interview.markdownPath)}`,
- '',
- '[system status: continue without acknowledging this notification]',
- ].join('\n'),
- },
- ],
- });
- }
- function registerCommand(opencodeConfig: Record<string, unknown>): void {
- const configCommand = opencodeConfig.command as
- | Record<string, unknown>
- | undefined;
- if (!configCommand?.[COMMAND_NAME]) {
- if (!opencodeConfig.command) {
- opencodeConfig.command = {};
- }
- (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
- template: 'Start an interview and write a live markdown spec',
- description:
- 'Open a localhost interview UI linked to the current OpenCode session',
- };
- }
- }
- async function getInterviewState(
- interviewId: string,
- ): Promise<InterviewState> {
- const interview = getInterviewById(interviewId);
- if (!interview) {
- throw new Error('Interview not found');
- }
- return syncInterview(interview);
- }
- function listInterviews(): InterviewListItem[] {
- const result: InterviewListItem[] = [];
- for (const interview of interviewsById.values()) {
- if (interview.status !== 'active') continue;
- result.push({
- id: interview.id,
- idea: interview.idea,
- status: interview.status,
- createdAt: interview.createdAt,
- });
- }
- return result.sort(
- (a, b) =>
- new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
- );
- }
- async function submitAnswers(
- interviewId: string,
- answers: InterviewAnswer[],
- ): Promise<void> {
- const interview = getInterviewById(interviewId);
- if (!interview) {
- throw new Error('Interview not found');
- }
- if (interview.status === 'abandoned') {
- throw new Error('Interview session is no longer active.');
- }
- if (sessionBusy.get(interview.sessionID) === true) {
- throw new Error(
- 'Interview session is busy. Wait for the current response.',
- );
- }
- // Acquire busy lock immediately before any async operations to prevent race
- sessionBusy.set(interview.sessionID, true);
- let promptSent = false;
- try {
- const state = await getInterviewState(interviewId);
- if (state.mode === 'error') {
- throw new Error('Interview is waiting for a valid agent update.');
- }
- const activeQuestionIds = new Set(
- state.questions.map((question) => question.id),
- );
- if (activeQuestionIds.size === 0) {
- throw new Error('There are no active interview questions to answer.');
- }
- if (answers.length !== activeQuestionIds.size) {
- throw new Error(
- 'Answer every active interview question before submitting.',
- );
- }
- const invalidAnswer = answers.find(
- (answer) =>
- !activeQuestionIds.has(answer.questionId) || !answer.answer.trim(),
- );
- if (invalidAnswer) {
- throw new Error(
- 'Answers do not match the current interview questions.',
- );
- }
- await appendInterviewAnswers(interview, state.questions, answers);
- const prompt = buildAnswerPrompt(answers, state.questions, maxQuestions);
- // Use promptAsync for non-blocking - returns immediately, LLM
- // processes in background. State push updates dashboard when done.
- const model = sessionModel.get(interview.sessionID);
- await getClient(ctx).session.promptAsync({
- sessionID: interview.sessionID,
- agent: 'orchestrator',
- parts: [createInternalAgentTextPart(prompt)],
- ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
- });
- promptSent = true;
- } finally {
- if (!promptSent) {
- sessionBusy.set(interview.sessionID, false);
- }
- }
- }
- async function handleCommandExecuteBefore(
- input: { command: string; sessionID: string; arguments: string },
- output: { parts: Array<{ type: string; text?: string }> },
- ): Promise<void> {
- if (input.command !== COMMAND_NAME) {
- return;
- }
- const idea = input.arguments.trim();
- output.parts.length = 0;
- if (!idea) {
- const activeId = activeInterviewIds.get(input.sessionID);
- const interview = activeId ? interviewsById.get(activeId) : null;
- if (interview?.status !== 'active') {
- output.parts.push(
- createInternalAgentTextPart(
- 'The user ran /interview without an idea. Ask them for the product idea in one sentence.',
- ),
- );
- return;
- }
- await notifyInterviewUrl(input.sessionID, interview);
- output.parts.push(
- createInternalAgentTextPart(
- `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.`,
- ),
- );
- return;
- }
- const resumePath = resolveExistingInterviewPath(
- ctx.directory,
- outputFolder,
- idea,
- );
- if (resumePath) {
- const interview = await resumeInterview(input.sessionID, resumePath);
- const document = await fs.readFile(interview.markdownPath, 'utf8');
- await notifyInterviewUrl(input.sessionID, interview);
- output.parts.push(
- createInternalAgentTextPart(buildResumePrompt(document, maxQuestions)),
- );
- return;
- }
- const interview = await createInterview(input.sessionID, idea);
- await notifyInterviewUrl(input.sessionID, interview);
- output.parts.push(
- createInternalAgentTextPart(buildKickoffPrompt(idea, maxQuestions)),
- );
- // best-effort: rename the session so it's identifiable in the session list.
- // never block interview creation if the rename fails.
- let sessionTitle = `Interview: ${idea}`;
- if (sessionTitle.length > 50) {
- sessionTitle = `${sessionTitle.slice(0, 49)}…`;
- }
- getClient(ctx)
- .session.update({
- sessionID: input.sessionID,
- title: sessionTitle,
- })
- .catch(() => {});
- }
- async function handleEvent(input: {
- event: { type: string; properties?: Record<string, unknown> };
- }): Promise<void> {
- const { event } = input;
- const properties = event.properties ?? {};
- if (event.type === 'session.status') {
- const sessionID = properties.sessionID as string | undefined;
- const status = properties.status as { type?: string } | undefined;
- if (sessionID) {
- sessionBusy.set(sessionID, status?.type === 'busy');
- }
- return;
- }
- if (event.type === 'message.updated') {
- const info = properties as
- | {
- info?: {
- sessionID?: string;
- providerID?: string;
- modelID?: string;
- };
- }
- | undefined;
- const sessionID = info?.info?.sessionID;
- const providerID = info?.info?.providerID;
- const modelID = info?.info?.modelID;
- if (sessionID && providerID && modelID) {
- sessionModel.set(sessionID, `${providerID}/${modelID}`);
- }
- return;
- }
- if (event.type === 'session.deleted') {
- const deletedSessionId =
- ((properties.info as { id?: string } | undefined)?.id ??
- (properties.sessionID as string | undefined)) ||
- null;
- if (!deletedSessionId) {
- return;
- }
- sessionBusy.delete(deletedSessionId);
- sessionModel.delete(deletedSessionId);
- const interviewId = activeInterviewIds.get(deletedSessionId);
- if (!interviewId) {
- return;
- }
- const interview = interviewsById.get(interviewId);
- if (!interview) {
- return;
- }
- abandonInterview(interview);
- fileCache = null;
- activeInterviewIds.delete(deletedSessionId);
- log('[interview] session deleted, interview marked abandoned', {
- sessionID: deletedSessionId,
- interviewId,
- });
- }
- }
- let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
- const FILE_CACHE_TTL = 10_000;
- async function listInterviewFiles(): Promise<InterviewFileItem[]> {
- if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
- return fileCache.items;
- }
- const outputDir = createInterviewDirectoryPath(ctx.directory, outputFolder);
- const activePaths = new Set(
- [...interviewsById.values()]
- .filter((i) => i.status === 'active')
- .map((i) => path.resolve(i.markdownPath)),
- );
- let entries: string[];
- try {
- entries = await fs.readdir(outputDir);
- } catch {
- return [];
- }
- const items: InterviewFileItem[] = [];
- for (const entry of entries) {
- if (!entry.endsWith('.md')) continue;
- const fullPath = path.join(outputDir, entry);
- if (activePaths.has(path.resolve(fullPath))) continue;
- let content: string;
- try {
- content = await fs.readFile(fullPath, 'utf8');
- } catch {
- continue;
- }
- const title = extractTitle(content) || entry.replace(/\.md$/, '');
- const summary = extractSummarySection(content) || '';
- const baseName = entry.replace(/\.md$/, '');
- items.push({
- fileName: entry,
- resumeCommand: `/interview ${baseName}`,
- title,
- summary:
- summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
- });
- }
- const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
- fileCache = { items: sorted, at: Date.now() };
- return sorted;
- }
- async function submitBlockComment(
- interviewId: string,
- sectionTitle: string,
- comment: string,
- ): Promise<void> {
- const interview = getInterviewById(interviewId);
- if (!interview) {
- throw new Error('Interview not found');
- }
- if (interview.status === 'abandoned') {
- throw new Error('Interview session is no longer active.');
- }
- if (sessionBusy.get(interview.sessionID) === true) {
- throw new Error(
- 'Interview session is busy. Wait for the current response.',
- );
- }
- sessionBusy.set(interview.sessionID, true);
- let promptSent = false;
- try {
- const state = await getInterviewState(interviewId);
- if (state.mode === 'error') {
- throw new Error('Interview is waiting for a valid agent update.');
- }
- const relativePath = relativeInterviewPath(
- ctx.directory,
- interview.markdownPath,
- );
- const prompt = [
- `You are updating the active interview specification document at "${relativePath}".`,
- `The current document content on disk is:`,
- `\`\`\`markdown`,
- state.document,
- `\`\`\``,
- ``,
- `The user submitted specific feedback/comments for the section "${sectionTitle}".`,
- `Feedback: ${comment}`,
- ``,
- `Update the specification summary (focusing heavily on making changes to the "${sectionTitle}" section) to address this feedback.`,
- `If this feedback implies other parts of the spec should change, update them too.`,
- `Include the updated 11-section specification and ask the next highest-value clarifying questions as questions (up to ${maxQuestions} questions) if needed.`,
- `Return the same <interview_state> JSON block format as before.`,
- ].join('\n');
- const model = sessionModel.get(interview.sessionID);
- await getClient(ctx).session.promptAsync({
- sessionID: interview.sessionID,
- agent: 'orchestrator',
- parts: [createInternalAgentTextPart(prompt)],
- ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
- });
- promptSent = true;
- } finally {
- if (!promptSent) {
- sessionBusy.set(interview.sessionID, false);
- }
- }
- }
- async function submitChat(
- interviewId: string,
- message: string,
- ): Promise<void> {
- const interview = getInterviewById(interviewId);
- if (!interview) {
- throw new Error('Interview not found');
- }
- if (interview.status === 'abandoned') {
- throw new Error('Interview session is no longer active.');
- }
- if (sessionBusy.get(interview.sessionID) === true) {
- throw new Error(
- 'Interview session is busy. Wait for the current response.',
- );
- }
- sessionBusy.set(interview.sessionID, true);
- let promptSent = false;
- try {
- const state = await getInterviewState(interviewId);
- if (state.mode === 'error') {
- throw new Error('Interview is waiting for a valid agent update.');
- }
- const relativePath = relativeInterviewPath(
- ctx.directory,
- interview.markdownPath,
- );
- const prompt = [
- `You are continuing the interview for the specification document at "${relativePath}".`,
- `The current document content on disk is:`,
- `\`\`\`markdown`,
- state.document,
- `\`\`\``,
- ``,
- `The user sent a freeform message via the dashboard chat panel:`,
- `${message}`,
- ``,
- `Process this request - it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
- `Update the specification document accordingly and include the updated 11-section specification.`,
- `Ask up to ${maxQuestions} clarifying questions if needed using the same <interview_state> JSON block format as before.`,
- ].join('\n');
- const model = sessionModel.get(interview.sessionID);
- await getClient(ctx).session.promptAsync({
- sessionID: interview.sessionID,
- agent: 'orchestrator',
- parts: [createInternalAgentTextPart(prompt)],
- ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
- });
- promptSent = true;
- } finally {
- if (!promptSent) {
- sessionBusy.set(interview.sessionID, false);
- }
- }
- }
- async function handleNudgeAction(
- interviewId: string,
- action: 'more-questions' | 'confirm-complete',
- ): Promise<void> {
- const interview = getInterviewById(interviewId);
- if (!interview) {
- throw new Error('Interview not found');
- }
- if (interview.status === 'abandoned') {
- throw new Error('Interview session is no longer active.');
- }
- if (sessionBusy.get(interview.sessionID) === true) {
- throw new Error(
- 'Interview session is busy. Wait for the current response.',
- );
- }
- sessionBusy.set(interview.sessionID, true);
- let promptSent = false;
- try {
- const state = await getInterviewState(interviewId);
- const relativePath = relativeInterviewPath(
- ctx.directory,
- interview.markdownPath,
- );
- let prompt: string;
- if (action === 'more-questions') {
- prompt = [
- `You are continuing the interview for the specification document at "${relativePath}".`,
- `The current document content on disk is:`,
- `\`\`\`markdown`,
- state.document,
- `\`\`\``,
- ``,
- `The user reviewed the completed interview spec and wants you to continue.`,
- ``,
- `Ask up to ${maxQuestions} new clarifying questions about aspects that are still unclear or underspecified.`,
- `Include the structured <interview_state> block with new questions.`,
- ].join('\n');
- } else {
- prompt = [
- `You are finishing the interview for the specification document at "${relativePath}".`,
- `The current document content on disk is:`,
- `\`\`\`markdown`,
- state.document,
- `\`\`\``,
- ``,
- `The user confirmed the interview spec is complete.`,
- ``,
- `Produce a final, polished version of the full spec document.`,
- `Do NOT include any <interview_state> block - just output the final spec as clean markdown.`,
- `The spec should be comprehensive, well-structured, and ready for implementation.`,
- ].join('\n');
- }
- const model = sessionModel.get(interview.sessionID);
- await getClient(ctx).session.promptAsync({
- sessionID: interview.sessionID,
- agent: 'orchestrator',
- parts: [createInternalAgentTextPart(prompt)],
- ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
- });
- promptSent = true;
- } finally {
- if (!promptSent) {
- sessionBusy.set(interview.sessionID, false);
- }
- }
- }
- return {
- setBaseUrlResolver,
- setStatePushCallback,
- setOnInterviewCreated,
- getActiveInterviewId,
- registerCommand,
- handleCommandExecuteBefore,
- handleEvent,
- getInterviewState,
- listInterviewFiles,
- listInterviews,
- submitAnswers,
- submitBlockComment,
- submitChat,
- handleNudgeAction,
- };
- }
|