@rubytech/taskmaster 1.0.94 → 1.0.95

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 (40) hide show
  1. package/dist/agents/taskmaster-tools.js +4 -4
  2. package/dist/agents/tool-policy.js +2 -2
  3. package/dist/agents/tools/contact-lookup-tool.js +45 -0
  4. package/dist/agents/tools/contact-update-tool.js +68 -0
  5. package/dist/agents/tools/memory-tool.js +10 -3
  6. package/dist/build-info.json +3 -3
  7. package/dist/cli/provision-seed.js +2 -2
  8. package/dist/control-ui/assets/{index-B7exVNNa.css → index-BWth4bv5.css} +1 -1
  9. package/dist/control-ui/assets/{index-DfQL37PU.js → index-Bem0-Ojd.js} +220 -220
  10. package/dist/control-ui/assets/index-Bem0-Ojd.js.map +1 -0
  11. package/dist/control-ui/index.html +2 -2
  12. package/dist/gateway/chat-sanitize.js +121 -5
  13. package/dist/gateway/media-http.js +120 -0
  14. package/dist/gateway/public-chat-api.js +5 -3
  15. package/dist/gateway/server-http.js +3 -0
  16. package/dist/gateway/server-methods/chat.js +5 -3
  17. package/dist/infra/heartbeat-infra-alert.js +143 -0
  18. package/dist/infra/heartbeat-runner.js +13 -0
  19. package/dist/memory/manager.js +15 -8
  20. package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
  21. package/extensions/googlechat/node_modules/.bin/taskmaster +0 -0
  22. package/extensions/line/node_modules/.bin/taskmaster +0 -0
  23. package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
  24. package/extensions/matrix/node_modules/.bin/taskmaster +0 -0
  25. package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
  26. package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
  27. package/extensions/msteams/node_modules/.bin/taskmaster +0 -0
  28. package/extensions/nostr/node_modules/.bin/taskmaster +0 -0
  29. package/extensions/nostr/node_modules/.bin/tsc +0 -0
  30. package/extensions/nostr/node_modules/.bin/tsserver +0 -0
  31. package/extensions/zalo/node_modules/.bin/taskmaster +0 -0
  32. package/extensions/zalouser/node_modules/.bin/taskmaster +0 -0
  33. package/package.json +64 -54
  34. package/scripts/install.sh +0 -0
  35. package/taskmaster-docs/USER-GUIDE.md +1 -1
  36. package/dist/control-ui/assets/index-DfQL37PU.js.map +0 -1
  37. package/templates/.DS_Store +0 -0
  38. package/templates/customer/.DS_Store +0 -0
  39. package/templates/customer/agents/.DS_Store +0 -0
  40. package/templates/taskmaster/.gitignore +0 -1
@@ -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-DfQL37PU.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-B7exVNNa.css">
9
+ <script type="module" crossorigin src="./assets/index-Bem0-Ojd.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-BWth4bv5.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -119,13 +119,18 @@ export function stripEnvelopeFromMessages(messages) {
119
119
  return changed ? next : messages;
120
120
  }
121
121
  // ---------------------------------------------------------------------------
122
- // Base64 image stripping
122
+ // Base64 image stripping & media URL references
123
123
  // ---------------------------------------------------------------------------
124
- // Images must be stored as physical files on disk and referenced by path
125
- // never as inline base64 in transcripts or chat history responses.
126
- // These functions remove base64 data from image content blocks wherever
127
- // they appear (user, assistant, tool messages).
124
+ // Images are stored as physical files on disk and referenced by path.
125
+ // When sending chat history to the UI, base64 image data is replaced with
126
+ // URL references to the /api/media endpoint. The UI renders these as <img>.
127
+ //
128
+ // Flow:
129
+ // 1. Extract file paths from [media attached: /path (type)] text annotations
130
+ // 2. Remove base64 image blocks from content
131
+ // 3. Add { type: "image", url: "/api/media?path=..." } blocks
128
132
  // ---------------------------------------------------------------------------
