@ebowwa/glm-daemon 0.3.1 → 0.3.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/memory.ts +173 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ebowwa/glm-daemon",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Autonomous GLM 4.7 daemon with hooks, tools, teammates, and communication channels (Telegram, Discord)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/memory.ts CHANGED
@@ -3,9 +3,18 @@
3
3
  *
4
4
  * Tracks chat history per user/conversation with persistence.
5
5
  * Can be used by Telegram, Discord, or any other channel.
6
+ *
7
+ * Storage format: JSONL (JSON Lines)
8
+ * Each line is a JSON object: {"id": "...", "messages": [...], "lastUpdated": timestamp}
6
9
  */
7
10
 
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
11
+ import {
12
+ readFileSync,
13
+ writeFileSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ renameSync,
17
+ } from "fs";
9
18
  import { dirname } from "path";
10
19
 
11
20
  /**
@@ -17,21 +26,35 @@ export interface ConversationMessage {
17
26
  timestamp?: number;
18
27
  }
19
28
 
29
+ /**
30
+ * Internal storage format for JSONL
31
+ */
32
+ interface ConversationEntry {
33
+ id: string;
34
+ messages: ConversationMessage[];
35
+ lastUpdated: number;
36
+ }
37
+
20
38
  /**
21
39
  * Configuration for ConversationMemory
22
40
  */
23
41
  export interface ConversationMemoryConfig {
24
- /** Path to persistence file (default: ./conversations.json) */
42
+ /** Path to persistence file (default: ./conversations.jsonl) */
25
43
  file?: string;
26
44
  /** Maximum messages to keep per conversation (default: 10) */
27
45
  maxMessages?: number;
28
46
  /** Include timestamps in messages (default: true) */
29
47
  includeTimestamps?: boolean;
48
+ /** Auto-migrate old .json files to .jsonl (default: true) */
49
+ autoMigrate?: boolean;
30
50
  }
31
51
 
