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}