461 lines
12 KiB
TypeScript
461 lines
12 KiB
TypeScript
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<boolean> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<SessionMeta>,
|
|
groupSlug?: string | null
|
|
): Promise<SessionMeta> {
|
|
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<void> {
|
|
const sessionDir = getSessionDir(cwd, sessionId, groupSlug);
|
|
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
}
|
|
|
|
export async function listUserSessions(
|
|
baseCwd: string
|
|
): Promise<SessionMeta[]> {
|
|
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<SessionMeta & { preview?: string; relevance?: number }>
|
|
> {
|
|
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<void> {
|
|
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<GroupMeta> {
|
|
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<boolean> {
|
|
try {
|
|
const groupMetaPath = getGroupMetaPath(cwd, groupSlug);
|
|
await fs.access(groupMetaPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function listGroups(
|
|
baseCwd: string
|
|
): Promise<GroupMeta[]> {
|
|
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<GroupMeta>
|
|
): Promise<GroupMeta> {
|
|
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<void> {
|
|
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<SessionMeta> {
|
|
// 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;
|
|
}
|