Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/infra/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ function createDefaultConfig(): AppConfig {
apiHeaders: {},
reasoningEffort: DEFAULT_LLM_REASONING_EFFORT,
stream: false,
showInteraction: false,
interactionMode: 'summary',
activeProfile: '',
profiles: {},
},
Expand Down Expand Up @@ -187,6 +189,8 @@ export class ConfigService {
},
reasoningEffort: this.normalizeReasoningEffort(config.llm?.reasoningEffort),
stream: config.llm?.stream === true,
showInteraction: config.llm?.showInteraction === true,
interactionMode: this.normalizeInteractionMode(config.llm?.interactionMode),
profiles: this.normalizeProviderProfiles(config.llm?.profiles),
},
automation: {
Expand Down Expand Up @@ -234,6 +238,10 @@ export class ConfigService {
}
}

private normalizeInteractionMode(value: unknown): AppConfig['llm']['interactionMode'] {
return value === 'raw' ? 'raw' : 'summary';
}
Comment on lines +241 to +243

private normalizeProviderProfiles(
profiles: AppConfig['llm']['profiles'] = {},
): AppConfig['llm']['profiles'] {
Expand Down
10 changes: 10 additions & 0 deletions src/infra/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export type {
} from './github-repo.js';
export { UserCancelledError, isPromptAbortError, isUserCancelledError, getErrorMessage } from './errors.js';
export { DEFAULT_LLM_REASONING_EFFORT, LLM_REASONING_EFFORTS, parseLLMReasoningEffort } from './llm-reasoning.js';
export {
BufferedLLMInteractionReporter,
NoopLLMInteractionReporter,
createLLMInteractionReporter,
} from './llm-interaction-reporter.js';
export type {
LLMInteractionEvent,
LLMInteractionReporter,
LLMInteractionStage,
} from './llm-interaction-reporter.js';
export { prompt } from './prompts.js';
export { selectPrompt } from './select.js';
export { ui } from './ui.js';
Expand Down
245 changes: 245 additions & 0 deletions src/infra/llm-interaction-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import type { LLMInteractionMode, LLMProvider } from '../types/index.js';

export type LLMInteractionStage =
| 'validate'
| 'issue_scoring'
| 'repository_analysis'
| 'patch_draft'
| 'implementation_draft'
| 'validation_repair'
| 'pull_request_draft'
| 'daily_report'
| 'daily_diary';

export interface LLMInteractionEvent {
stage: LLMInteractionStage;
model: string;
provider: LLMProvider;
streaming: boolean;
promptChars: number;
context?: string;
}

export interface LLMInteractionReporter {
onRequestStart(event: LLMInteractionEvent): void;
onResponseChunk(chunk: string): void;
onResponseComplete(event: LLMInteractionEvent & { responseChars: number }): void;
onParseComplete(event: LLMInteractionEvent & { kind: string; status: string; parsed?: unknown }): void;
onRepairStart(event: LLMInteractionEvent & { error: string }): void;
}

export interface BufferedLLMInteractionReporterOptions {
write?: (line: string) => void;
chunkFlushChars?: number;
mode?: LLMInteractionMode;
}

export class NoopLLMInteractionReporter implements LLMInteractionReporter {
onRequestStart(): void {}
onResponseChunk(): void {}
onResponseComplete(): void {}
onParseComplete(): void {}
onRepairStart(): void {}
}

export class BufferedLLMInteractionReporter implements LLMInteractionReporter {
private readonly write: (line: string) => void;
private readonly chunkFlushChars: number;
private readonly mode: LLMInteractionMode;
private chunkBuffer = '';
private wroteAssistantHeader = false;

constructor(options: BufferedLLMInteractionReporterOptions = {}) {
this.write = options.write ?? ((line) => process.stdout.write(`${line}\n`));
this.chunkFlushChars = options.chunkFlushChars ?? 500;
this.mode = options.mode ?? 'summary';
}

onRequestStart(event: LLMInteractionEvent): void {
this.chunkBuffer = '';
this.wroteAssistantHeader = false;
this.write('');
this.write(`LLM Interaction: ${event.stage}`);
this.write(`Model: ${event.model}`);
this.write(`Provider: ${event.provider}`);
this.write(`Streaming: ${event.streaming ? 'yes' : 'no'}`);
this.write(`Prompt: ${event.promptChars.toLocaleString()} chars`);
if (event.context) {
this.write(`Context: ${event.context}`);
}
}

onResponseChunk(chunk: string): void {
if (this.mode !== 'raw') {
return;
}

if (!chunk) {
return;
}

this.chunkBuffer += chunk;
if (this.chunkBuffer.length >= this.chunkFlushChars) {
this.flushChunks();
}
}

onResponseComplete(event: LLMInteractionEvent & { responseChars: number }): void {
this.flushChunks();
this.write(`Assistant response received: ${event.responseChars.toLocaleString()} chars`);
}

onParseComplete(event: LLMInteractionEvent & { kind: string; status: string; parsed?: unknown }): void {
this.write(`Parsed: ${event.kind} / ${event.status}`);
if (this.mode !== 'summary') {
return;
}

for (const line of this.describeParsedOutput(event.kind, event.parsed)) {
this.write(line);
}
}

onRepairStart(event: LLMInteractionEvent & { error: string }): void {
this.write(`Structured output parse failed; requesting repair. ${event.error}`);
}

private flushChunks(): void {
if (!this.chunkBuffer) {
return;
}

if (!this.wroteAssistantHeader) {
this.write('Assistant:');
this.wroteAssistantHeader = true;
}

this.write(this.chunkBuffer);
this.chunkBuffer = '';
}

private describeParsedOutput(kind: string, parsed: unknown): string[] {
const data = this.getEnvelopeData(parsed);

if (kind === 'repository_suggestion_list' && Array.isArray(data)) {
return [
`Repository suggestions: ${data.length}`,
...data.slice(0, 5).map((item) => {
const record = this.asRecord(item);
const title = this.asString(record?.['title'], 'Untitled suggestion');
const score = this.asNumber(record?.['prPotentialScore']);
const files = this.formatTargetFiles(record?.['targetFiles']);
return `- ${title}${score === undefined ? '' : ` | score ${score}`}${files ? ` | files ${files}` : ''}`;
}),
];
}

if (kind === 'patch_draft' && data) {
const record = this.asRecord(data);
return [
`Patch goal: ${this.asString(record?.['goal'], 'Not provided')}`,
`Target files: ${this.formatTargetFiles(record?.['targetFiles']) || 'none'}`,
`Changes: ${this.countArray(record?.['proposedChanges'])}`,
`Risks: ${this.countArray(record?.['risks'])}`,
];
}

if (kind === 'implementation_draft' && data) {
const record = this.asRecord(data);
return [
`Implementation summary: ${this.asString(record?.['summary'], 'Not provided')}`,
`File changes: ${this.formatFileChanges(record?.['fileChanges']) || 'none'}`,
];
}

if (kind === 'pull_request_draft' && data) {
const record = this.asRecord(data);
return [
`PR title: ${this.asString(record?.['title'], 'Not provided')}`,
`Changes: ${this.countArray(record?.['changes'])}`,
`Validation notes: ${this.countArray(record?.['validation'])}`,
`Risks: ${this.countArray(record?.['risks'])}`,
];
}

if (kind === 'issue_match_list' && Array.isArray(data)) {
return [
`Matched issues: ${data.length}`,
...data.slice(0, 5).map((item) => {
const record = this.asRecord(item);
const reference = `${this.asString(record?.['repoFullName'], 'unknown')}#${this.asString(record?.['number'], '?')}`;
const title = this.asString(record?.['title'], 'Untitled issue');
const score = this.asNumber(record?.['matchScore']);
return `- ${reference} | ${title}${score === undefined ? '' : ` | score ${score}`}`;
}),
];
}

return [];
}

private getEnvelopeData(value: unknown): unknown {
const record = this.asRecord(value);
return record?.['data'];
}

private formatTargetFiles(value: unknown): string {
if (!Array.isArray(value)) {
return '';
}
return value
.slice(0, 5)
.map((item) => this.asString(this.asRecord(item)?.['path'], ''))
.filter(Boolean)
.join(', ');
}

private formatFileChanges(value: unknown): string {
if (!Array.isArray(value)) {
return '';
}
return value
.slice(0, 5)
.map((item) => this.asString(this.asRecord(item)?.['path'], ''))
.filter(Boolean)
.join(', ');
}

private countArray(value: unknown): string {
return Array.isArray(value) ? String(value.length) : '0';
}

private asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined;
}

private asString(value: unknown, fallback: string): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
return fallback;
}

private asNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined;
}
}

export function createLLMInteractionReporter(
enabled: boolean,
modeOrOptions?: LLMInteractionMode | BufferedLLMInteractionReporterOptions,
options?: BufferedLLMInteractionReporterOptions,
): LLMInteractionReporter {
if (!enabled) {
return new NoopLLMInteractionReporter();
}

if (typeof modeOrOptions === 'string') {
return new BufferedLLMInteractionReporter({ ...options, mode: modeOrOptions });
}

return new BufferedLLMInteractionReporter(modeOrOptions);
}
4 changes: 4 additions & 0 deletions src/orchestration/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
} from '../types/index.js';
import {
configService,
createLLMInteractionReporter,
ensureDirectory,
getLocalDateStamp,
getOpenMetaArtifactRoot,
Expand Down Expand Up @@ -808,6 +809,9 @@ export class AgentOrchestrator {
config.llm.provider,
config.llm.reasoningEffort,
config.llm.stream === true,
config.llm.showInteraction === true,
createLLMInteractionReporter(config.llm.showInteraction === true, config.llm.interactionMode || 'summary'),
config.llm.interactionMode || 'summary',
);
const llmValid = await ui.task({
title: 'Validating LLM provider',
Expand Down
4 changes: 4 additions & 0 deletions src/orchestration/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PatchDraft, PullRequestDraft, RepositoryImprovementSuggestion } fr
import type { AppConfig, RankedIssue, RepoWorkspaceContext } from '../types/index.js';
import {
configService,
createLLMInteractionReporter,
ensureDirectory,
getLocalDateStamp,
getOpenMetaArtifactRoot,
Expand Down Expand Up @@ -204,6 +205,9 @@ export class AnalyzeOrchestrator {
config.llm.provider,
config.llm.reasoningEffort,
config.llm.stream === true,
config.llm.showInteraction === true,
createLLMInteractionReporter(config.llm.showInteraction === true, config.llm.interactionMode || 'summary'),
config.llm.interactionMode || 'summary',
);
const llmValid = await ui.task({
title: 'Validating LLM provider',
Expand Down
20 changes: 19 additions & 1 deletion src/orchestration/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class ConfigOrchestrator {
{ label: 'Model', value: config.llm.modelName || '(not set)', tone: config.llm.modelName ? 'info' : 'warning' },
{ label: 'Reasoning effort', value: config.llm.reasoningEffort || 'none', tone: 'info' },
{ label: 'Streaming', value: config.llm.stream ? 'yes' : 'no', tone: config.llm.stream ? 'info' : 'muted' },
{ label: 'LLM interaction output', value: config.llm.showInteraction ? 'yes' : 'no', tone: config.llm.showInteraction ? 'info' : 'muted' },
{ label: 'LLM interaction mode', value: config.llm.interactionMode || 'summary', tone: config.llm.showInteraction ? 'info' : 'muted' },
{ label: 'Extra headers', value: Object.keys(config.llm.apiHeaders || {}).length > 0 ? JSON.stringify(config.llm.apiHeaders) : '(none)', tone: 'info' },
{ label: 'API key', value: ui.maskSecret(config.llm.apiKey), tone: config.llm.apiKey ? 'info' : 'warning' },
{ label: 'Saved profiles', value: String(Object.keys(config.llm.profiles || {}).length), tone: Object.keys(config.llm.profiles || {}).length > 0 ? 'info' : 'muted' },
Expand Down Expand Up @@ -99,7 +101,7 @@ export class ConfigOrchestrator {
async set(key: string, value: string): Promise<void> {
const config = await configService.get();
const validPaths = ['userProfile.techStack', 'userProfile.proficiency', 'userProfile.focusAreas',
'github.username', 'github.pat', 'github.targetRepoPath', 'llm.provider', 'llm.apiBaseUrl', 'llm.apiKey', 'llm.modelName', 'llm.reasoningEffort', 'llm.stream',
'github.username', 'github.pat', 'github.targetRepoPath', 'llm.provider', 'llm.apiBaseUrl', 'llm.apiKey', 'llm.modelName', 'llm.reasoningEffort', 'llm.stream', 'llm.showInteraction', 'llm.interactionMode',
'automation.enabled', 'automation.scheduleTime', 'automation.contentType',
'automation.minMatchScore', 'automation.skipIfAlreadyGeneratedToday',
'commitTemplate'];
Expand Down Expand Up @@ -150,6 +152,10 @@ export class ConfigOrchestrator {
updated = await configService.update({ llm: { ...config.llm, reasoningEffort: parseLLMReasoningEffort(value) } });
} else if (key === 'llm.stream') {
updated = await configService.update({ llm: { ...config.llm, stream: this.parseBoolean(value, key) } });
} else if (key === 'llm.showInteraction') {
updated = await configService.update({ llm: { ...config.llm, showInteraction: this.parseBoolean(value, key) } });
} else if (key === 'llm.interactionMode') {
updated = await configService.update({ llm: { ...config.llm, interactionMode: this.parseInteractionMode(value) } });
} else if (key === 'automation.enabled') {
updated = await configService.update({
automation: {
Expand Down Expand Up @@ -312,6 +318,10 @@ export class ConfigOrchestrator {
return config.llm.reasoningEffort || 'none';
case 'llm.stream':
return config.llm.stream ? 'yes' : 'no';
case 'llm.showInteraction':
return config.llm.showInteraction ? 'yes' : 'no';
case 'llm.interactionMode':
return config.llm.interactionMode || 'summary';
case 'automation.enabled':
return config.automation.enabled ? 'yes' : 'no';
case 'automation.scheduleTime':
Expand All @@ -328,6 +338,14 @@ export class ConfigOrchestrator {
return '(updated)';
}
}

private parseInteractionMode(value: string): AppConfig['llm']['interactionMode'] {
const normalized = value.trim().toLowerCase();
if (normalized === 'summary' || normalized === 'raw') {
return normalized;
}
throw new Error('llm.interactionMode must be "summary" or "raw".');
}
}

export const configOrchestrator = new ConfigOrchestrator();
2 changes: 1 addition & 1 deletion src/orchestration/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export class DoctorOrchestrator {
label: 'LLM configuration',
status: 'pass',
summary: `${config.llm.provider} provider is configured.`,
detail: `${config.llm.modelName} at ${config.llm.apiBaseUrl}; reasoning ${config.llm.reasoningEffort || 'none'}; streaming ${config.llm.stream ? 'yes' : 'no'}; key ${ui.maskSecret(config.llm.apiKey)}`,
detail: `${config.llm.modelName} at ${config.llm.apiBaseUrl}; reasoning ${config.llm.reasoningEffort || 'none'}; streaming ${config.llm.stream ? 'yes' : 'no'}; interaction output ${config.llm.showInteraction ? `yes (${config.llm.interactionMode || 'summary'})` : 'no'}; key ${ui.maskSecret(config.llm.apiKey)}`,
};
}

Expand Down
Loading
Loading