@rubytech/taskmaster 1.0.61 → 1.0.63

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,381 @@
1
+ /**
2
+ * Recovers orphaned session JSONL files that exist on disk but are not
3
+ * referenced by any session store entry (neither as a current session nor
4
+ * in any `previousSessions` array).
5
+ *
6
+ * This situation arises when daily/idle resets silently replaced session
7
+ * entries without archiving the previous session. The JSONL files were
8
+ * preserved on disk but the store lost track of them.
9
+ *
10
+ * The recovery runs once at gateway startup, is idempotent, and classifies
11
+ * each orphan by reading its first user message to determine which session
12
+ * key it belongs to (cron, WhatsApp DM, or webchat main).
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { resolveStateDir } from "../config/paths.js";
17
+ import { updateSessionStore } from "../config/sessions.js";
18
+ import { createSubsystemLogger } from "../logging/subsystem.js";
19
+ const log = createSubsystemLogger("session-recovery");
20
+ // ---------- Classification helpers ----------
21
+ const CRON_PATTERN = /^\[cron:([0-9a-f-]{36})\b/i;
22
+ const WHATSAPP_PATTERN = /^\[WhatsApp\s+(\+\d+)\b/i;
23
+ /**
24
+ * Read the session header (first JSONL line) to get the timestamp,
25
+ * and the first user message to classify the channel.
26
+ */
27
+ function classifyOrphan(filePath, agentId, knownSessionKeys) {
28
+ const uuid = path.basename(filePath, ".jsonl");
29
+ let headerTimestamp = 0;
30
+ let firstUserMessage = null;
31
+ let fd = null;
32
+ try {
33
+ fd = fs.openSync(filePath, "r");
34
+ // Read enough to cover the header + first few messages
35
+ const buf = Buffer.alloc(16384);
36
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
37
+ if (bytesRead === 0)
38
+ return null;
39
+ const chunk = buf.toString("utf-8", 0, bytesRead);
40
+ const lines = chunk.split(/\r?\n/).slice(0, 20);
41
+ for (const line of lines) {
42
+ if (!line.trim())
43
+ continue;
44
+ try {
45
+ const parsed = JSON.parse(line);
46
+ // Session header line
47
+ if (parsed?.type === "session" && parsed?.timestamp) {
48
+ const ts = typeof parsed.timestamp === "number"
49
+ ? parsed.timestamp
50
+ : new Date(parsed.timestamp).getTime();
51
+ if (Number.isFinite(ts))
52
+ headerTimestamp = ts;
53
+ }
54
+ // First user message
55
+ if (parsed?.type === "message" && !firstUserMessage) {
56
+ const msg = parsed?.message;
57
+ if (msg?.role === "user") {
58
+ const content = msg.content;
59
+ if (typeof content === "string") {
60
+ firstUserMessage = content;
61
+ }
62
+ else if (Array.isArray(content)) {
63
+ for (const block of content) {
64
+ if (block?.type === "text" && typeof block.text === "string") {
65
+ firstUserMessage = block.text;
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ if (headerTimestamp && firstUserMessage)
73
+ break;
74
+ }
75
+ catch {
76
+ // skip malformed lines
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ finally {
84
+ if (fd !== null)
85
+ fs.closeSync(fd);
86
+ }
87
+ // Fall back to file mtime if no header timestamp
88
+ if (!headerTimestamp) {
89
+ try {
90
+ headerTimestamp = fs.statSync(filePath).mtimeMs;
91
+ }
92
+ catch {
93
+ headerTimestamp = 0;
94
+ }
95
+ }
96
+ // Classify by first user message content
97
+ let sessionKey = null;
98
+ if (firstUserMessage) {
99
+ const cronMatch = firstUserMessage.match(CRON_PATTERN);
100
+ if (cronMatch) {
101
+ const cronId = cronMatch[1];
102
+ const candidate = `agent:${agentId}:cron:${cronId}`;
103
+ // Only assign to a cron key that exists in the store
104
+ sessionKey = knownSessionKeys.has(candidate) ? candidate : null;
105
+ }
106
+ if (!sessionKey) {
107
+ const waMatch = firstUserMessage.match(WHATSAPP_PATTERN);
108
+ if (waMatch) {
109
+ const phone = waMatch[1];
110
+ const candidate = `agent:${agentId}:dm:${phone}`;
111
+ sessionKey = knownSessionKeys.has(candidate) ? candidate : null;
112
+ }
113
+ }
114
+ }
115
+ // Default: assign to the agent's main webchat session
116
+ if (!sessionKey) {
117
+ const mainKey = `agent:${agentId}:main`;
118
+ if (knownSessionKeys.has(mainKey)) {
119
+ sessionKey = mainKey;
120
+ }
121
+ }
122
+ return { uuid, filePath, headerTimestamp, sessionKey };
123
+ }
124
+ // ---------- Main recovery ----------
125
+ export async function recoverOrphanedSessions(params) {
126
+ const stateDir = params?.stateDir ?? resolveStateDir();
127
+ const agentsDir = path.join(stateDir, "agents");
128
+ if (!fs.existsSync(agentsDir)) {
129
+ return { recovered: 0, agents: 0 };
130
+ }
131
+ let totalRecovered = 0;
132
+ let agentsProcessed = 0;
133
+ let agentEntries;
134
+ try {
135
+ agentEntries = fs.readdirSync(agentsDir, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return { recovered: 0, agents: 0 };
139
+ }
140
+ for (const agentEntry of agentEntries) {
141
+ if (!agentEntry.isDirectory())
142
+ continue;
143
+ const agentId = agentEntry.name;
144
+ const sessionsDir = path.join(agentsDir, agentId, "sessions");
145
+ const storePath = path.join(sessionsDir, "sessions.json");
146
+ if (!fs.existsSync(storePath))
147
+ continue;
148
+ // Load the store
149
+ let store;
150
+ try {
151
+ const raw = fs.readFileSync(storePath, "utf-8");
152
+ store = JSON.parse(raw);
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ // Collect all referenced session IDs
158
+ const referencedIds = new Set();
159
+ const knownSessionKeys = new Set();
160
+ for (const [key, entry] of Object.entries(store)) {
161
+ knownSessionKeys.add(key);
162
+ if (entry.sessionId)
163
+ referencedIds.add(entry.sessionId);
164
+ if (Array.isArray(entry.previousSessions)) {
165
+ for (const prev of entry.previousSessions) {
166
+ if (prev.sessionId)
167
+ referencedIds.add(prev.sessionId);
168
+ }
169
+ }
170
+ }
171
+ // List JSONL files on disk
172
+ let files;
173
+ try {
174
+ files = fs
175
+ .readdirSync(sessionsDir)
176
+ .filter((f) => f.endsWith(".jsonl"));
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ // Find orphans
182
+ const orphans = [];
183
+ for (const file of files) {
184
+ const uuid = file.replace(".jsonl", "");
185
+ if (referencedIds.has(uuid))
186
+ continue;
187
+ const info = classifyOrphan(path.join(sessionsDir, file), agentId, knownSessionKeys);
188
+ if (info?.sessionKey) {
189
+ orphans.push(info);
190
+ }
191
+ }
192
+ if (orphans.length === 0)
193
+ continue;
194
+ agentsProcessed++;
195
+ // Group orphans by session key
196
+ const byKey = new Map();
197
+ for (const orphan of orphans) {
198
+ if (!orphan.sessionKey)
199
+ continue;
200
+ const group = byKey.get(orphan.sessionKey) ?? [];
201
+ group.push(orphan);
202
+ byKey.set(orphan.sessionKey, group);
203
+ }
204
+ // Update the store: prepend orphans to each entry's previousSessions
205
+ try {
206
+ await updateSessionStore(storePath, (currentStore) => {
207
+ for (const [sessionKey, orphanGroup] of byKey.entries()) {
208
+ const entry = currentStore[sessionKey];
209
+ if (!entry)
210
+ continue;
211
+ // Re-check: skip any that are now referenced (in case of concurrent update)
212
+ const nowReferenced = new Set();
213
+ if (entry.sessionId)
214
+ nowReferenced.add(entry.sessionId);
215
+ if (Array.isArray(entry.previousSessions)) {
216
+ for (const prev of entry.previousSessions) {
217
+ if (prev.sessionId)
218
+ nowReferenced.add(prev.sessionId);
219
+ }
220
+ }
221
+ const toAdd = orphanGroup
222
+ .filter((o) => !nowReferenced.has(o.uuid))
223
+ .sort((a, b) => a.headerTimestamp - b.headerTimestamp);
224
+ if (toAdd.length === 0)
225
+ continue;
226
+ const existingPrev = entry.previousSessions ?? [];
227
+ const newPrev = [
228
+ ...toAdd.map((o) => ({
229
+ sessionId: o.uuid,
230
+ sessionFile: o.filePath,
231
+ endedAt: o.headerTimestamp,
232
+ })),
233
+ ...existingPrev,
234
+ ];
235
+ entry.previousSessions = newPrev;
236
+ totalRecovered += toAdd.length;
237
+ }
238
+ });
239
+ }
240
+ catch (err) {
241
+ log.warn(`failed to update store for agent ${agentId}: ${String(err)}`);
242
+ }
243
+ }
244
+ if (totalRecovered > 0) {
245
+ log.info(`recovered ${totalRecovered} orphaned session(s) across ${agentsProcessed} agent(s)`);
246
+ }
247
+ return { recovered: totalRecovered, agents: agentsProcessed };
248
+ }
249
+ // ---------- Base64 image stripping from JSONL transcripts ----------
250
+ /**
251
+ * Returns true if a content block contains inline base64 image data
252
+ * that should have been stored as a physical file instead.
253
+ */
254
+ function hasInlineBase64(block) {
255
+ if (block.type === "image") {
256
+ if (typeof block.data === "string" && block.data.length > 256)
257
+ return true;
258
+ const source = block.source;
259
+ if (source?.type === "base64" && typeof source.data === "string" && source.data.length > 256) {
260
+ return true;
261
+ }
262
+ }
263
+ if (block.type === "image_url") {
264
+ const imageUrl = block.image_url;
265
+ if (typeof imageUrl?.url === "string" && imageUrl.url.startsWith("data:"))
266
+ return true;
267
+ }
268
+ return false;
269
+ }
270
+ /**
271
+ * Replace base64 image blocks in a content array with text placeholders.
272
+ * Returns null if no changes were made.
273
+ */
274
+ function stripBase64FromContent(content) {
275
+ let changed = false;
276
+ const result = content.map((block) => {
277
+ if (!block || typeof block !== "object")
278
+ return block;
279
+ const b = block;
280
+ if (!hasInlineBase64(b))
281
+ return block;
282
+ changed = true;
283
+ const mime = b.mimeType ??
284
+ b.media_type ??
285
+ b.source?.media_type ??
286
+ "image";
287
+ return { type: "text", text: `[${mime}]` };
288
+ });
289
+ return changed ? result : null;
290
+ }
291
+ /**
292
+ * Scans all JSONL transcript files across all agents and removes inline
293
+ * base64 image data, replacing with text placeholders.
294
+ *
295
+ * Idempotent — files with no base64 are left untouched.
296
+ */
297
+ export async function stripBase64FromTranscripts(params) {
298
+ const stateDir = params?.stateDir ?? resolveStateDir();
299
+ const agentsDir = path.join(stateDir, "agents");
300
+ if (!fs.existsSync(agentsDir)) {
301
+ return { cleaned: 0, bytesReclaimed: 0 };
302
+ }
303
+ let totalCleaned = 0;
304
+ let totalBytesReclaimed = 0;
305
+ let agentEntries;
306
+ try {
307
+ agentEntries = fs.readdirSync(agentsDir, { withFileTypes: true });
308
+ }
309
+ catch {
310
+ return { cleaned: 0, bytesReclaimed: 0 };
311
+ }
312
+ for (const agentEntry of agentEntries) {
313
+ if (!agentEntry.isDirectory())
314
+ continue;
315
+ const sessionsDir = path.join(agentsDir, agentEntry.name, "sessions");
316
+ let files;
317
+ try {
318
+ files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
319
+ }
320
+ catch {
321
+ continue;
322
+ }
323
+ for (const file of files) {
324
+ const filePath = path.join(sessionsDir, file);
325
+ let raw;
326
+ try {
327
+ raw = fs.readFileSync(filePath, "utf-8");
328
+ }
329
+ catch {
330
+ continue;
331
+ }
332
+ const lines = raw.split(/\r?\n/);
333
+ let fileChanged = false;
334
+ const rewritten = [];
335
+ for (const line of lines) {
336
+ if (!line.trim()) {
337
+ rewritten.push(line);
338
+ continue;
339
+ }
340
+ try {
341
+ const parsed = JSON.parse(line);
342
+ const msg = parsed?.message;
343
+ if (msg && Array.isArray(msg.content)) {
344
+ const stripped = stripBase64FromContent(msg.content);
345
+ if (stripped) {
346
+ parsed.message = { ...msg, content: stripped };
347
+ rewritten.push(JSON.stringify(parsed));
348
+ fileChanged = true;
349
+ continue;
350
+ }
351
+ }
352
+ }
353
+ catch {
354
+ // keep line as-is
355
+ }
356
+ rewritten.push(line);
357
+ }
358
+ if (!fileChanged)
359
+ continue;
360
+ const newContent = rewritten.join("\n");
361
+ const oldSize = Buffer.byteLength(raw, "utf-8");
362
+ const newSize = Buffer.byteLength(newContent, "utf-8");
363
+ try {
364
+ // Atomic write: write to tmp, then rename
365
+ const tmpPath = `${filePath}.tmp`;
366
+ fs.writeFileSync(tmpPath, newContent, "utf-8");
367
+ fs.renameSync(tmpPath, filePath);
368
+ totalCleaned++;
369
+ totalBytesReclaimed += oldSize - newSize;
370
+ }
371
+ catch (err) {
372
+ log.warn(`failed to clean ${file}: ${String(err)}`);
373
+ }
374
+ }
375
+ }
376
+ if (totalCleaned > 0) {
377
+ const mb = (totalBytesReclaimed / (1024 * 1024)).toFixed(1);
378
+ log.info(`stripped base64 images from ${totalCleaned} transcript(s), reclaimed ${mb} MB`);
379
+ }
380
+ return { cleaned: totalCleaned, bytesReclaimed: totalBytesReclaimed };
381
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -242,6 +242,10 @@ You can send files to your assistant by dragging and dropping them onto the chat
242
242
 
243
243
  If your assistant is busy thinking, you can still type and send more messages. They appear immediately in the chat as normal messages and are delivered to your assistant in order once it finishes its current task.
244
244
 
245
+ ### Suggestion Chips
246
+
247
+ After each response, a suggested follow-up appears as a clickable chip below the conversation. Tap it to send the suggestion, or type your own message — the chip disappears as soon as you start a new message. When you open a fresh chat session, a starter suggestion helps you begin the conversation.
248
+
245
249
  ### Model and Thinking Level
246
250
 
247
251
  Below the message box you'll see two dropdown selectors: