@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.
- package/dist/agents/pi-embedded-runner/compact.js +7 -3
- package/dist/agents/pi-embedded-runner/history.js +82 -0
- package/dist/agents/pi-embedded-runner/run/attempt.js +9 -3
- package/dist/agents/pi-embedded-runner.js +1 -1
- package/dist/auto-reply/commands-registry.data.js +6 -0
- package/dist/auto-reply/reply/commands-core.js +2 -0
- package/dist/auto-reply/reply/commands-restore.js +64 -0
- package/dist/auto-reply/reply/session.js +6 -1
- package/dist/build-info.json +3 -3
- package/dist/config/sessions/reset.js +4 -1
- package/dist/config/zod-schema.session.js +1 -1
- package/dist/control-ui/assets/{index-Sm-M_hDk.js → index-BPvR6pln.js} +20 -8
- package/dist/control-ui/assets/index-BPvR6pln.js.map +1 -0
- package/dist/control-ui/assets/index-mweBpmCT.css +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/chat-sanitize.js +75 -0
- package/dist/gateway/protocol/schema/logs-chat.js +1 -1
- package/dist/gateway/server-methods/chat.js +31 -15
- package/dist/gateway/server-restart-sentinel.js +6 -3
- package/dist/gateway/server-startup.js +7 -0
- package/dist/infra/restart-sentinel.js +8 -1
- package/dist/infra/session-recovery.js +381 -0
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +4 -0
- package/dist/control-ui/assets/index-Sm-M_hDk.js.map +0 -1
- package/dist/control-ui/assets/index-wibL0JHX.css +0 -1
|
@@ -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
|
@@ -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:
|