32
52
  /**
33
53
  * Generic conversation memory that tracks chat history per conversation ID.
34
54
  *
55
+ * Uses JSONL format for efficient append-only writes and streaming reads.
56
+ * Automatically migrates from old JSON format on first load.
57
+ *
35
58
  * The ID type is generic (string | number) to support different channel types:
36
59
  * - Telegram: number (chatId)
37
60
  * - Discord: string (channelId/userId)
@@ -50,28 +73,85 @@ export class ConversationMemory<IdType = string | number> {
50
73
  private maxMessages: number;
51
74
  private includeTimestamps: boolean;
52
75
  private file: string;
76
+ private needsFullSave = false;
53
77
 
54
78
  constructor(config: ConversationMemoryConfig = {}) {
55
- this.file = config.file || "./conversations.json";
79
+ // Default to .jsonl extension
80
+ const defaultFile = "./conversations.jsonl";
81
+ this.file = config.file || defaultFile;
82
+
83
+ // Auto-migrate from .json to .jsonl if needed
84
+ if (config.autoMigrate !== false && !existsSync(this.file)) {
85
+ this.migrateFromJson(this.file);
86
+ }
87
+
56
88
  this.maxMessages = config.maxMessages ?? 10;
57
89
  this.includeTimestamps = config.includeTimestamps ?? true;
58
90
  this.load();
59
91
  }
60
92
 
61
93
  /**
62
- * Load conversations from disk
94
+ * Migrate from old JSON format to JSONL
95
+ */
96
+ private migrateFromJson(jsonlFile: string): void {
97
+ // Check for old .json file
98
+ const jsonFile = jsonlFile.replace(/\.jsonl$/, ".json");
99
+ if (!existsSync(jsonFile)) return;
100
+
101
+ try {
102
+ const data = JSON.parse(readFileSync(jsonFile, "utf-8"));
103
+ const entries: ConversationEntry[] = [];
104
+
105
+ for (const [id, messages] of Object.entries(data)) {
106
+ entries.push({
107
+ id,
108
+ messages: messages as ConversationMessage[],
109
+ lastUpdated: Date.now(),
110
+ });
111
+ }
112
+
113
+ // Write as JSONL
114
+ const dir = dirname(jsonlFile);
115
+ if (!existsSync(dir)) {
116
+ mkdirSync(dir, { recursive: true });
117
+ }
118
+
119
+ const lines = entries.map((e) => JSON.stringify(e)).join("\n");
120
+ writeFileSync(jsonlFile, lines + (lines ? "\n" : ""));
121
+
122
+ // Backup old file instead of deleting
123
+ const backupFile = jsonFile + ".backup";
124
+ renameSync(jsonFile, backupFile);
125
+
126
+ console.log(`Migrated ${jsonFile} to ${jsonlFile} (${entries.length} conversations)`);
127
+ } catch (error) {
128
+ console.error(`Migration failed: ${error}`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Load conversations from JSONL file
134
+ * Uses synchronous read for constructor compatibility
63
135
  */
64
136
  private load(): void {
65
137
  if (!existsSync(this.file)) return;
138
+
66
139
  try {
67
- const data = JSON.parse(readFileSync(this.file, "utf-8"));
68
- for (const [id, messages] of Object.entries(data)) {
69
- // Convert string keys back to original type
70
- const key = this.parseKey(id);
71
- this.conversations.set(key, messages as ConversationMessage[]);
140
+ const content = readFileSync(this.file, "utf-8");
141
+ const lines = content.split("\n");
142
+
143
+ for (const line of lines) {
144
+ if (!line.trim()) continue;
145
+ try {
146
+ const entry: ConversationEntry = JSON.parse(line);
147
+ const key = this.parseKey(entry.id);
148
+ this.conversations.set(key, entry.messages);
149
+ } catch {
150
+ // Skip malformed lines
151
+ }
72
152
  }
73
153
  } catch {
74
- // Ignore parse errors - start fresh
154
+ // Start fresh on error
75
155
  }
76
156
  }
77
157
 
@@ -87,20 +167,69 @@ export class ConversationMemory<IdType = string | number> {
87
167
  }
88
168
 
89
169
  /**
90
- * Save conversations to disk
170
+ * Convert IdType to string for storage
91
171
  */
92
- private save(): void {
172
+ private stringifyKey(id: IdType): string {
173
+ return String(id);
174
+ }
175
+
176
+ /**
177
+ * Save all conversations to disk (full rewrite)
178
+ * Used when entries are deleted or modified
179
+ */
180
+ private saveFull(): void {
93
181
  try {
94
- // Ensure directory exists
95
182
  const dir = dirname(this.file);
96
183
  if (!existsSync(dir)) {
97
184
  mkdirSync(dir, { recursive: true });
98
185
  }
99
186
 
100
- const data = Object.fromEntries(this.conversations);
101
- writeFileSync(this.file, JSON.stringify(data, null, 2));
102
- } catch {
103
- // Ignore save errors
187
+ const lines: string[] = [];
188
+ for (const [id, messages] of this.conversations) {
189
+ const entry: ConversationEntry = {
190
+ id: this.stringifyKey(id),
191
+ messages,
192
+ lastUpdated: Date.now(),
193
+ };
194
+ lines.push(JSON.stringify(entry));
195
+ }
196
+
197
+ writeFileSync(this.file, lines.join("\n") + (lines.length ? "\n" : ""));
198
+ this.needsFullSave = false;
199
+ } catch (error) {
200
+ console.error(`Save failed: ${error}`);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Append a single entry to the file (efficient for adds)
206
+ */
207
+ private appendEntry(conversationId: IdType, messages: ConversationMessage[]): void {
208
+ try {
209
+ const dir = dirname(this.file);
210
+ if (!existsSync(dir)) {
211
+ mkdirSync(dir, { recursive: true });
212
+ }
213
+
214
+ const entry: ConversationEntry = {
215
+ id: this.stringifyKey(conversationId),
216
+ messages,
217
+ lastUpdated: Date.now(),
218
+ };
219
+
220
+ // Check if file exists and has content to determine if we need newline
221
+ const hasContent = existsSync(this.file) && readFileSync(this.file, "utf-8").length > 0;
222
+ const prefix = hasContent ? "" : "";
223
+ const line = JSON.stringify(entry) + "\n";
224
+
225
+ // We need to do a full save to replace the old entry for this conversation
226
+ // JSONL doesn't support in-place updates, so we mark for full save
227
+ this.needsFullSave = true;
228
+
229
+ // For now, do a full save since we need to replace the entry
230
+ this.saveFull();
231
+ } catch (error) {
232
+ console.error(`Append failed: ${error}`);
104
233
  }
105
234
  }
106
235
 
@@ -130,7 +259,8 @@ export class ConversationMemory<IdType = string | number> {
130
259
  history.splice(0, history.length - this.maxMessages);
131
260
  }
132
261
 
133
- this.save();
262
+ // Save the updated conversation
263
+ this.appendEntry(conversationId, history);
134
264
  }
135
265
 
136
266
  /**
@@ -152,7 +282,7 @@ export class ConversationMemory<IdType = string | number> {
152
282
  */
153
283
  clear(conversationId: IdType): void {
154
284
  this.conversations.delete(conversationId);
155
- this.save();
285
+ this.saveFull();
156
286
  }
157
287
 
158
288
  /**
@@ -160,7 +290,7 @@ export class ConversationMemory<IdType = string | number> {
160
290
  */
161
291
  clearAll(): void {
162
292
  this.conversations.clear();
163
- this.save();
293
+ this.saveFull();
164
294
  }
165
295
 
166
296
  /**
@@ -195,6 +325,29 @@ export class ConversationMemory<IdType = string | number> {
195
325
  content,
196
326
  }));
197
327
  }
328
+
329
+ /**
330
+ * Force a full save (useful before shutdown)
331
+ */
332
+ flush(): void {
333
+ if (this.needsFullSave || this.conversations.size > 0) {
334
+ this.saveFull();
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Get the file path being used
340
+ */
341
+ getFilePath(): string {
342
+ return this.file;
343
+ }
344
+
345
+ /**
346
+ * Check if the storage uses JSONL format
347
+ */
348
+ isJsonl(): boolean {
349
+ return this.file.endsWith(".jsonl");
350
+ }
198
351
  }
199
352
 
200
353
  /**