@rubytech/taskmaster 1.3.0 → 1.4.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.
Files changed (44) hide show
  1. package/dist/agents/auth-profiles/oauth.js +24 -0
  2. package/dist/agents/tools/message-history-tool.js +2 -3
  3. package/dist/auto-reply/media-note.js +11 -0
  4. package/dist/auto-reply/reply/get-reply.js +4 -0
  5. package/dist/build-info.json +3 -3
  6. package/dist/control-ui/assets/{index-DQ1kxYd4.js → index-BDETQp97.js} +281 -283
  7. package/dist/control-ui/assets/index-BDETQp97.js.map +1 -0
  8. package/dist/control-ui/index.html +1 -1
  9. package/dist/gateway/chat-sanitize.js +5 -1
  10. package/dist/gateway/server/tls.js +2 -2
  11. package/dist/gateway/server-http.js +34 -4
  12. package/dist/gateway/server-methods/chat.js +64 -25
  13. package/dist/gateway/server.impl.js +23 -5
  14. package/dist/infra/tls/gateway.js +19 -3
  15. package/dist/memory/audit.js +9 -0
  16. package/dist/memory/manager.js +1 -1
  17. package/dist/web/auto-reply/monitor/process-message.js +44 -17
  18. package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
  19. package/extensions/googlechat/node_modules/.bin/taskmaster +0 -0
  20. package/extensions/line/node_modules/.bin/taskmaster +0 -0
  21. package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
  22. package/extensions/matrix/node_modules/.bin/taskmaster +0 -0
  23. package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
  24. package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
  25. package/extensions/msteams/node_modules/.bin/taskmaster +0 -0
  26. package/extensions/nostr/node_modules/.bin/taskmaster +0 -0
  27. package/extensions/nostr/node_modules/.bin/tsc +0 -0
  28. package/extensions/nostr/node_modules/.bin/tsserver +0 -0
  29. package/extensions/zalo/node_modules/.bin/taskmaster +0 -0
  30. package/extensions/zalouser/node_modules/.bin/taskmaster +0 -0
  31. package/package.json +54 -64
  32. package/scripts/install.sh +0 -0
  33. package/taskmaster-docs/USER-GUIDE.md +28 -2
  34. package/templates/.DS_Store +0 -0
  35. package/templates/customer/.DS_Store +0 -0
  36. package/templates/customer/agents/.DS_Store +0 -0
  37. package/templates/maxy/.DS_Store +0 -0
  38. package/templates/maxy/.gitignore +1 -0
  39. package/templates/maxy/agents/.DS_Store +0 -0
  40. package/templates/maxy/agents/admin/.DS_Store +0 -0
  41. package/templates/maxy/memory/.DS_Store +0 -0
  42. package/templates/maxy/skills/.DS_Store +0 -0
  43. package/templates/taskmaster/.gitignore +1 -0
  44. package/dist/control-ui/assets/index-DQ1kxYd4.js.map +0 -1
@@ -6,7 +6,7 @@
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-DQ1kxYd4.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-BDETQp97.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-CPawOl_z.css">
11
11
  </head>
12
12
  <body>
@@ -208,6 +208,8 @@ export function mimeFromExt(ext) {
208
208
  return "audio/wav";
209
209
  case "m4a":
210
210
  return "audio/mp4";
211
+ case "webm":
212
+ return "audio/webm";
211
213
  default:
212
214
  return "application/octet-stream";
213
215
  }
@@ -243,7 +245,9 @@ function mediaRefToUrl(ref, workspaceRoot) {
243
245
  const stat = nodeFs.statSync(ref.absPath);
244
246
  mtime = `&t=${stat.mtimeMs | 0}`;
245
247
  }
246
- catch { /* file may not exist yet */ }
248
+ catch {
249
+ /* file may not exist yet */
250
+ }
247
251
  return `/api/media?path=${encodeURIComponent(relPath)}${mtime}`;
248
252
  }
