Major enhancements to voice assistant with conversation persistence and mobile app planning: - Add conversation management with multiple active conversations - Implement conversation persistence (save/load from disk) - Add conversation selector UI for switching between conversations - Enhance agent prompt with per-conversation tmux sessions - Add mobile app technical specification (React Native + Expo) - Improve UI with active processes view and agent stream - Add ACP (Agent Control Protocol) server implementation Mobile spec details: - React Native with Expo SDK 54+ - NativeWind v5 for styling - Share Zod schemas/types from server - Android in-call experience (InCallManager) - Direct WebSocket connection via Tailscale - Same audio quality as web prototype 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
155 lines
4.0 KiB
TypeScript
155 lines
4.0 KiB
TypeScript
import { readFile, writeFile, readdir, unlink, mkdir, stat } from "fs/promises";
|
|
import { join } from "path";
|
|
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
|
import { standardizePrompt } from "ai/internal";
|
|
|
|
const CONVERSATIONS_DIR = join(process.cwd(), "conversations");
|
|
|
|
interface ConversationMetadata {
|
|
id: string;
|
|
lastUpdated: Date;
|
|
messageCount: number;
|
|
}
|
|
|
|
interface ConversationData {
|
|
conversationId: string;
|
|
lastUpdated: string;
|
|
messageCount: number;
|
|
messages: ModelMessage[];
|
|
}
|
|
|
|
/**
|
|
* Ensure conversations directory exists
|
|
*/
|
|
async function ensureConversationsDir(): Promise<void> {
|
|
await mkdir(CONVERSATIONS_DIR, { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* Save conversation to disk
|
|
*/
|
|
export async function saveConversation(
|
|
conversationId: string,
|
|
messages: ModelMessage[]
|
|
): Promise<void> {
|
|
try {
|
|
await ensureConversationsDir();
|
|
|
|
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
|
|
const data: ConversationData = {
|
|
conversationId,
|
|
lastUpdated: new Date().toISOString(),
|
|
messageCount: messages.length,
|
|
messages,
|
|
};
|
|
|
|
await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8");
|
|
console.log(
|
|
`[Persistence] Saved conversation ${conversationId} (${messages.length} messages)`
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
`[Persistence] Failed to save conversation ${conversationId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load conversation from disk
|
|
* Returns null if conversation doesn't exist or fails to parse
|
|
*/
|
|
export async function loadConversation(
|
|
conversationId: string
|
|
): Promise<ModelMessage[] | null> {
|
|
try {
|
|
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
|
|
|
|
// Check if file exists
|
|
try {
|
|
await stat(filepath);
|
|
} catch {
|
|
console.log(`[Persistence] Conversation ${conversationId} not found`);
|
|
return null;
|
|
}
|
|
|
|
// Read and parse file
|
|
const fileContent = await readFile(filepath, "utf-8");
|
|
const data: ConversationData = JSON.parse(fileContent);
|
|
|
|
// Validate and standardize messages using AI SDK utility
|
|
const result = await standardizePrompt({
|
|
prompt: data.messages,
|
|
});
|
|
|
|
console.log(
|
|
`[Persistence] Loaded conversation ${conversationId} (${data.messageCount} messages)`
|
|
);
|
|
|
|
return result.messages as ModelMessage[];
|
|
} catch (error) {
|
|
console.error(
|
|
`[Persistence] Failed to load conversation ${conversationId}:`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all conversations with metadata
|
|
*/
|
|
export async function listConversations(): Promise<ConversationMetadata[]> {
|
|
try {
|
|
await ensureConversationsDir();
|
|
|
|
const files = await readdir(CONVERSATIONS_DIR);
|
|
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
|
|
const conversations: ConversationMetadata[] = [];
|
|
|
|
for (const file of jsonFiles) {
|
|
try {
|
|
const filepath = join(CONVERSATIONS_DIR, file);
|
|
const fileContent = await readFile(filepath, "utf-8");
|
|
const data: ConversationData = JSON.parse(fileContent);
|
|
|
|
conversations.push({
|
|
id: data.conversationId,
|
|
lastUpdated: new Date(data.lastUpdated),
|
|
messageCount: data.messageCount,
|
|
});
|
|
} catch (error) {
|
|
console.error(`[Persistence] Failed to read conversation ${file}:`, error);
|
|
// Skip invalid files
|
|
}
|
|
}
|
|
|
|
// Sort by lastUpdated descending
|
|
conversations.sort((a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime());
|
|
|
|
return conversations;
|
|
} catch (error) {
|
|
console.error("[Persistence] Failed to list conversations:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete conversation from disk
|
|
*/
|
|
export async function deleteConversation(conversationId: string): Promise<void> {
|
|
try {
|
|
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
|
|
await unlink(filepath);
|
|
console.log(`[Persistence] Deleted conversation ${conversationId}`);
|
|
} catch (error) {
|
|
console.error(
|
|
`[Persistence] Failed to delete conversation ${conversationId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|