main
  1import * as fs from "fs/promises";
  2import * as path from "path";
  3import { SessionEntry, ConversationMessage } from "./types.js";
  4import { bareJid } from "../xmpp/types.js";
  5
  6export class SessionManager {
  7  private dataDir: string;
  8
  9  constructor(dataDir: string) {
 10    this.dataDir = dataDir;
 11  }
 12
 13  private getSessionDir(jid: string): string {
 14    // Sanitize JID for filesystem (replace @ and . with safe chars)
 15    const sanitized = bareJid(jid).replace(/@/g, "_at_").replace(/\./g, "_");
 16    return path.join(this.dataDir, sanitized);
 17  }
 18
 19  private getContextPath(jid: string): string {
 20    return path.join(this.getSessionDir(jid), "context.jsonl");
 21  }
 22
 23  async ensureSessionDir(jid: string): Promise<void> {
 24    const dir = this.getSessionDir(jid);
 25    await fs.mkdir(dir, { recursive: true });
 26  }
 27
 28  async appendEntry(jid: string, entry: SessionEntry): Promise<void> {
 29    await this.ensureSessionDir(jid);
 30    const contextPath = this.getContextPath(jid);
 31    const line = JSON.stringify(entry) + "\n";
 32    await fs.appendFile(contextPath, line, "utf-8");
 33  }
 34
 35  async loadHistory(jid: string): Promise<SessionEntry[]> {
 36    const contextPath = this.getContextPath(jid);
 37
 38    try {
 39      const content = await fs.readFile(contextPath, "utf-8");
 40      const lines = content.trim().split("\n").filter((l) => l.length > 0);
 41      return lines.map((line) => JSON.parse(line) as SessionEntry);
 42    } catch (err) {
 43      if ((err as NodeJS.ErrnoException).code === "ENOENT") {
 44        return [];
 45      }
 46      throw err;
 47    }
 48  }
 49
 50  async getConversationHistory(
 51    jid: string,
 52    maxEntries: number = 50
 53  ): Promise<ConversationMessage[]> {
 54    const entries = await this.loadHistory(jid);
 55    const messages: ConversationMessage[] = [];
 56
 57    // Take last N entries and convert to conversation format
 58    const recentEntries = entries.slice(-maxEntries);
 59
 60    for (const entry of recentEntries) {
 61      if (entry.type === "user") {
 62        messages.push({ role: "user", content: entry.content });
 63      } else if (entry.type === "assistant") {
 64        messages.push({ role: "assistant", content: entry.content });
 65      } else if (entry.type === "system") {
 66        messages.push({ role: "system", content: entry.content });
 67      }
 68      // Tool calls and results are embedded in assistant/user messages
 69    }
 70
 71    return messages;
 72  }
 73
 74  async clearSession(jid: string): Promise<void> {
 75    const contextPath = this.getContextPath(jid);
 76    try {
 77      await fs.unlink(contextPath);
 78    } catch (err) {
 79      if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
 80        throw err;
 81      }
 82    }
 83  }
 84
 85  async getSessionStats(jid: string): Promise<{
 86    messageCount: number;
 87    firstMessage: string | null;
 88    lastMessage: string | null;
 89  }> {
 90    const entries = await this.loadHistory(jid);
 91    const userEntries = entries.filter((e) => e.type === "user");
 92
 93    return {
 94      messageCount: userEntries.length,
 95      firstMessage: entries.length > 0 ? entries[0].timestamp : null,
 96      lastMessage:
 97        entries.length > 0 ? entries[entries.length - 1].timestamp : null,
 98    };
 99  }
100}