133
+ import nodePath from "node:path";
129
134
  function isBase64ImageBlock(block) {
130
135
  if (!block || typeof block !== "object")
131
136
  return false;
@@ -148,6 +153,33 @@ function isBase64ImageBlock(block) {
148
153
  }
149
154
  return false;
150
155
  }
156
+ // Pattern: [media attached: /path (mime/type)] or [media attached 1/2: /path (mime/type) | url]
157
+ const MEDIA_PATH_PATTERN = /\[media attached(?:\s+\d+\/\d+)?:\s*(.+?)\s*\(([^)]+)\)(?:\s*\|[^\]]+)?\]/gi;
158
+ /**
159
+ * Parse [media attached: ...] annotations from text to extract file paths.
160
+ */
161
+ function extractMediaRefs(text) {
162
+ if (!text.includes("[media attached"))
163
+ return [];
164
+ const refs = [];
165
+ let match;
166
+ MEDIA_PATH_PATTERN.lastIndex = 0;
167
+ while ((match = MEDIA_PATH_PATTERN.exec(text)) !== null) {
168
+ const absPath = match[1]?.trim();
169
+ const mimeType = match[2]?.trim();
170
+ if (absPath && mimeType) {
171
+ refs.push({ absPath, mimeType });
172
+ }
173
+ }
174
+ return refs;
175
+ }
176
+ function mediaRefToUrl(ref, workspaceRoot) {
177
+ const relPath = nodePath.relative(workspaceRoot, ref.absPath);
178
+ // Must stay within workspace (no ../ escapes)
179
+ if (relPath.startsWith("..") || nodePath.isAbsolute(relPath))
180
+ return null;
181
+ return `/api/media?path=${encodeURIComponent(relPath)}`;
182
+ }
151
183
  function stripBase64FromContentBlocks(content) {
152
184
  let changed = false;
153
185
  const next = content.map((block) => {
@@ -193,3 +225,87 @@ export function stripBase64ImagesFromMessages(messages) {
193
225
  });
194
226
  return changed ? next : messages;
195
227
  }
228
+ // ---------------------------------------------------------------------------
229
+ // Combined media sanitization for chat display
230
+ // ---------------------------------------------------------------------------
231
+ /**
232
+ * Sanitize media in chat messages for UI display.
233
+ * - Extracts file paths from [media attached: ...] text annotations
234
+ * - Removes base64 image blocks
235
+ * - Creates URL-based image references for the /api/media endpoint
236
+ *
237
+ * Must be called BEFORE stripEnvelopeFromMessages (which strips annotations).
238
+ */
239
+ export function sanitizeMediaForChat(messages, workspaceRoot) {
240
+ if (messages.length === 0 || !workspaceRoot) {
241
+ // No workspace context — fall back to plain base64 stripping
242
+ return stripBase64ImagesFromMessages(messages);
243
+ }
244
+ let changed = false;
245
+ const next = messages.map((message) => {
246
+ const result = sanitizeMessageMedia(message, workspaceRoot);
247
+ if (result !== message)
248
+ changed = true;
249
+ return result;
250
+ });
251
+ return changed ? next : messages;
252
+ }
253
+ function sanitizeMessageMedia(message, workspaceRoot) {
254
+ if (!message || typeof message !== "object")
255
+ return message;
256
+ const entry = message;
257
+ // Collect media refs from text content (works for both string and array content)
258
+ const mediaRefs = extractMediaRefsFromMessage(entry);
259
+ // Build URL-based image blocks from annotations
260
+ const imageBlocks = [];
261
+ for (const ref of mediaRefs) {
262
+ const url = mediaRefToUrl(ref, workspaceRoot);
263
+ if (url) {
264
+ imageBlocks.push({ type: "image", url });
265
+ }
266
+ }
267
+ if (!Array.isArray(entry.content)) {
268
+ // String content — no base64 blocks to strip, just add image blocks if found
269
+ if (imageBlocks.length === 0)
270
+ return message;
271
+ const textContent = typeof entry.content === "string" ? entry.content : "";
272
+ return {
273
+ ...entry,
274
+ content: [{ type: "text", text: textContent }, ...imageBlocks],
275
+ };
276
+ }
277
+ // Array content — remove base64 image blocks, add URL-based ones
278
+ let didChange = false;
279
+ const filtered = entry.content.filter((block) => {
280
+ if (isBase64ImageBlock(block)) {
281
+ didChange = true;
282
+ return false;
283
+ }
284
+ return true;
285
+ });
286
+ if (imageBlocks.length > 0) {
287
+ didChange = true;
288
+ filtered.push(...imageBlocks);
289
+ }
290
+ if (!didChange)
291
+ return message;
292
+ return { ...entry, content: filtered };
293
+ }
294
+ function extractMediaRefsFromMessage(entry) {
295
+ if (typeof entry.content === "string") {
296
+ return extractMediaRefs(entry.content);
297
+ }
298
+ if (Array.isArray(entry.content)) {
299
+ const refs = [];
300
+ for (const block of entry.content) {
301
+ if (!block || typeof block !== "object")
302
+ continue;
303
+ const b = block;
304
+ if (b.type === "text" && typeof b.text === "string") {
305
+ refs.push(...extractMediaRefs(b.text));
306
+ }
307
+ }
308
+ return refs;
309
+ }
310
+ return [];
311
+ }
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveAgentWorkspaceRoot } from "../agents/agent-scope.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Workspace media file endpoint
6
+ // ---------------------------------------------------------------------------
7
+ // Serves image files from the workspace root so that chat history can display
8
+ // inline images by URL instead of embedding base64 data in WebSocket messages.
9
+ //
10
+ // Route: GET /api/media?path=<workspace-relative-path>
11
+ // ---------------------------------------------------------------------------
12
+ const ALLOWED_IMAGE_EXTENSIONS = new Set([
13
+ ".png",
14
+ ".jpg",
15
+ ".jpeg",
16
+ ".gif",
17
+ ".webp",
18
+ ".heic",
19
+ ".heif",
20
+ ".bmp",
21
+ ".tiff",
22
+ ".tif",
23
+ ".pdf",
24
+ ]);
25
+ function contentType(ext) {
26
+ switch (ext) {
27
+ case ".png":
28
+ return "image/png";
29
+ case ".jpg":
30
+ case ".jpeg":
31
+ return "image/jpeg";
32
+ case ".gif":
33
+ return "image/gif";
34
+ case ".webp":
35
+ return "image/webp";
36
+ case ".heic":
37
+ return "image/heic";
38
+ case ".heif":
39
+ return "image/heif";
40
+ case ".bmp":
41
+ return "image/bmp";
42
+ case ".tiff":
43
+ case ".tif":
44
+ return "image/tiff";
45
+ case ".pdf":
46
+ return "application/pdf";
47
+ default:
48
+ return "application/octet-stream";
49
+ }
50
+ }
51
+ function isSafeRelativePath(relPath) {
52
+ if (!relPath)
53
+ return false;
54
+ const normalized = path.posix.normalize(relPath);
55
+ if (normalized.startsWith("../") || normalized === "..")
56
+ return false;
57
+ if (normalized.includes("\0"))
58
+ return false;
59
+ return true;
60
+ }
61
+ export function resolveWorkspaceRoot(config) {
62
+ return resolveAgentWorkspaceRoot(config, "admin");
63
+ }
64
+ export function handleMediaRequest(req, res, opts) {
65
+ const urlObj = new URL(req.url ?? "/", "http://localhost");
66
+ if (urlObj.pathname !== "/api/media")
67
+ return false;
68
+ if (req.method !== "GET" && req.method !== "HEAD") {
69
+ res.statusCode = 405;
70
+ res.setHeader("Allow", "GET, HEAD");
71
+ res.end();
72
+ return true;
73
+ }
74
+ const relPath = urlObj.searchParams.get("path") ?? "";
75
+ if (!relPath || !isSafeRelativePath(relPath)) {
76
+ res.statusCode = 404;
77
+ res.end("Not Found");
78
+ return true;
79
+ }
80
+ const ext = path.extname(relPath).toLowerCase();
81
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
82
+ res.statusCode = 403;
83
+ res.end("Forbidden");
84
+ return true;
85
+ }
86
+ const workspaceRoot = resolveWorkspaceRoot(opts.config);
87
+ const filePath = path.resolve(workspaceRoot, relPath);
88
+ // Boundary check: must stay within workspace
89
+ if (!filePath.startsWith(workspaceRoot + path.sep) && filePath !== workspaceRoot) {
90
+ res.statusCode = 403;
91
+ res.end("Forbidden");
92
+ return true;
93
+ }
94
+ let stat;
95
+ try {
96
+ stat = fs.statSync(filePath);
97
+ }
98
+ catch {
99
+ res.statusCode = 404;
100
+ res.end("Not Found");
101
+ return true;
102
+ }
103
+ if (!stat.isFile()) {
104
+ res.statusCode = 404;
105
+ res.end("Not Found");
106
+ return true;
107
+ }
108
+ res.statusCode = 200;
109
+ res.setHeader("Content-Type", contentType(ext));
110
+ res.setHeader("Content-Length", stat.size);
111
+ res.setHeader("Cache-Control", "private, max-age=86400");
112
+ if (req.method === "HEAD") {
113
+ res.end();
114
+ }
115
+ else {
116
+ const stream = fs.createReadStream(filePath);
117
+ stream.pipe(res);
118
+ }
119
+ return true;
120
+ }
@@ -36,7 +36,8 @@ import { requestOtp, verifyOtp } from "./public-chat/otp.js";
36
36
  import { deliverOtp } from "./public-chat/deliver-otp.js";
37
37
  import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
38
38
  import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
39
- import { stripBase64ImagesFromMessages, stripEnvelopeFromMessages } from "./chat-sanitize.js";
39
+ import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
40
+ import { resolveWorkspaceRoot } from "./media-http.js";
40
41
  import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
41
42
  // ---------------------------------------------------------------------------
42
43
  // Helpers
@@ -597,7 +598,7 @@ async function handleChatHistory(req, res) {
597
598
  sendInvalidRequest(res, "X-Session-Key header required");
598
599
  return;
599
600
  }
600
- const { storePath, entry } = loadSessionEntry(sessionKey);
601
+ const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
601
602
  const sessionId = entry?.sessionId;
602
603
  let rawMessages = [];
603
604
  if (entry && storePath) {
@@ -620,7 +621,8 @@ async function handleChatHistory(req, res) {
620
621
  const limitParam = url.searchParams.get("limit");
621
622
  const requested = limitParam ? Math.min(10_000, Math.max(1, Number(limitParam) || 5000)) : 5000;
622
623
  const messages = rawMessages.length > requested ? rawMessages.slice(-requested) : rawMessages;
623
- const sanitized = stripEnvelopeFromMessages(stripBase64ImagesFromMessages(messages));
624
+ const workspaceRoot = resolveWorkspaceRoot(cfg);
625
+ const sanitized = stripEnvelopeFromMessages(sanitizeMediaForChat(messages, workspaceRoot));
624
626
  sendJson(res, 200, {
625
627
  session_key: sessionKey,
626
628
  messages: sanitized,
@@ -13,6 +13,7 @@ import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalize
13
13
  import { applyHookMappings } from "./hooks-mapping.js";
14
14
  import { handleOpenAiHttpRequest } from "./openai-http.js";
15
15
  import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
16
+ import { handleMediaRequest } from "./media-http.js";
16
17
  import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
17
18
  function sendJson(res, status, body) {
18
19
  res.statusCode = status;
@@ -226,6 +227,8 @@ export function createGatewayHttpServer(opts) {
226
227
  }))
227
228
  return;
228
229
  }
230
+ if (handleMediaRequest(req, res, { config: configSnapshot }))
231
+ return;
229
232
  if (canvasHost) {
230
233
  if (await handleA2uiHttpRequest(req, res))
231
234
  return;
@@ -15,7 +15,8 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
15
15
  import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
16
16
  import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
17
17
  import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from "../session-utils.js";
18
- import { stripBase64ImagesFromMessages, stripEnvelopeFromMessages } from "../chat-sanitize.js";
18
+ import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "../chat-sanitize.js";
19
+ import { resolveWorkspaceRoot } from "../media-http.js";
19
20
  import { formatForLog } from "../ws-log.js";
20
21
  function resolveTranscriptPath(params) {
21
22
  const { sessionId, storePath, sessionFile } = params;
@@ -189,8 +190,9 @@ export const chatHandlers = {
189
190
  const requested = typeof limit === "number" ? limit : defaultLimit;
190
191
  const max = Math.min(hardMax, requested);
191
192
  const messages = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
192
- const withoutBase64 = stripBase64ImagesFromMessages(messages);
193
- const sanitized = preserveEnvelopes ? withoutBase64 : stripEnvelopeFromMessages(withoutBase64);
193
+ const workspaceRoot = resolveWorkspaceRoot(cfg);
194
+ const withMediaUrls = sanitizeMediaForChat(messages, workspaceRoot);
195
+ const sanitized = preserveEnvelopes ? withMediaUrls : stripEnvelopeFromMessages(withMediaUrls);
194
196
  // Diagnostic: log resolution details so we can trace "lost history" reports.
195
197
  const prevCount = entry?.previousSessions?.length ?? 0;
196
198
  context.logGateway.info(`chat.history: sessionKey=${sessionKey} resolvedSessionId=${sessionId ?? "none"} storePath=${storePath ?? "none"} entryExists=${!!entry} previousSessions=${prevCount} rawMessages=${rawMessages.length} sent=${sanitized.length}`);
@@ -0,0 +1,143 @@
1
+ import { describeFailoverError } from "../agents/failover-error.js";
2
+ import { getChannelPlugin } from "../channels/plugins/index.js";
3
+ import { createSubsystemLogger } from "../logging/subsystem.js";
4
+ import { deliverOutboundPayloads } from "./outbound/deliver.js";
5
+ const log = createSubsystemLogger("gateway/heartbeat-infra-alert");
6
+ const COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours
7
+ const CONSECUTIVE_FAILURE_THRESHOLD = 3;
8
+ const MESSAGES = {
9
+ auth: "Your AI assistant is offline because the API key is invalid or has expired. Open the control panel and go to Settings > API Keys to update it.",
10
+ billing: "Your AI assistant is offline because of a billing issue with the AI provider. Check your AI provider account.",
11
+ repeated: (n) => `Your AI assistant has failed to respond ${n} times in a row.`,
12
+ };
13
+ // In-memory cooldown map: category → last-sent timestamp.
14
+ // Resets on gateway restart — correct: admin may have restarted to fix the issue.
15
+ const cooldowns = new Map();
16
+ // Consecutive non-auth/billing failure counter. Resets when an auth/billing
17
+ // alert fires or when the heartbeat succeeds (caller responsibility via resetConsecutiveFailures).
18
+ let consecutiveUnknownFailures = 0;
19
+ function isCooledDown(category, nowMs) {
20
+ const lastSent = cooldowns.get(category);
21
+ if (typeof lastSent !== "number")
22
+ return false;
23
+ return nowMs - lastSent < COOLDOWN_MS;
24
+ }
25
+ function resolveAlertMessage(category, count) {
26
+ const template = MESSAGES[category];
27
+ if (typeof template === "function")
28
+ return template(count ?? 0);
29
+ return template;
30
+ }
31
+ /**
32
+ * Attempt to send an infra alert to the admin when a heartbeat fails due to
33
+ * auth, billing, or repeated unknown errors. Uses deliverOutboundPayloads
34
+ * directly — no agent involvement.
35
+ *
36
+ * Returns true if an alert was sent, false if suppressed or skipped.
37
+ * Never throws.
38
+ */
39
+ export async function maybeAlertAdmin(ctx) {
40
+ try {
41
+ const { cfg, delivery, err, deps } = ctx;
42
+ const nowMs = ctx.nowMs ?? Date.now();
43
+ // No delivery target — can't alert.
44
+ if (delivery.channel === "none" || !delivery.to)
45
+ return false;
46
+ const classified = describeFailoverError(err);
47
+ const reason = classified.reason;
48
+ // Auth or billing: send targeted alert.
49
+ if (reason === "auth" || reason === "billing") {
50
+ // Reset consecutive unknown failures — we have a specific diagnosis.
51
+ consecutiveUnknownFailures = 0;
52
+ const category = reason;
53
+ if (isCooledDown(category, nowMs)) {
54
+ log.debug("infra alert suppressed (cooldown)", { category });
55
+ return false;
56
+ }
57
+ // Check channel readiness before attempting delivery.
58
+ const plugin = getChannelPlugin(delivery.channel);
59
+ if (plugin?.heartbeat?.checkReady) {
60
+ const readiness = await plugin.heartbeat.checkReady({
61
+ cfg,
62
+ accountId: delivery.accountId,
63
+ deps,
64
+ });
65
+ if (!readiness.ok) {
66
+ log.info("infra alert skipped: channel not ready", {
67
+ category,
68
+ reason: readiness.reason,
69
+ });
70
+ return false;
71
+ }
72
+ }
73
+ const message = resolveAlertMessage(category);
74
+ await deliverOutboundPayloads({
75
+ cfg,
76
+ channel: delivery.channel,
77
+ to: delivery.to,
78
+ accountId: delivery.accountId,
79
+ payloads: [{ text: message }],
80
+ deps,
81
+ });
82
+ cooldowns.set(category, nowMs);
83
+ log.info("infra alert sent", { category, to: delivery.to });
84
+ return true;
85
+ }
86
+ // Non-auth/billing: track consecutive failures.
87
+ consecutiveUnknownFailures += 1;
88
+ if (consecutiveUnknownFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
89
+ const category = "repeated";
90
+ if (isCooledDown(category, nowMs)) {
91
+ log.debug("infra alert suppressed (cooldown)", { category });
92
+ return false;
93
+ }
94
+ const plugin = getChannelPlugin(delivery.channel);
95
+ if (plugin?.heartbeat?.checkReady) {
96
+ const readiness = await plugin.heartbeat.checkReady({
97
+ cfg,
98
+ accountId: delivery.accountId,
99
+ deps,
100
+ });
101
+ if (!readiness.ok) {
102
+ log.info("infra alert skipped: channel not ready", {
103
+ category,
104
+ reason: readiness.reason,
105
+ });
106
+ return false;
107
+ }
108
+ }
109
+ const message = resolveAlertMessage(category, consecutiveUnknownFailures);
110
+ await deliverOutboundPayloads({
111
+ cfg,
112
+ channel: delivery.channel,
113
+ to: delivery.to,
114
+ accountId: delivery.accountId,
115
+ payloads: [{ text: message }],
116
+ deps,
117
+ });
118
+ cooldowns.set(category, nowMs);
119
+ log.info("infra alert sent", {
120
+ category,
121
+ consecutiveFailures: consecutiveUnknownFailures,
122
+ to: delivery.to,
123
+ });
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+ catch (alertErr) {
129
+ log.error("infra alert delivery failed", {
130
+ error: alertErr instanceof Error ? alertErr.message : String(alertErr),
131
+ });
132
+ return false;
133
+ }
134
+ }
135
+ /** Reset consecutive unknown failure counter. Call when heartbeat succeeds. */
136
+ export function resetConsecutiveFailures() {
137
+ consecutiveUnknownFailures = 0;
138
+ }
139
+ /** Reset all cooldowns and counters. Exposed for testing. */
140
+ export function resetAlertCooldowns() {
141
+ cooldowns.clear();
142
+ consecutiveUnknownFailures = 0;
143
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveAgentConfig, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js";
4
+ import { describeFailoverError } from "../agents/failover-error.js";
4
5
  import { resolveUserTimezone } from "../agents/date-time.js";
5
6
  import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
6
7
  import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
@@ -19,6 +20,7 @@ import { defaultRuntime } from "../runtime.js";
19
20
  import { resolveAgentBoundAccountId } from "../routing/bindings.js";
20
21
  import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
21
22
  import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
23
+ import { maybeAlertAdmin, resetConsecutiveFailures } from "./heartbeat-infra-alert.js";
22
24
  import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
23
25
  import { requestHeartbeatNow, setHeartbeatWakeHandler, } from "./heartbeat-wake.js";
24
26
  import { deliverOutboundPayloads } from "./outbound/deliver.js";
@@ -418,6 +420,7 @@ export async function runHeartbeatOnce(opts) {
418
420
  };
419
421
  try {
420
422
  const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
423
+ resetConsecutiveFailures();
421
424
  const replyPayload = resolveHeartbeatReplyPayload(replyResult);
422
425
  const includeReasoning = heartbeat?.includeReasoning === true;
423
426
  const reasoningPayloads = includeReasoning
@@ -597,12 +600,22 @@ export async function runHeartbeatOnce(opts) {
597
600
  }
598
601
  catch (err) {
599
602
  const reason = formatErrorMessage(err);
603
+ const classified = describeFailoverError(err);
604
+ let infraAlertSent = false;
605
+ try {
606
+ infraAlertSent = await maybeAlertAdmin({ cfg, delivery, err, deps: opts.deps });
607
+ }
608
+ catch {
609
+ // Never let alert delivery crash the heartbeat runner.
610
+ }
600
611
  emitHeartbeatEvent({
601
612
  status: "failed",
602
613
  reason,
603
614
  durationMs: Date.now() - startedAt,
604
615
  channel: delivery.channel !== "none" ? delivery.channel : undefined,
605
616
  indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
617
+ failureReason: classified.reason,
618
+ infraAlertSent,
606
619
  });
607
620
  log.error(`heartbeat failed: ${reason}`, { error: reason });
608
621
  return { status: "failed", reason };
@@ -588,19 +588,26 @@ export class MemoryIndexManager {
588
588
  return { path: relPath, bytesWritten: Buffer.byteLength(params.content, "utf-8") };
589
589
  }
590
590
  /**
591
- * Save media (binary) to a user's memory folder with session-scoped access control.
592
- * Copies from a source path (e.g., inbound media) to the user's media folder.
591
+ * Save media (binary) to a memory folder with session-scoped access control.
592
+ * Copies from a source path (e.g., inbound media) to the destination folder.
593
+ * When destFolder is omitted, defaults to memory/users/{peer}/media/.
593
594
  * Returns the destination path relative to workspace.
594
595
  */
595
596
  async writeMedia(params) {
596
- // Resolve session context to get user folder
597
597
  const sessionCtx = params.sessionContext ?? parseSessionContext(params.sessionKey);
598
- const peer = sessionCtx.peer;
599
- if (!peer) {
600
- throw new Error("session context required (peer not found)");
598
+ let relPath;
599
+ if (params.destFolder) {
600
+ // Explicit folder use as-is (scope checking enforces access)
601
+ relPath = `${params.destFolder}/${params.destFilename}`;
602
+ }
603
+ else {
604
+ // Default: memory/users/{peer}/media/{filename}
605
+ const peer = sessionCtx.peer;
606
+ if (!peer) {
607
+ throw new Error("session context required (peer not found)");
608
+ }
609
+ relPath = `memory/users/${peer}/media/${params.destFilename}`;
601
610
  }
602
- // Build destination path: memory/users/{peer}/media/{filename}
603
- const relPath = `memory/users/${peer}/media/${params.destFilename}`;
604
611
  // Apply scope filtering before writing (uses "write" scope)
605
612
  const scope = this.settings.scope;
606
613
  if (scope) {
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