@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.
- package/dist/agents/taskmaster-tools.js +4 -4
- package/dist/agents/tool-policy.js +2 -2
- package/dist/agents/tools/contact-lookup-tool.js +45 -0
- package/dist/agents/tools/contact-update-tool.js +68 -0
- package/dist/agents/tools/memory-tool.js +10 -3
- package/dist/build-info.json +3 -3
- package/dist/cli/provision-seed.js +2 -2
- package/dist/control-ui/assets/{index-B7exVNNa.css → index-BWth4bv5.css} +1 -1
- package/dist/control-ui/assets/{index-DfQL37PU.js → index-Bem0-Ojd.js} +220 -220
- package/dist/control-ui/assets/index-Bem0-Ojd.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/chat-sanitize.js +121 -5
- package/dist/gateway/media-http.js +120 -0
- package/dist/gateway/public-chat-api.js +5 -3
- package/dist/gateway/server-http.js +3 -0
- package/dist/gateway/server-methods/chat.js +5 -3
- package/dist/infra/heartbeat-infra-alert.js +143 -0
- package/dist/infra/heartbeat-runner.js +13 -0
- package/dist/memory/manager.js +15 -8
- package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
- package/extensions/googlechat/node_modules/.bin/taskmaster +0 -0
- package/extensions/line/node_modules/.bin/taskmaster +0 -0
- package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
- package/extensions/matrix/node_modules/.bin/taskmaster +0 -0
- package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
- package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
- package/extensions/msteams/node_modules/.bin/taskmaster +0 -0
- package/extensions/nostr/node_modules/.bin/taskmaster +0 -0
- package/extensions/nostr/node_modules/.bin/tsc +0 -0
- package/extensions/nostr/node_modules/.bin/tsserver +0 -0
- package/extensions/zalo/node_modules/.bin/taskmaster +0 -0
- package/extensions/zalouser/node_modules/.bin/taskmaster +0 -0
- package/package.json +64 -54
- package/scripts/install.sh +0 -0
- package/taskmaster-docs/USER-GUIDE.md +1 -1
- package/dist/control-ui/assets/index-DfQL37PU.js.map +0 -1
- package/templates/.DS_Store +0 -0
- package/templates/customer/.DS_Store +0 -0
- package/templates/customer/agents/.DS_Store +0 -0
- 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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
193
|
-
const
|
|
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 };
|
package/dist/memory/manager.js
CHANGED
|
@@ -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
|
|
592
|
-
* Copies from a source path (e.g., inbound media) to the
|
|
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
|
-
|
|
599
|
-
if (
|
|
600
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|