249
253
  function stripBase64FromContentBlocks(content) {
@@ -1,4 +1,4 @@
1
1
  import { loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig, } from "../../infra/tls/gateway.js";
2
- export async function loadGatewayTlsRuntime(cfg, log) {
3
- return await loadGatewayTlsRuntimeConfig(cfg, log);
2
+ export async function loadGatewayTlsRuntime(cfg, log, hostnames) {
3
+ return await loadGatewayTlsRuntimeConfig(cfg, log, hostnames);
4
4
  }
@@ -1,5 +1,6 @@
1
1
  import { createServer as createHttpServer, } from "node:http";
2
2
  import { createServer as createHttpsServer } from "node:https";
3
+ import net from "node:net";
3
4
  import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
4
5
  import { loadConfig } from "../config/config.js";
5
6
  import { handleSlackHttpRequest } from "../slack/http/index.js";
@@ -141,13 +142,42 @@ export function createHooksRequestHandler(opts) {
141
142
  }
142
143
  export function createGatewayHttpServer(opts) {
143
144
  const { canvasHost, controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, handleHooksRequest, handlePluginRequest, resolvedAuth, } = opts;
144
- const httpServer = opts.tlsOptions
145
- ? createHttpsServer(opts.tlsOptions, (req, res) => {
145
+ // When TLS is enabled, create a dual-protocol server: a net.Server listens
146
+ // on the port, peeks at the first byte of each connection to detect TLS vs
147
+ // plain HTTP, and dispatches accordingly. Plain HTTP gets a 301 redirect to
148
+ // HTTPS so bookmarks and muscle-memory URLs keep working.
149
+ let httpServer;
150
+ if (opts.tlsOptions) {
151
+ const httpsServer = createHttpsServer(opts.tlsOptions, (req, res) => {
146
152
  void handleRequest(req, res);
147
- })
148
- : createHttpServer((req, res) => {
153
+ });
154
+ const redirectServer = createHttpServer((req, res) => {
155
+ const host = req.headers.host ?? "localhost";
156
+ res.writeHead(301, { Location: `https://${host}${req.url ?? "/"}`, Connection: "close" });
157
+ res.end();
158
+ });
159
+ const tcpServer = net.createServer((socket) => {
160
+ socket.once("data", (buf) => {
161
+ socket.pause();
162
+ socket.unshift(buf);
163
+ // TLS ClientHello starts with byte 0x16 (22)
164
+ const target = buf[0] === 0x16 ? httpsServer : redirectServer;
165
+ target.emit("connection", socket);
166
+ process.nextTick(() => socket.resume());
167
+ });
168
+ });
169
+ // Forward upgrade events from the HTTPS server to the TCP wrapper
170
+ // so WebSocket handlers attached via attachGatewayUpgradeHandler work.
171
+ httpsServer.on("upgrade", (req, socket, head) => {
172
+ tcpServer.emit("upgrade", req, socket, head);
173
+ });
174
+ httpServer = tcpServer;
175
+ }
176
+ else {
177
+ httpServer = createHttpServer((req, res) => {
149
178
  void handleRequest(req, res);
150
179
  });
180
+ }
151
181
  async function handleRequest(req, res) {
152
182
  // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
153
183
  if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket")
@@ -379,8 +379,12 @@ export const chatHandlers = {
379
379
  }
380
380
  }
381
381
  }
382
- // Save document attachments to workspace uploads dir (persistent, accessible by agent)
382
+ // Save document attachments to workspace uploads dir (persistent, accessible by agent).
383
+ // Audio files are separated so they can be routed through the media understanding
384
+ // pipeline (STT) instead of being treated as generic file attachments.
383
385
  const savedDocPaths = [];
386
+ const savedAudioPaths = [];
387
+ const savedAudioTypes = [];
384
388
  if (documentAttachments.length > 0 && uploadsDir) {
385
389
  for (const doc of documentAttachments) {
386
390
  if (!doc.content || typeof doc.content !== "string")
@@ -389,7 +393,14 @@ export const chatHandlers = {
389
393
  const destPath = path.join(uploadsDir, safeName);
390
394
  try {
391
395
  fs.writeFileSync(destPath, Buffer.from(doc.content, "base64"));
392
- savedDocPaths.push(destPath);
396
+ const mimeBase = doc.mimeType?.split(";")[0]?.trim() ?? "";
397
+ if (mimeBase.startsWith("audio/")) {
398
+ savedAudioPaths.push(destPath);
399
+ savedAudioTypes.push(doc.mimeType ?? "audio/webm");
400
+ }
401
+ else {
402
+ savedDocPaths.push(destPath);
403
+ }
393
404
  }
394
405
  catch (err) {
395
406
  context.logGateway.warn(`chat document save failed: ${String(err)}`);
@@ -460,18 +471,29 @@ export const chatHandlers = {
460
471
  const trimmedMessage = p.message.trim();
461
472
  const injectThinking = Boolean(p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"));
462
473
  const commandBody = injectThinking ? `/think ${p.thinking} ${p.message}` : p.message;
463
- // If documents were saved, prepend file paths to message so the agent knows about them
474
+ // If non-audio documents were saved, prepend file paths to message.
475
+ // Audio files are NOT annotated here — they go through MediaPaths so the
476
+ // media understanding pipeline (STT) handles them, and buildInboundMediaNote
477
+ // generates the proper [media attached: ...] annotation.
464
478
  const docNote = savedDocPaths.length > 0
465
479
  ? savedDocPaths.map((p) => `[file: ${p}]`).join("\n") + "\n\n"
466
480
  : "";
467
- const messageWithDocs = docNote + p.message;
481
+ // Audio-only message (voice note, no text): use placeholder so
482
+ // applyMediaUnderstanding knows to replace with transcript or error.
483
+ const hasAudioMedia = savedAudioPaths.length > 0;
484
+ const effectiveBody = hasAudioMedia && !trimmedMessage ? "<media:audio>" : p.message;
485
+ const messageWithDocs = docNote + effectiveBody;
486
+ const effectiveCommandBody = hasAudioMedia && !trimmedMessage ? "<media:audio>" : commandBody;
487
+ // Merge image and audio paths so the media understanding pipeline sees both.
488
+ const allMediaPaths = [...savedImagePaths, ...savedAudioPaths];
489
+ const allMediaTypes = [...savedImageTypes, ...savedAudioTypes];
468
490
  const clientInfo = client?.connect?.client;
469
491
  const ctx = {
470
492
  Body: messageWithDocs,
471
493
  BodyForAgent: messageWithDocs,
472
- BodyForCommands: docNote + commandBody,
494
+ BodyForCommands: docNote + effectiveCommandBody,
473
495
  RawBody: messageWithDocs,
474
- CommandBody: docNote + commandBody,
496
+ CommandBody: docNote + effectiveCommandBody,
475
497
  SessionKey: p.sessionKey,
476
498
  Provider: INTERNAL_MESSAGE_CHANNEL,
477
499
  Surface: INTERNAL_MESSAGE_CHANNEL,
@@ -485,10 +507,10 @@ export const chatHandlers = {
485
507
  // Image/media paths — same pattern as WhatsApp. buildInboundMediaNote()
486
508
  // will generate [media attached: ...] annotations that the agent runner
487
509
  // detects and loads from disk at inference time.
488
- MediaPaths: savedImagePaths.length > 0 ? savedImagePaths : undefined,
489
- MediaPath: savedImagePaths[0],
490
- MediaTypes: savedImageTypes.length > 0 ? savedImageTypes : undefined,
491
- MediaType: savedImageTypes[0],
510
+ MediaPaths: allMediaPaths.length > 0 ? allMediaPaths : undefined,
511
+ MediaPath: allMediaPaths[0],
512
+ MediaTypes: allMediaTypes.length > 0 ? allMediaTypes : undefined,
513
+ MediaType: allMediaTypes[0],
492
514
  };
493
515
  const agentId = resolveSessionAgentId({
494
516
  sessionKey: p.sessionKey,
@@ -496,16 +518,26 @@ export const chatHandlers = {
496
518
  });
497
519
  // Fire message:inbound hook for conversation archiving.
498
520
  // Include image paths so the archive references the attached media.
521
+ // Audio archive is deferred until after media understanding resolves (see
522
+ // onMediaResolved below) so the transcript is available instead of the
523
+ // raw <media:audio> placeholder.
499
524
  const imageNote = savedImagePaths.length > 0 ? savedImagePaths.map((ip) => `[image: ${ip}]`).join("\n") : "";
500
- const archiveText = [p.message, imageNote].filter(Boolean).join("\n").trim();
501
- void triggerInternalHook(createInternalHookEvent("message", "inbound", p.sessionKey, {
502
- text: archiveText || undefined,
503
- timestamp: now,
504
- chatType: "direct",
505
- agentId,
506
- channel: "webchat",
507
- cfg,
508
- }));
525
+ const fireArchiveHook = (resolvedBody) => {
526
+ const body = resolvedBody ?? p.message;
527
+ const archiveText = [body, imageNote].filter(Boolean).join("\n").trim();
528
+ void triggerInternalHook(createInternalHookEvent("message", "inbound", p.sessionKey, {
529
+ text: archiveText || undefined,
530
+ timestamp: now,
531
+ chatType: "direct",
532
+ agentId,
533
+ channel: "webchat",
534
+ cfg,
535
+ }));
536
+ };
537
+ if (!hasAudioMedia) {
538
+ // No audio — fire immediately (no STT to wait for).
539
+ fireArchiveHook();
540
+ }
509
541
  let prefixContext = {
510
542
  identityName: resolveIdentityName(cfg, agentId),
511
543
  };
@@ -541,7 +573,7 @@ export const chatHandlers = {
541
573
  },
542
574
  });
543
575
  let agentRunStarted = false;
544
- context.logGateway.info(`webchat dispatch: sessionKey=${p.sessionKey} runId=${clientRunId} body=${messageWithDocs.length}ch images=${savedImagePaths.length} docs=${savedDocPaths.length}`);
576
+ context.logGateway.info(`webchat dispatch: sessionKey=${p.sessionKey} runId=${clientRunId} body=${messageWithDocs.length}ch images=${savedImagePaths.length} audio=${savedAudioPaths.length} docs=${savedDocPaths.length}`);
545
577
  void dispatchInboundMessage({
546
578
  ctx,
547
579
  cfg,
@@ -554,11 +586,18 @@ export const chatHandlers = {
554
586
  agentRunStarted = true;
555
587
  context.logGateway.info(`webchat agent run started: sessionKey=${p.sessionKey} runId=${runId}`);
556
588
  },
557
- onModelSelected: (ctx) => {
558
- prefixContext.provider = ctx.provider;
559
- prefixContext.model = extractShortModelName(ctx.model);
560
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
561
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
589
+ onMediaResolved: hasAudioMedia
590
+ ? () => {
591
+ // STT complete — archive the resolved body (transcript) instead
592
+ // of the raw <media:audio> placeholder.
593
+ fireArchiveHook(ctx.Body);
594
+ }
595
+ : undefined,
596
+ onModelSelected: (modelCtx) => {
597
+ prefixContext.provider = modelCtx.provider;
598
+ prefixContext.model = extractShortModelName(modelCtx.model);
599
+ prefixContext.modelFull = `${modelCtx.provider}/${modelCtx.model}`;
600
+ prefixContext.thinkingLevel = modelCtx.thinkLevel ?? "off";
562
601
  },
563
602
  },
564
603
  })
@@ -54,6 +54,7 @@ import { ensureWatchdogUnitOnStartup, scheduleWatchdogStabilityConfirmation, } f
54
54
  import { startGatewayTailscaleExposure } from "./server-tailscale.js";
55
55
  import { startWifiWatchdog } from "./server-wifi-watchdog.js";
56
56
  import { loadGatewayTlsRuntime } from "./server/tls.js";
57
+ import { isLoopbackHost } from "./net.js";
57
58
  import { createWizardSessionTracker } from "./server-wizard-sessions.js";
58
59
  import { attachGatewayWsHandlers } from "./server-ws-runtime.js";
59
60
  import { isLicenseValid } from "../license/validate.js";
@@ -226,10 +227,30 @@ export async function startGatewayServer(port = 18789, opts = {}) {
226
227
  const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker();
227
228
  const deps = createDefaultDeps();
228
229
  let canvasHostServer = null;
229
- const gatewayTls = await loadGatewayTlsRuntime(cfgAtStart.gateway?.tls, log.child("tls"));
230
- if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) {
230
+ // Auto-enable TLS when binding to a non-loopback address (LAN, custom, etc.)
231
+ // so that browser secure-context APIs (getUserMedia, etc.) work over .local.
232
+ // Only auto-enable when the user hasn't explicitly configured tls.enabled.
233
+ const tlsExplicit = cfgAtStart.gateway?.tls?.enabled;
234
+ const tlsAutoEnable = tlsExplicit === undefined && !isLoopbackHost(bindHost);
235
+ const effectiveTlsConfig = tlsAutoEnable
236
+ ? { ...cfgAtStart.gateway?.tls, enabled: true }
237
+ : cfgAtStart.gateway?.tls;
238
+ if (tlsAutoEnable) {
239
+ log.child("tls").info("gateway tls: auto-enabled for non-loopback bind");
240
+ }
241
+ const bonjourHostname = cfgAtStart.discovery?.bonjourHostname || "taskmaster";
242
+ const tlsHostnames = [bonjourHostname];
243
+ const gatewayTls = await loadGatewayTlsRuntime(effectiveTlsConfig, log.child("tls"), tlsHostnames);
244
+ if (tlsExplicit === true && !gatewayTls.enabled) {
245
+ // User explicitly enabled TLS — fail hard if it can't start.
231
246
  throw new Error(gatewayTls.error ?? "gateway tls: failed to enable");
232
247
  }
248
+ if (tlsAutoEnable && !gatewayTls.enabled) {
249
+ // Auto-enabled TLS failed — fall back to HTTP with a warning.
250
+ log
251
+ .child("tls")
252
+ .warn(`gateway tls: auto-enable failed (${gatewayTls.error ?? "unknown"}), continuing with HTTP`);
253
+ }
233
254
  const { canvasHost, httpServer, httpServers, httpBindHosts, wss, clients, broadcast, agentRunSeq, dedupe, chatRunState, chatRunBuffers, chatDeltaSentAt, addChatRun, removeChatRun, chatAbortControllers, } = await createGatewayRuntimeState({
234
255
  cfg: cfgAtStart,
235
256
  bindHost,
@@ -283,9 +304,6 @@ export async function startGatewayServer(port = 18789, opts = {}) {
283
304
  });
284
305
  const { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut } = channelManager;
285
306
  const machineDisplayName = await getMachineDisplayName();
286
- // Default to "taskmaster" hostname for mDNS so taskmaster.local works out of the box.
287
- // Users can override via discovery.bonjourHostname config if needed.
288
- const bonjourHostname = cfgAtStart.discovery?.bonjourHostname || "taskmaster";
289
307
  const discovery = await startGatewayDiscovery({
290
308
  machineDisplayName,
291
309
  port,
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { X509Certificate } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { promisify } from "node:util";
6
7
  import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js";
@@ -15,6 +16,18 @@ async function fileExists(filePath) {
15
16
  return false;
16
17
  }
17
18
  }
19
+ function buildSanString(hostnames) {
20
+ const sans = new Set(["DNS:localhost", "IP:127.0.0.1"]);
21
+ const raw = hostnames?.length ? hostnames : [os.hostname()];
22
+ for (const h of raw) {
23
+ const name = h.replace(/\.local$/i, "").trim();
24
+ if (!name)
25
+ continue;
26
+ sans.add(`DNS:${name}`);
27
+ sans.add(`DNS:${name}.local`);
28
+ }
29
+ return [...sans].join(",");
30
+ }
18
31
  async function generateSelfSignedCert(params) {
19
32
  const certDir = path.dirname(params.certPath);
20
33
  const keyDir = path.dirname(params.keyPath);
@@ -22,6 +35,7 @@ async function generateSelfSignedCert(params) {
22
35
  if (keyDir !== certDir) {
23
36
  await ensureDir(keyDir);
24
37
  }
38
+ const san = buildSanString(params.hostnames);
25
39
  await execFileAsync("openssl", [
26
40
  "req",
27
41
  "-x509",
@@ -37,12 +51,14 @@ async function generateSelfSignedCert(params) {
37
51
  params.certPath,
38
52
  "-subj",
39
53
  "/CN=taskmaster-gateway",
54
+ "-addext",
55
+ `subjectAltName=${san}`,
40
56
  ]);
41
57
  await fs.chmod(params.keyPath, 0o600).catch(() => { });
42
58
  await fs.chmod(params.certPath, 0o600).catch(() => { });
43
- params.log?.info?.(`gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`);
59
+ params.log?.info?.(`gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)} (SAN: ${san})`);
44
60
  }
45
- export async function loadGatewayTlsRuntime(cfg, log) {
61
+ export async function loadGatewayTlsRuntime(cfg, log, hostnames) {
46
62
  if (!cfg || cfg.enabled !== true)
47
63
  return { enabled: false, required: false };
48
64
  const autoGenerate = cfg.autoGenerate !== false;
@@ -54,7 +70,7 @@ export async function loadGatewayTlsRuntime(cfg, log) {
54
70
  const hasKey = await fileExists(keyPath);
55
71
  if (!hasCert && !hasKey && autoGenerate) {
56
72
  try {
57
- await generateSelfSignedCert({ certPath, keyPath, log });
73
+ await generateSelfSignedCert({ certPath, keyPath, hostnames, log });
58
74
  }
59
75
  catch (err) {
60
76
  return {
@@ -55,11 +55,20 @@ function writeAuditFile(workspaceDir, data) {
55
55
  // Audit is best-effort — don't fail writes over audit persistence
56
56
  }
57
57
  }
58
+ /** Deduplicate window: ignore a second write to the same path by the same agent within this period. */
59
+ const DEDUP_WINDOW_MS = 60_000;
58
60
  /**
59
61
  * Record an audit entry for a memory write.
62
+ * Skips the entry if an identical path+agent combination was recorded within the dedup window.
60
63
  */
61
64
  export function recordAuditEntry(workspaceDir, entry) {
62
65
  const audit = readAuditFile(workspaceDir);
66
+ // Deduplicate: skip if same path + agent recorded within the last DEDUP_WINDOW_MS
67
+ const dominated = audit.entries.some((e) => e.path === entry.path &&
68
+ e.agentId === entry.agentId &&
69
+ entry.timestamp - e.timestamp < DEDUP_WINDOW_MS);
70
+ if (dominated)
71
+ return;
63
72
  audit.entries.push(entry);
64
73
  // Cap at 500 entries to prevent unbounded growth.
65
74
  if (audit.entries.length > 500) {
@@ -1245,7 +1245,7 @@ export class MemoryIndexManager {
1245
1245
  }
1246
1246
  const action = record ? "updated" : "added";
1247
1247
  log.info(`file ${action} (${this.agentId}): ${entry.path}`);
1248
- if (isAuditablePath(entry.path)) {
1248
+ if (isAuditablePath(entry.path) && record?.hash !== entry.hash) {
1249
1249
  recordAuditEntry(this.workspaceDir, {
1250
1250
  path: entry.path,
1251
1251
  timestamp: Date.now(),
@@ -130,23 +130,32 @@ export async function processMessage(params) {
130
130
  params.echoForget(combinedEchoKey);
131
131
  return false;
132
132
  }
133
- // Fire message:inbound hook for conversation archiving
134
- const inboundHookEvent = createInternalHookEvent("message", "inbound", params.route.sessionKey, {
135
- from: params.msg.senderE164 ?? params.msg.from,
136
- to: params.msg.to,
137
- text: params.msg.body,
138
- timestamp: params.msg.timestamp ?? Date.now(),
139
- chatType: params.msg.chatType,
140
- agentId: params.route.agentId,
141
- channel: "whatsapp",
142
- mediaType: params.msg.mediaType,
143
- senderName: params.msg.senderName,
144
- senderE164: params.msg.senderE164,
145
- groupSubject: params.msg.groupSubject,
146
- conversationId,
147
- cfg: params.cfg,
148
- });
149
- void triggerInternalHook(inboundHookEvent);
133
+ // Fire message:inbound hook for conversation archiving.
134
+ // For media messages (audio, image, video) the raw body is a placeholder like
135
+ // `<media:audio>`. Defer the hook until after media understanding resolves so
136
+ // the archive records the transcript / description instead of the dead link.
137
+ // Text-only messages fire immediately (unchanged behaviour).
138
+ const hasMediaAttachment = Boolean(params.msg.mediaType);
139
+ const fireInboundArchiveHook = (text) => {
140
+ void triggerInternalHook(createInternalHookEvent("message", "inbound", params.route.sessionKey, {
141
+ from: params.msg.senderE164 ?? params.msg.from,
142
+ to: params.msg.to,
143
+ text,
144
+ timestamp: params.msg.timestamp ?? Date.now(),
145
+ chatType: params.msg.chatType,
146
+ agentId: params.route.agentId,
147
+ channel: "whatsapp",
148
+ mediaType: params.msg.mediaType,
149
+ senderName: params.msg.senderName,
150
+ senderE164: params.msg.senderE164,
151
+ groupSubject: params.msg.groupSubject,
152
+ conversationId,
153
+ cfg: params.cfg,
154
+ }));
155
+ };
156
+ if (!hasMediaAttachment) {
157
+ fireInboundArchiveHook(params.msg.body);
158
+ }
150
159
  // Send ack reaction immediately upon message receipt (post-gating).
151
160
  // Suppress when running silently on un-mentioned group messages.
152
161
  if (params.suppressDelivery) {
@@ -364,6 +373,24 @@ export async function processMessage(params) {
364
373
  ? !params.cfg.channels.whatsapp.blockStreaming
365
374
  : undefined,
366
375
  onModelSelected: prefixContext.onModelSelected,
376
+ // For media messages the inbound archive hook was deferred until after
377
+ // media understanding resolves. Fire it now with the resolved text.
378
+ ...(hasMediaAttachment
379
+ ? {
380
+ onMediaResolved: () => {
381
+ const raw = params.msg.body ?? "";
382
+ // Prefer the raw transcript for audio. For other media or failed
383
+ // transcription fall back to the resolved Body (which contains the
384
+ // description or a human-readable failure reason). The regex guard
385
+ // prevents using the envelope-formatted Body for plain text messages.
386
+ const resolvedText = ctxPayload.Transcript ??
387
+ (/^<media:[^>]+>/i.test(raw.trim())
388
+ ? String(ctxPayload.Body ?? raw)
389
+ : raw);
390
+ fireInboundArchiveHook(String(resolvedText));
391
+ },
392
+ }
393
+ : {}),
367
394
  },
368
395
  });
369
396
  if (!queuedFinal) {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes