import * as fs from "fs/promises"; import * as path from "path"; import type { SessionMeta, Message, GroupMeta } from "./types"; const PI_SESSIONS_DIR = ".pi-sessions"; const GROUP_PREFIX = "@"; const GROUP_META_FILE = ".group-meta.json"; function getSessionDir(cwd: string, sessionId: string, groupSlug?: string | null): string { if (groupSlug) { return path.join(cwd, PI_SESSIONS_DIR, `${GROUP_PREFIX}${groupSlug}`, sessionId); } return path.join(cwd, PI_SESSIONS_DIR, sessionId); } function getMetaPath(cwd: string, sessionId: string, groupSlug?: string | null): string { return path.join(getSessionDir(cwd, sessionId, groupSlug), "meta.json"); } function getMessagesPath(cwd: string, sessionId: string, groupSlug?: string | null): string { return path.join(getSessionDir(cwd, sessionId, groupSlug), "messages.json"); } function getGroupDir(cwd: string, groupSlug: string): string { return path.join(cwd, PI_SESSIONS_DIR, `${GROUP_PREFIX}${groupSlug}`); } function getGroupMetaPath(cwd: string, groupSlug: string): string { return path.join(getGroupDir(cwd, groupSlug), GROUP_META_FILE); } async function isGroupDirectory(dirPath: string): Promise { try { const groupMetaPath = path.join(dirPath, GROUP_META_FILE); await fs.access(groupMetaPath); return true; } catch { return false; } } export async function saveSession( cwd: string, sessionId: string, meta: SessionMeta, messages: Message[] ): Promise { const sessionDir = getSessionDir(cwd, sessionId, meta.groupSlug); await fs.mkdir(sessionDir, { recursive: true }); const metaPath = getMetaPath(cwd, sessionId, meta.groupSlug); const messagesPath = getMessagesPath(cwd, sessionId, meta.groupSlug); await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); await fs.writeFile( messagesPath, JSON.stringify({ messages }, null, 2) ); } export async function loadSession( cwd: string, sessionId: string, groupSlug?: string | null ): Promise<{ meta: SessionMeta; messages: Message[] }> { const metaPath = getMetaPath(cwd, sessionId, groupSlug); const messagesPath = getMessagesPath(cwd, sessionId, groupSlug); const metaContent = await fs.readFile(metaPath, "utf-8"); const messagesContent = await fs.readFile(messagesPath, "utf-8"); const meta: SessionMeta = JSON.parse(metaContent); const { messages } = JSON.parse(messagesContent) as { messages: Message[]; }; return { meta, messages }; } export async function sessionExists( cwd: string, sessionId: string, groupSlug?: string | null ): Promise { try { const sessionDir = getSessionDir(cwd, sessionId, groupSlug); await fs.access(sessionDir); return true; } catch { return false; } } export async function updateSessionMeta( cwd: string, sessionId: string, updates: Partial, groupSlug?: string | null ): Promise { const { meta, messages } = await loadSession(cwd, sessionId, groupSlug); const updatedMeta: SessionMeta = { ...meta, ...updates, updatedAt: Date.now(), }; await saveSession(cwd, sessionId, updatedMeta, messages); return updatedMeta; } export async function deleteSession( cwd: string, sessionId: string, groupSlug?: string | null ): Promise { const sessionDir = getSessionDir(cwd, sessionId, groupSlug); await fs.rm(sessionDir, { recursive: true, force: true }); } export async function listUserSessions( baseCwd: string ): Promise { const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR); try { await fs.access(sessionsDir); } catch { return []; } const entries = await fs.readdir(sessionsDir); const sessions: SessionMeta[] = []; for (const entry of entries) { const entryPath = path.join(sessionsDir, entry); const stats = await fs.stat(entryPath); if (!stats.isDirectory()) continue; // Check if it's a group (starts with @) if (entry.startsWith(GROUP_PREFIX)) { const groupSlug = entry.slice(GROUP_PREFIX.length); const groupSessions = await fs.readdir(entryPath); for (const sessionId of groupSessions) { if (sessionId === GROUP_META_FILE) continue; try { const metaPath = getMetaPath(baseCwd, sessionId, groupSlug); const metaContent = await fs.readFile(metaPath, "utf-8"); const meta: SessionMeta = JSON.parse(metaContent); sessions.push(meta); } catch { continue; } } } else { // Regular ungrouped session try { const metaPath = getMetaPath(baseCwd, entry); const metaContent = await fs.readFile(metaPath, "utf-8"); const meta: SessionMeta = JSON.parse(metaContent); sessions.push(meta); } catch { continue; } } } sessions.sort((a, b) => b.updatedAt - a.updatedAt); return sessions; } export async function searchSessions( baseCwd: string, query: string ): Promise< Array > { const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR); try { await fs.access(sessionsDir); } catch { return []; } const entries = await fs.readdir(sessionsDir); const results: Array< SessionMeta & { preview?: string; relevance?: number } > = []; const lowerQuery = query.toLowerCase(); for (const entry of entries) { const entryPath = path.join(sessionsDir, entry); const stats = await fs.stat(entryPath); if (!stats.isDirectory()) continue; // Check if it's a group if (entry.startsWith(GROUP_PREFIX)) { const groupSlug = entry.slice(GROUP_PREFIX.length); // Search in group metadata try { const groupMetaPath = getGroupMetaPath(baseCwd, groupSlug); const groupMetaContent = await fs.readFile(groupMetaPath, "utf-8"); const groupMeta: GroupMeta = JSON.parse(groupMetaContent); let groupRelevance = 0; if (groupMeta.name.toLowerCase().includes(lowerQuery)) { groupRelevance += 2.0; } if (groupMeta.description?.toLowerCase().includes(lowerQuery)) { groupRelevance += 1.5; } // Search sessions in group const groupSessions = await fs.readdir(entryPath); for (const sessionId of groupSessions) { if (sessionId === GROUP_META_FILE) continue; try { const { meta, messages } = await loadSession(baseCwd, sessionId, groupSlug); let relevance = groupRelevance; let preview = ""; if (meta.title.toLowerCase().includes(lowerQuery)) { relevance += 1.0; preview = meta.title; } for (const msg of messages) { if (msg.text && msg.text.toLowerCase().includes(lowerQuery)) { relevance += 0.5; if (!preview) { const index = msg.text.toLowerCase().indexOf(lowerQuery); const start = Math.max(0, index - 50); const end = Math.min(msg.text.length, index + query.length + 50); preview = "..." + msg.text.slice(start, end) + "..."; } } } if (relevance > 0) { results.push({ ...meta, preview, relevance }); } } catch { continue; } } } catch { continue; } } else { // Regular ungrouped session try { const { meta, messages } = await loadSession(baseCwd, entry); let relevance = 0; let preview = ""; if (meta.title.toLowerCase().includes(lowerQuery)) { relevance += 1.0; preview = meta.title; } for (const msg of messages) { if (msg.text && msg.text.toLowerCase().includes(lowerQuery)) { relevance += 0.5; if (!preview) { const index = msg.text.toLowerCase().indexOf(lowerQuery); const start = Math.max(0, index - 50); const end = Math.min(msg.text.length, index + query.length + 50); preview = "..." + msg.text.slice(start, end) + "..."; } } } if (relevance > 0) { results.push({ ...meta, preview, relevance }); } } catch { continue; } } } results.sort((a, b) => (b.relevance || 0) - (a.relevance || 0)); return results; } /** * Group Management Functions */ export async function saveGroup( cwd: string, groupMeta: GroupMeta ): Promise { const groupDir = getGroupDir(cwd, groupMeta.slug); await fs.mkdir(groupDir, { recursive: true }); const groupMetaPath = getGroupMetaPath(cwd, groupMeta.slug); await fs.writeFile(groupMetaPath, JSON.stringify(groupMeta, null, 2)); } export async function loadGroup( cwd: string, groupSlug: string ): Promise { const groupMetaPath = getGroupMetaPath(cwd, groupSlug); const groupMetaContent = await fs.readFile(groupMetaPath, "utf-8"); return JSON.parse(groupMetaContent) as GroupMeta; } export async function groupExists( cwd: string, groupSlug: string ): Promise { try { const groupMetaPath = getGroupMetaPath(cwd, groupSlug); await fs.access(groupMetaPath); return true; } catch { return false; } } export async function listGroups( baseCwd: string ): Promise { const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR); try { await fs.access(sessionsDir); } catch { return []; } const entries = await fs.readdir(sessionsDir); const groups: GroupMeta[] = []; for (const entry of entries) { if (!entry.startsWith(GROUP_PREFIX)) continue; const groupSlug = entry.slice(GROUP_PREFIX.length); try { const groupMeta = await loadGroup(baseCwd, groupSlug); groups.push(groupMeta); } catch { continue; } } groups.sort((a, b) => b.updatedAt - a.updatedAt); return groups; } export async function updateGroupMeta( cwd: string, groupSlug: string, updates: Partial ): Promise { const groupMeta = await loadGroup(cwd, groupSlug); const updatedGroupMeta: GroupMeta = { ...groupMeta, ...updates, slug: groupMeta.slug, // Prevent slug changes updatedAt: Date.now(), }; await saveGroup(cwd, updatedGroupMeta); return updatedGroupMeta; } export async function deleteGroup( cwd: string, groupSlug: string ): Promise { const groupDir = getGroupDir(cwd, groupSlug); // Move all sessions in the group to root level try { const entries = await fs.readdir(groupDir); const sessionsDir = path.join(cwd, PI_SESSIONS_DIR); for (const entry of entries) { if (entry === GROUP_META_FILE) continue; const sessionDir = path.join(groupDir, entry); const destDir = path.join(sessionsDir, entry); // Update session meta to remove groupSlug try { const metaPath = path.join(sessionDir, "meta.json"); const metaContent = await fs.readFile(metaPath, "utf-8"); const meta: SessionMeta = JSON.parse(metaContent); meta.groupSlug = null; await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); } catch { // Continue even if meta update fails } // Move session directory await fs.rename(sessionDir, destDir); } } catch (err) { // Continue to delete group even if moving fails } // Delete the group directory await fs.rm(groupDir, { recursive: true, force: true }); } export async function moveSession( cwd: string, sessionId: string, fromGroupSlug: string | null, toGroupSlug: string | null ): Promise { // Load the session const { meta, messages } = await loadSession(cwd, sessionId, fromGroupSlug); // Update groupSlug meta.groupSlug = toGroupSlug; meta.updatedAt = Date.now(); // Save to new location await saveSession(cwd, sessionId, meta, messages); // Delete from old location await deleteSession(cwd, sessionId, fromGroupSlug); // Update session count in groups if (fromGroupSlug) { try { const fromGroup = await loadGroup(cwd, fromGroupSlug); fromGroup.sessionCount = Math.max(0, fromGroup.sessionCount - 1); fromGroup.updatedAt = Date.now(); await saveGroup(cwd, fromGroup); } catch { // Group might not exist } } if (toGroupSlug) { try { const toGroup = await loadGroup(cwd, toGroupSlug); toGroup.sessionCount += 1; toGroup.updatedAt = Date.now(); await saveGroup(cwd, toGroup); } catch { // Group might not exist } } return meta; }