wispy-cli 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * core/session.mjs — Session management for Wispy
3
+ *
4
+ * Class SessionManager:
5
+ * - create({ workstream?, channel?, chatId? }) → Session
6
+ * - get(id) → Session
7
+ * - list(filter?) → Session[]
8
+ * - addMessage(id, message) → void
9
+ * - clear(id) → void
10
+ * - save(id) → void
11
+ * - load(id) → Session
12
+ * - getOrCreate(key) → Session (for channel adapters: key = "telegram:chatId")
13
+ *
14
+ * File-based persistence: ~/.wispy/sessions/{id}.json
15
+ */
16
+
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
20
+ import { SESSIONS_DIR } from "./config.mjs";
21
+
22
+ export class Session {
23
+ constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null }) {
24
+ this.id = id;
25
+ this.workstream = workstream;
26
+ this.channel = channel;
27
+ this.chatId = chatId;
28
+ this.messages = messages;
29
+ this.createdAt = createdAt ?? new Date().toISOString();
30
+ this.updatedAt = new Date().toISOString();
31
+ }
32
+
33
+ toJSON() {
34
+ return {
35
+ id: this.id,
36
+ workstream: this.workstream,
37
+ channel: this.channel,
38
+ chatId: this.chatId,
39
+ messages: this.messages,
40
+ createdAt: this.createdAt,
41
+ updatedAt: this.updatedAt,
42
+ };
43
+ }
44
+ }
45
+
46
+ export class SessionManager {
47
+ constructor() {
48
+ this._sessions = new Map(); // id → Session (in-memory cache)
49
+ this._keyMap = new Map(); // composite key → id (for getOrCreate)
50
+ }
51
+
52
+ /**
53
+ * Create a new session
54
+ */
55
+ create({ workstream = "default", channel = null, chatId = null } = {}) {
56
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
57
+ const session = new Session({ id, workstream, channel, chatId });
58
+ this._sessions.set(id, session);
59
+ if (channel && chatId) {
60
+ this._keyMap.set(`${channel}:${chatId}`, id);
61
+ }
62
+ return session;
63
+ }
64
+
65
+ /**
66
+ * Get a session by ID. Returns null if not found in memory.
67
+ */
68
+ get(id) {
69
+ return this._sessions.get(id) ?? null;
70
+ }
71
+
72
+ /**
73
+ * Get or load a session by ID.
74
+ */
75
+ async getOrLoad(id) {
76
+ if (this._sessions.has(id)) return this._sessions.get(id);
77
+ return this.load(id);
78
+ }
79
+
80
+ /**
81
+ * List sessions matching an optional filter.
82
+ */
83
+ list(filter = null) {
84
+ const all = Array.from(this._sessions.values());
85
+ if (!filter) return all;
86
+ return all.filter(s => {
87
+ if (filter.workstream && s.workstream !== filter.workstream) return false;
88
+ if (filter.channel && s.channel !== filter.channel) return false;
89
+ return true;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Add a message to a session.
95
+ */
96
+ addMessage(id, message) {
97
+ const session = this._sessions.get(id);
98
+ if (!session) throw new Error(`Session not found: ${id}`);
99
+ session.messages.push(message);
100
+ session.updatedAt = new Date().toISOString();
101
+ // Keep last 50 messages
102
+ if (session.messages.length > 50) {
103
+ session.messages = session.messages.slice(-50);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Clear message history for a session.
109
+ */
110
+ clear(id) {
111
+ const session = this._sessions.get(id);
112
+ if (!session) throw new Error(`Session not found: ${id}`);
113
+ session.messages = [];
114
+ session.updatedAt = new Date().toISOString();
115
+ }
116
+
117
+ /**
118
+ * Save a session to disk.
119
+ */
120
+ async save(id) {
121
+ const session = this._sessions.get(id);
122
+ if (!session) throw new Error(`Session not found: ${id}`);
123
+ await mkdir(SESSIONS_DIR, { recursive: true });
124
+ const filePath = path.join(SESSIONS_DIR, `${id}.json`);
125
+ await writeFile(filePath, JSON.stringify(session.toJSON(), null, 2) + "\n", "utf8");
126
+ }
127
+
128
+ /**
129
+ * Load a session from disk.
130
+ */
131
+ async load(id) {
132
+ const filePath = path.join(SESSIONS_DIR, `${id}.json`);
133
+ try {
134
+ const raw = await readFile(filePath, "utf8");
135
+ const data = JSON.parse(raw);
136
+ const session = new Session(data);
137
+ this._sessions.set(id, session);
138
+ if (session.channel && session.chatId) {
139
+ this._keyMap.set(`${session.channel}:${session.chatId}`, id);
140
+ }
141
+ return session;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get or create a session by a composite key (e.g., "telegram:chatId").
149
+ * Used by channel adapters to maintain per-chat sessions.
150
+ */
151
+ async getOrCreate(key, opts = {}) {
152
+ // Check in-memory key map
153
+ if (this._keyMap.has(key)) {
154
+ const id = this._keyMap.get(key);
155
+ if (this._sessions.has(id)) return this._sessions.get(id);
156
+ }
157
+
158
+ // Try to find on disk by scanning sessions dir
159
+ try {
160
+ const files = await readdir(SESSIONS_DIR);
161
+ for (const file of files) {
162
+ if (!file.endsWith(".json")) continue;
163
+ const id = file.replace(".json", "");
164
+ const existing = await this.load(id);
165
+ if (existing) {
166
+ const existingKey = existing.channel && existing.chatId
167
+ ? `${existing.channel}:${existing.chatId}`
168
+ : null;
169
+ if (existingKey === key) return existing;
170
+ }
171
+ }
172
+ } catch { /* sessions dir might not exist yet */ }
173
+
174
+ // Parse channel:chatId from key
175
+ const colonIdx = key.indexOf(":");
176
+ const channel = colonIdx !== -1 ? key.slice(0, colonIdx) : null;
177
+ const chatId = colonIdx !== -1 ? key.slice(colonIdx + 1) : key;
178
+
179
+ const session = this.create({ channel, chatId, ...opts });
180
+ this._keyMap.set(key, session.id);
181
+ return session;
182
+ }
183
+
184
+ /**
185
+ * Get all messages for a session, including a system prompt prefix.
186
+ * Ensures the system message is always first.
187
+ */
188
+ getMessages(id) {
189
+ const session = this._sessions.get(id);
190
+ if (!session) return [];
191
+ return session.messages;
192
+ }
193
+ }
194
+
195
+ // Default global instance
196
+ export const sessionManager = new SessionManager();