Skip to content
Open
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
26 changes: 23 additions & 3 deletions src/core/state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AppConfig, ChatMessage, McpInspectorSnapshot } from './types.js';
export interface AgentStateSnapshot {
config: AppConfig;
messages: ChatMessage[];
title: string | undefined;
authSource: ProviderAuthSource;
status: string;
isBusy: boolean;
Expand All @@ -17,7 +18,11 @@ type StateListener = () => void;

export interface AgentStateOptions {
initialMessages?: ChatMessage[];
onConversationChange?: (messages: ChatMessage[]) => void | Promise<void>;
initialTitle?: string | undefined;
onConversationChange?: (
messages: ChatMessage[],
title: string | undefined,
) => void | Promise<void> | Promise<boolean>;
}

export class AgentStateManager {
Expand All @@ -26,14 +31,18 @@ export class AgentStateManager {
private readonly listeners = new Set<StateListener>();

private readonly onConversationChange:
| ((messages: ChatMessage[]) => void | Promise<void>)
| ((
messages: ChatMessage[],
title: string | undefined,
) => void | Promise<void> | Promise<boolean>)
| undefined;

public constructor(config: AppConfig, options: AgentStateOptions = {}) {
this.onConversationChange = options.onConversationChange;
this.snapshotValue = {
config,
messages: structuredClone(options.initialMessages ?? []),
title: options.initialTitle,
authSource: 'missing',
status: 'Idle',
isBusy: false,
Expand Down Expand Up @@ -122,6 +131,7 @@ export class AgentStateManager {
public clearConversation(): void {
this.update((snapshot) => {
snapshot.messages = [];
snapshot.title = undefined;
snapshot.status = 'Idle';
snapshot.isBusy = false;
snapshot.streamingText = '';
Expand All @@ -132,9 +142,10 @@ export class AgentStateManager {
this.notifyConversationChange();
}

public replaceConversation(messages: ChatMessage[]): void {
public replaceConversation(messages: ChatMessage[], title?: string): void {
this.update((snapshot) => {
snapshot.messages = structuredClone(messages);
snapshot.title = title;
snapshot.status = 'Idle';
snapshot.isBusy = false;
snapshot.streamingText = '';
Expand All @@ -144,6 +155,14 @@ export class AgentStateManager {
this.notifyConversationChange();
}

public setTitle(title: string): void {
this.update((snapshot) => {
snapshot.title = title;
});

this.notifyConversationChange();
}

public replaceConfig(config: AppConfig): void {
this.update((snapshot) => {
snapshot.config = config;
Expand Down Expand Up @@ -182,6 +201,7 @@ export class AgentStateManager {

void this.onConversationChange(
structuredClone(this.snapshotValue.messages),
this.snapshotValue.title,
);
}
}
87 changes: 55 additions & 32 deletions src/core/transcript-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const chatMessageSchema = z.object({

const transcriptSchema = z.object({
messages: z.array(chatMessageSchema),
title: z.string().optional(),
});

export const transcriptPath = join(agentHomeDir, 'history.yaml');
Expand All @@ -41,6 +42,7 @@ const archiveIndexEntrySchema = z.object({
messageCount: z.number(),
preview: z.string(),
updatedAt: z.string(),
title: z.string().optional(),
});

const archiveIndexSchema = z.object({
Expand All @@ -52,6 +54,7 @@ type ArchiveSummary = {
messageCount: number;
preview: string;
updatedAt: string;
title: string | undefined;
};

async function ensureAgentHome(): Promise<void> {
Expand Down Expand Up @@ -85,12 +88,6 @@ function mapTranscriptMessage(
};
}

async function readTranscriptFile(path: string): Promise<ChatMessage[] | null> {
const raw = await readFile(path, 'utf8');
const parsed = transcriptSchema.parse(YAML.parse(raw));
return parsed.messages.map(mapTranscriptMessage);
}

function summarizeTranscript(messages: ChatMessage[]): string {
const firstMeaningfulMessage = messages.find(
(message) => message.role !== 'system',
Expand Down Expand Up @@ -198,13 +195,16 @@ async function readArchiveSummaryFromFile(
const filePath = join(transcriptArchiveDir, fileName);

try {
const messages = await readTranscriptFile(filePath);
const raw = await readFile(filePath, 'utf8');
const parsed = transcriptSchema.parse(YAML.parse(raw));
const messages = parsed.messages.map(mapTranscriptMessage);
const fileStat = await stat(filePath);
return {
id: fileName,
messageCount: messages?.length ?? 0,
preview: summarizeTranscript(messages ?? []),
updatedAt: fileStat.mtime.toISOString(),
title: parsed.title,
};
} catch (error) {
debug(
Expand Down Expand Up @@ -258,37 +258,57 @@ async function rewriteArchiveIndex(entries: ArchiveSummary[]): Promise<void> {
);
}

export async function loadTranscript(): Promise<ChatMessage[]> {
export interface LoadedTranscript {
messages: ChatMessage[];
title: string | undefined;
}

export async function loadTranscript(): Promise<LoadedTranscript> {
try {
return (await readTranscriptFile(transcriptPath)) ?? [];
const raw = await readFile(transcriptPath, 'utf8');
const parsed = transcriptSchema.parse(YAML.parse(raw));
return {
messages: parsed.messages.map(mapTranscriptMessage),
title: parsed.title,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
return { messages: [], title: undefined };
}

debug(
`Failed to load current transcript: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
return { messages: [], title: undefined };
}
}

export async function saveTranscript(messages: ChatMessage[]): Promise<void> {
export async function saveTranscript(
messages: ChatMessage[],
title?: string,
): Promise<void> {
await ensureAgentHome();
const output = YAML.stringify({ messages }, { indent: 2 });
const output = YAML.stringify(
{ messages, ...(title !== undefined ? { title } : {}) },
{ indent: 2 },
);
await writeFile(transcriptPath, output, { encoding: 'utf8' });
}

export async function archiveTranscript(
messages: ChatMessage[],
title?: string,
): Promise<void> {
if (messages.length === 0) {
return;
}

await ensureTranscriptArchiveDir();
const fileName = createArchiveFileName();
const output = YAML.stringify({ messages }, { indent: 2 });
const output = YAML.stringify(
{ messages, ...(title !== undefined ? { title } : {}) },
{ indent: 2 },
);
await writeFile(join(transcriptArchiveDir, fileName), output, {
encoding: 'utf8',
});
Expand All @@ -299,6 +319,7 @@ export async function archiveTranscript(
messageCount: messages.length,
preview,
updatedAt: new Date().toISOString(),
title,
};

try {
Expand All @@ -323,14 +344,7 @@ export async function archiveTranscript(
}
}

export async function loadArchivedSummaries(): Promise<
Array<{
id: string;
messageCount: number;
preview: string;
updatedAt: string;
}>
> {
export async function loadArchivedSummaries(): Promise<ArchiveSummary[]> {
await ensureTranscriptArchiveDir();

const archiveFiles = await listArchiveFiles();
Expand All @@ -345,7 +359,7 @@ export async function loadArchivedSummaries(): Promise<
for (const fileName of archiveFiles) {
const indexedSummary = indexedEntries.get(fileName);
if (indexedSummary) {
summaries.push(indexedSummary);
summaries.push(indexedSummary as ArchiveSummary);
continue;
}

Expand All @@ -364,7 +378,7 @@ export async function loadArchivedSummaries(): Promise<
}
}

export async function loadTranscriptById(id: string): Promise<ChatMessage[]> {
export async function loadTranscriptById(id: string): Promise<LoadedTranscript> {
if (id === 'current') {
return await loadTranscript();
}
Expand All @@ -377,8 +391,12 @@ export async function loadTranscriptById(id: string): Promise<ChatMessage[]> {
}

try {
const messages = await readTranscriptFile(resolvedPath);
return messages ?? [];
const raw = await readFile(resolvedPath, 'utf8');
const parsed = transcriptSchema.parse(YAML.parse(raw));
return {
messages: parsed.messages.map(mapTranscriptMessage),
title: parsed.title,
};
} catch (error) {
throw new Error(
`Failed to read transcript "${id}": ${error instanceof Error ? error.message : String(error)}`,
Expand All @@ -394,19 +412,22 @@ export async function listTranscripts(): Promise<
preview: string;
updatedAt: string;
isCurrent: boolean;
title: string | undefined;
}>
> {
const currentMessages = await loadTranscript();
const currentTranscript = currentMessages.length
const currentTranscript = await loadTranscript();
const currentMessages = currentTranscript.messages;
const currentTranscriptEntries = currentMessages.length
? [
{
id: 'current',
label: 'Current conversation',
label: currentTranscript.title ?? 'Current conversation',
messageCount: currentMessages.length,
preview: summarizeTranscript(currentMessages),
updatedAt:
(await statIfExists(transcriptPath)) ?? new Date(0).toISOString(),
isCurrent: true,
title: currentTranscript.title,
},
]
: [];
Expand All @@ -415,16 +436,18 @@ export async function listTranscripts(): Promise<
const archivedSummaries = archiveTranscripts.map((transcript) => ({
id: transcript.id,
label:
transcript.preview.length > 48
transcript.title ??
(transcript.preview.length > 48
? `${transcript.preview.slice(0, 45)}...`
: transcript.preview,
: transcript.preview),
messageCount: transcript.messageCount,
preview: transcript.preview,
updatedAt: transcript.updatedAt,
isCurrent: false,
title: transcript.title,
}));

return [...currentTranscript, ...archivedSummaries].sort((left, right) =>
return [...currentTranscriptEntries, ...archivedSummaries].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
);
}
Expand Down
Loading