@rubytech/taskmaster 1.0.38 → 1.0.39

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.
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-B0Q2Wmm1.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-DkMDU6zX.css">
9
+ <script type="module" crossorigin src="./assets/index-gQeHDI6a.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-Ceb3FTmS.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -15,6 +15,9 @@ const ENVELOPE_CHANNELS = [
15
15
  "BlueBubbles",
16
16
  ];
17
17
  const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
18
+ // Internal annotations prepended by buildInboundMediaNote / get-reply-run
19
+ const MEDIA_ATTACHED_LINE = /^\s*\[media attached(?:\s+\d+\/\d+)?:\s*[^\]]+\]\s*$/i;
20
+ const MEDIA_REPLY_HINT = /^\s*To send an image back, prefer the message tool\b/;
18
21
  function looksLikeEnvelopeHeader(header) {
19
22
  if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header))
20
23
  return true;
@@ -23,13 +26,16 @@ function looksLikeEnvelopeHeader(header) {
23
26
  return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
24
27
  }
25
28
  export function stripEnvelope(text) {
26
- const match = text.match(ENVELOPE_PREFIX);
27
- if (!match)
28
- return text;
29
- const header = match[1] ?? "";
30
- if (!looksLikeEnvelopeHeader(header))
31
- return text;
32
- return text.slice(match[0].length);
29
+ let result = text;
30
+ const match = result.match(ENVELOPE_PREFIX);
31
+ if (match) {
32
+ const header = match[1] ?? "";
33
+ if (looksLikeEnvelopeHeader(header)) {
34
+ result = result.slice(match[0].length);
35
+ }
36
+ }
37
+ result = stripMediaAnnotations(result);
38
+ return result;
33
39
  }
34
40
  function stripMessageIdHints(text) {
35
41
  if (!text.includes("[message_id:"))
@@ -38,6 +44,17 @@ function stripMessageIdHints(text) {
38
44
  const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
39
45
  return filtered.length === lines.length ? text : filtered.join("\n");
40
46
  }
47
+ function stripMediaAnnotations(text) {
48
+ if (!text.includes("[media attached"))
49
+ return text;
50
+ const lines = text.split(/\r?\n/);
51
+ const filtered = lines.filter((line) => !MEDIA_ATTACHED_LINE.test(line) && !MEDIA_REPLY_HINT.test(line));
52
+ if (filtered.length === lines.length)
53
+ return text;
54
+ // Also strip the "[media attached: N files]" header line
55
+ const result = filtered.filter((line) => !/^\s*\[media attached:\s*\d+\s+files?\]\s*$/i.test(line));
56
+ return result.join("\n").trim();
57
+ }
41
58
  function stripEnvelopeFromContent(content) {
42
59
  let changed = false;
43
60
  const next = content.map((item) => {
@@ -10,9 +10,9 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
10
10
  import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
11
11
  import { extractShortModelName, } from "../../auto-reply/reply/response-prefix-template.js";
12
12
  import { resolveSendPolicy } from "../../sessions/send-policy.js";
13
+ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
13
14
  import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
14
15
  import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
15
- import { parseMessageWithAttachments } from "../chat-attachments.js";
16
16
  import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
17
17
  import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
18
18
  import { capArrayByJsonBytes, loadSessionEntry, readSessionMessages, resolveSessionModelRef, } from "../session-utils.js";
@@ -250,35 +250,74 @@ export const chatHandlers = {
250
250
  // Separate document attachments (PDFs, text files) from image attachments
251
251
  const imageAttachments = normalizedAttachments.filter((a) => a.type !== "document");
252
252
  const documentAttachments = normalizedAttachments.filter((a) => a.type === "document");
253
- let parsedMessage = p.message;
254
- let parsedImages = [];
255
- if (imageAttachments.length > 0) {
256
- try {
257
- const parsed = await parseMessageWithAttachments(p.message, imageAttachments, {
258
- maxBytes: 5_000_000,
259
- log: context.logGateway,
260
- });
261
- parsedMessage = parsed.message;
262
- parsedImages = parsed.images;
263
- }
264
- catch (err) {
265
- respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
266
- return;
267
- }
268
- }
269
- // Save document attachments to workspace uploads dir (persistent, accessible by agent)
270
- const savedDocPaths = [];
271
- if (documentAttachments.length > 0) {
253
+ // Resolve workspace uploads dir for all attachments (persistent, no TTL).
254
+ // Both images and documents are saved as plain files — same as every other channel.
255
+ let uploadsDir = null;
256
+ if (normalizedAttachments.length > 0) {
272
257
  const { cfg: sessionCfg } = loadSessionEntry(p.sessionKey);
273
258
  const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: sessionCfg });
274
259
  const workspaceDir = resolveAgentWorkspaceDir(sessionCfg, agentId);
275
- const uploadsDir = path.join(workspaceDir, "uploads");
260
+ uploadsDir = path.join(workspaceDir, "uploads");
276
261
  try {
277
262
  fs.mkdirSync(uploadsDir, { recursive: true });
278
263
  }
279
264
  catch {
280
265
  /* ignore if exists */
281
266
  }
267
+ }
268
+ // Save image attachments to workspace uploads dir (persistent, accessible by agent).
269
+ // The agent runner detects file path references via [media attached: ...] and
270
+ // loads them from disk at inference time — no inline base64 in transcripts.
271
+ const savedImagePaths = [];
272
+ const savedImageTypes = [];
273
+ if (imageAttachments.length > 0 && uploadsDir) {
274
+ for (const att of imageAttachments) {
275
+ if (!att.content || typeof att.content !== "string")
276
+ continue;
277
+ try {
278
+ let b64 = att.content.trim();
279
+ const dataUrlMatch = /^data:[^;]+;base64,(.*)$/.exec(b64);
280
+ if (dataUrlMatch)
281
+ b64 = dataUrlMatch[1];
282
+ const buffer = Buffer.from(b64, "base64");
283
+ // Derive extension from mime type
284
+ const mimeBase = att.mimeType?.split(";")[0]?.trim();
285
+ const extMap = {
286
+ "image/jpeg": ".jpg",
287
+ "image/png": ".png",
288
+ "image/gif": ".gif",
289
+ "image/webp": ".webp",
290
+ "image/heic": ".heic",
291
+ "image/heif": ".heif",
292
+ "image/svg+xml": ".svg",
293
+ "image/avif": ".avif",
294
+ };
295
+ const ext = (mimeBase && extMap[mimeBase]) ?? ".jpg";
296
+ const uuid = randomUUID();
297
+ let safeName;
298
+ if (att.fileName) {
299
+ const base = path
300
+ .parse(att.fileName)
301
+ .name.replace(/[^a-zA-Z0-9._-]/g, "_")
302
+ .slice(0, 60);
303
+ safeName = base ? `${base}---${uuid}${ext}` : `${uuid}${ext}`;
304
+ }
305
+ else {
306
+ safeName = `${uuid}${ext}`;
307
+ }
308
+ const destPath = path.join(uploadsDir, safeName);
309
+ fs.writeFileSync(destPath, buffer);
310
+ savedImagePaths.push(destPath);
311
+ savedImageTypes.push(mimeBase ?? "image/png");
312
+ }
313
+ catch (err) {
314
+ context.logGateway.warn(`chat image save failed: ${String(err)}`);
315
+ }
316
+ }
317
+ }
318
+ // Save document attachments to workspace uploads dir (persistent, accessible by agent)
319
+ const savedDocPaths = [];
320
+ if (documentAttachments.length > 0 && uploadsDir) {
282
321
  for (const doc of documentAttachments) {
283
322
  if (!doc.content || typeof doc.content !== "string")
284
323
  continue;
@@ -354,14 +393,14 @@ export const chatHandlers = {
354
393
  status: "started",
355
394
  };
356
395
  respond(true, ackPayload, undefined, { runId: clientRunId });
357
- const trimmedMessage = parsedMessage.trim();
396
+ const trimmedMessage = p.message.trim();
358
397
  const injectThinking = Boolean(p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"));
359
- const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
398
+ const commandBody = injectThinking ? `/think ${p.thinking} ${p.message}` : p.message;
360
399
  // If documents were saved, prepend file paths to message so the agent knows about them
361
400
  const docNote = savedDocPaths.length > 0
362
401
  ? savedDocPaths.map((p) => `[file: ${p}]`).join("\n") + "\n\n"
363
402
  : "";
364
- const messageWithDocs = docNote + parsedMessage;
403
+ const messageWithDocs = docNote + p.message;
365
404
  const clientInfo = client?.connect?.client;
366
405
  const ctx = {
367
406
  Body: messageWithDocs,
@@ -379,11 +418,30 @@ export const chatHandlers = {
379
418
  SenderId: clientInfo?.id,
380
419
  SenderName: clientInfo?.displayName,
381
420
  SenderUsername: clientInfo?.displayName,
421
+ // Image/media paths — same pattern as WhatsApp. buildInboundMediaNote()
422
+ // will generate [media attached: ...] annotations that the agent runner
423
+ // detects and loads from disk at inference time.
424
+ MediaPaths: savedImagePaths.length > 0 ? savedImagePaths : undefined,
425
+ MediaPath: savedImagePaths[0],
426
+ MediaTypes: savedImageTypes.length > 0 ? savedImageTypes : undefined,
427
+ MediaType: savedImageTypes[0],
382
428
  };
383
429
  const agentId = resolveSessionAgentId({
384
430
  sessionKey: p.sessionKey,
385
431
  config: cfg,
386
432
  });
433
+ // Fire message:inbound hook for conversation archiving.
434
+ // Include image paths so the archive references the attached media.
435
+ const imageNote = savedImagePaths.length > 0 ? savedImagePaths.map((ip) => `[image: ${ip}]`).join("\n") : "";
436
+ const archiveText = [p.message, imageNote].filter(Boolean).join("\n").trim();
437
+ void triggerInternalHook(createInternalHookEvent("message", "inbound", p.sessionKey, {
438
+ text: archiveText || undefined,
439
+ timestamp: now,
440
+ chatType: "direct",
441
+ agentId,
442
+ channel: "webchat",
443
+ cfg,
444
+ }));
387
445
  let prefixContext = {
388
446
  identityName: resolveIdentityName(cfg, agentId),
389
447
  };
@@ -419,6 +477,7 @@ export const chatHandlers = {
419
477
  },
420
478
  });
421
479
  let agentRunStarted = false;
480
+ context.logGateway.info(`webchat dispatch: sessionKey=${p.sessionKey} runId=${clientRunId} body=${messageWithDocs.length}ch images=${savedImagePaths.length} docs=${savedDocPaths.length}`);
422
481
  void dispatchInboundMessage({
423
482
  ctx,
424
483
  cfg,
@@ -426,10 +485,10 @@ export const chatHandlers = {
426
485
  replyOptions: {
427
486
  runId: clientRunId,
428
487
  abortSignal: abortController.signal,
429
- images: parsedImages.length > 0 ? parsedImages : undefined,
430
488
  disableBlockStreaming: true,
431
- onAgentRunStart: () => {
489
+ onAgentRunStart: (runId) => {
432
490
  agentRunStarted = true;
491
+ context.logGateway.info(`webchat agent run started: sessionKey=${p.sessionKey} runId=${runId}`);
433
492
  },
434
493
  onModelSelected: (ctx) => {
435
494
  prefixContext.provider = ctx.provider;
@@ -440,6 +499,8 @@ export const chatHandlers = {
440
499
  },
441
500
  })
442
501
  .then(() => {
502
+ const { entry: postEntry } = loadSessionEntry(p.sessionKey);
503
+ context.logGateway.info(`webchat dispatch done: sessionKey=${p.sessionKey} agentRunStarted=${agentRunStarted} sessionId=${postEntry?.sessionId ?? "none"} sessionFile=${postEntry?.sessionFile ?? "none"}`);
443
504
  if (!agentRunStarted) {
444
505
  const combinedReply = finalReplyParts
445
506
  .map((part) => part.trim())
@@ -479,6 +540,18 @@ export const chatHandlers = {
479
540
  message,
480
541
  });
481
542
  }
543
+ // Fire message:outbound hook for conversation archiving
544
+ const outboundText = finalReplyParts.join("\n\n").trim();
545
+ if (outboundText) {
546
+ void triggerInternalHook(createInternalHookEvent("message", "outbound", p.sessionKey, {
547
+ text: outboundText,
548
+ timestamp: Date.now(),
549
+ chatType: "direct",
550
+ agentId,
551
+ channel: "webchat",
552
+ cfg,
553
+ }));
554
+ }
482
555
  context.dedupe.set(`chat:${clientRunId}`, {
483
556
  ts: Date.now(),
484
557
  ok: true,
@@ -486,6 +559,7 @@ export const chatHandlers = {
486
559
  });
487
560
  })
488
561
  .catch((err) => {
562
+ context.logGateway.warn(`webchat dispatch failed: sessionKey=${p.sessionKey} runId=${clientRunId} error=${formatForLog(err)}`);
489
563
  const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
490
564
  context.dedupe.set(`chat:${clientRunId}`, {
491
565
  ts: Date.now(),
@@ -31,6 +31,13 @@ function extractPeerFromSessionKey(sessionKey) {
31
31
  }
32
32
  return null;
33
33
  }
34
+ /**
35
+ * Detect webchat session key format: agent:{agentId}:main
36
+ */
37
+ function isWebchatSessionKey(sessionKey) {
38
+ const parts = sessionKey.toLowerCase().split(":").filter(Boolean);
39
+ return parts.length === 3 && parts[0] === "agent" && parts[2] === "main";
40
+ }
34
41
  /**
35
42
  * Extract group ID from session key
36
43
  *
@@ -148,9 +155,10 @@ const archiveConversation = async (event) => {
148
155
  }
149
156
  // Get timestamp from context or event
150
157
  const timestamp = context.timestamp ?? event.timestamp;
151
- // Try DM first, then group
158
+ // Determine conversation type from session key and route to correct archive path
152
159
  const peer = extractPeerFromSessionKey(event.sessionKey);
153
160
  const groupId = peer ? null : extractGroupIdFromSessionKey(event.sessionKey);
161
+ const isWebchat = !peer && !groupId && isWebchatSessionKey(event.sessionKey);
154
162
  if (peer) {
155
163
  // Admin DMs archive to memory/admin/conversations/ (not accessible by public agent).
156
164
  // Public DMs archive to memory/users/{peer}/conversations/.
@@ -187,8 +195,13 @@ const archiveConversation = async (event) => {
187
195
  fileHeader,
188
196
  });
189
197
  }
198
+ else if (isWebchat) {
199
+ // Webchat (control panel) — archive under memory/admin/conversations/
200
+ const role = event.action === "inbound" ? "Admin" : "Assistant";
201
+ await archiveMessage({ workspaceDir, subdir: "admin", role, text, timestamp });
202
+ }
190
203
  else {
191
- // Neither DM nor group — skip
204
+ // Unknown session key format — skip
192
205
  return;
193
206
  }
194
207
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import fsSync from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import chokidar from "chokidar";
@@ -323,7 +324,25 @@ export class MemoryIndexManager {
323
324
  maxEntries: params.settings.cache.maxEntries,
324
325
  };
325
326
  this.fts = { enabled: params.settings.query.hybrid.enabled, available: false };
326
- this.ensureSchema();
327
+ try {
328
+ this.ensureSchema();
329
+ }
330
+ catch (err) {
331
+ // Database is corrupted (e.g. orphaned FTS5 metadata blocking writes).
332
+ // Delete and rebuild from scratch.
333
+ const msg = err instanceof Error ? err.message : String(err);
334
+ log.warn(`schema init failed, rebuilding database: ${msg}`);
335
+ try {
336
+ this.db.close();
337
+ }
338
+ catch {
339
+ /* ignore */
340
+ }
341
+ const dbPath = resolveUserPath(this.settings.store.path);
342
+ this.removeIndexFilesSync(dbPath);
343
+ this.db = this.openDatabase();
344
+ this.ensureSchema();
345
+ }
327
346
  this.vector = {
328
347
  enabled: params.settings.store.vector.enabled,
329
348
  available: null,
@@ -848,6 +867,17 @@ export class MemoryIndexManager {
848
867
  const suffixes = ["", "-wal", "-shm"];
849
868
  await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
850
869
  }
870
+ removeIndexFilesSync(basePath) {
871
+ const suffixes = ["", "-wal", "-shm"];
872
+ for (const suffix of suffixes) {
873
+ try {
874
+ fsSync.rmSync(`${basePath}${suffix}`, { force: true });
875
+ }
876
+ catch {
877
+ /* ignore */
878
+ }
879
+ }
880
+ }
851
881
  ensureSchema() {
852
882
  const result = ensureMemoryIndexSchema({
853
883
  db: this.db,
@@ -64,6 +64,9 @@ export function ensureMemoryIndexSchema(params) {
64
64
  // Leaving them in the DB can cause "no such module: fts5" errors on
65
65
  // unrelated operations if SQLite touches the virtual table machinery.
66
66
  dropOrphanedFtsTables(params.db, params.ftsTable);
67
+ // Verify the database is still writable after cleanup. FTS5 virtual table
68
+ // metadata left in sqlite_master can corrupt the database for all writes.
69
+ verifyWritable(params.db);
67
70
  }
68
71
  }
69
72
  ensureColumn(params.db, "files", "source", "TEXT NOT NULL DEFAULT 'memory'");
@@ -72,6 +75,21 @@ export function ensureMemoryIndexSchema(params) {
72
75
  params.db.exec(`CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);`);
73
76
  return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
74
77
  }
78
+ /**
79
+ * Verify the database is writable by performing a test write.
80
+ * If FTS5 cleanup left orphaned virtual table metadata in sqlite_master,
81
+ * SQLite may refuse all writes. Throws so the caller can delete and rebuild.
82
+ */
83
+ function verifyWritable(db) {
84
+ try {
85
+ db.prepare(`INSERT INTO meta (key, value) VALUES ('_write_check', '1') ON CONFLICT(key) DO UPDATE SET value = '1'`).run();
86
+ db.prepare(`DELETE FROM meta WHERE key = '_write_check'`).run();
87
+ }
88
+ catch (err) {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ throw new Error(`database not writable after FTS cleanup (needs rebuild): ${msg}`);
91
+ }
92
+ }
75
93
  /**
76
94
  * When fts5 module is unavailable but the virtual table and its shadow tables
77
95
  * exist from a previous run, drop them to prevent "no such module" errors.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.38",
3
+ "version": "1.0.39",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"