@rubytech/taskmaster 1.0.92 → 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-hWMGux19.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 +25 -8
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +5 -5
- package/dist/control-ui/assets/index-hWMGux19.js.map +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) {
|
|
@@ -931,6 +938,16 @@ export class MemoryIndexManager {
|
|
|
931
938
|
path.join(this.workspaceDir, "MEMORY.md"),
|
|
932
939
|
path.join(this.workspaceDir, "memory"),
|
|
933
940
|
];
|
|
941
|
+
// Also watch the shared canonical memory dir (workspace root memory/).
|
|
942
|
+
// On Linux (inotify), filesystem events do not fire through symlinks —
|
|
943
|
+
// the agent's memory/public (symlink) won't detect changes to the real
|
|
944
|
+
// ~/taskmaster/memory/public/. Watching the real target directory ensures
|
|
945
|
+
// file changes are detected regardless of platform symlink behaviour.
|
|
946
|
+
const workspaceRoot = this.workspaceDir.replace(/\/agents\/[^/]+$/, "");
|
|
947
|
+
if (workspaceRoot !== this.workspaceDir) {
|
|
948
|
+
const sharedMemoryDir = path.join(workspaceRoot, "memory");
|
|
949
|
+
watchPaths.push(sharedMemoryDir);
|
|
950
|
+
}
|
|
934
951
|
this.watcher = chokidar.watch(watchPaths, {
|
|
935
952
|
ignoreInitial: true,
|
|
936
953
|
awaitWriteFinish: {
|
package/package.json
CHANGED
|
@@ -122,7 +122,7 @@ WhatsApp is only needed if you want your assistant to handle customer messages o
|
|
|
122
122
|
3. Scan the QR code on screen
|
|
123
123
|
4. Wait for all status lights to turn green
|
|
124
124
|
|
|
125
|
-
> **Important:** If you see "Taskmaster" already in your Linked Devices, remove it first before scanning.
|
|
125
|
+
> **Important:** If you see "macOS" or "Taskmaster" already in your Linked Devices for Taskmaster's connection, remove it first before scanning. (Taskmaster appears as "macOS" in the Linked Devices list.)
|
|
126
126
|
|
|
127
127
|
> **Private use only?** If you want to use WhatsApp to chat with your assistant yourself but don't want customers to receive automated replies, make sure the **Public facing** toggle on the Setup page is turned off. This keeps WhatsApp connected for your own admin chat while the public assistant stays silent. See "Public-Facing Messages" below for details.
|
|
128
128
|
|
|
@@ -137,7 +137,7 @@ After setup, you'll see a navigation bar at the top of the screen linking to all
|
|
|
137
137
|
| **Setup** | Check connection status, reconnect services, link WhatsApp, manage API keys, toggle public messages |
|
|
138
138
|
| **Chat** | Talk to your admin assistant directly — the preferred way to manage your business |
|
|
139
139
|
| **Admins** | Choose which phone numbers get admin access (see "Two Assistants" below) |
|
|
140
|
-
| **
|
|
140
|
+
| **Contacts** | Manage verified contact records (payment status, account details) that your assistant can look up but not change |
|
|
141
141
|
| **Files** | Browse and manage your assistant's knowledge files and memory |
|
|
142
142
|
| **Browser** | See what the assistant sees when it browses the web for you |
|
|
143
143
|
| **Advanced** | View events (automated tasks), sessions, skills, and logs |
|
|
@@ -845,7 +845,7 @@ On a Raspberry Pi, you can also just unplug the device, wait 10 seconds, and plu
|
|
|
845
845
|
1. Select the **affected account** from the account dropdown at the top
|
|
846
846
|
2. Tap **"Reset WhatsApp"** (red button) for that account
|
|
847
847
|
3. **On your phone:** WhatsApp → Settings → Linked Devices
|
|
848
|
-
4. **Remove** "
|
|
848
|
+
4. **Remove** "macOS" (Taskmaster's connection) from the list for that number (if present)
|
|
849
849
|
5. **Scan** the new QR code
|
|
850
850
|
|
|
851
851
|
### WhatsApp linking keeps failing?
|
|
@@ -855,7 +855,7 @@ The old connection data may be corrupted. To start fresh:
|
|
|
855
855
|
1. Go to **http://taskmaster.local:18789/setup**
|
|
856
856
|
2. Tap **"Reset WhatsApp"** (red button)
|
|
857
857
|
3. **On your phone:** WhatsApp → Settings → Linked Devices
|
|
858
|
-
4. **Remove** "
|
|
858
|
+
4. **Remove** "macOS" (Taskmaster's connection) from the list if present
|
|
859
859
|
5. **Scan** the new QR code
|
|
860
860
|
|
|
861
861
|
### WhatsApp shows "linked" on phone but status is red?
|
|
@@ -863,7 +863,7 @@ The old connection data may be corrupted. To start fresh:
|
|
|
863
863
|
The phone and Taskmaster are out of sync. To fix:
|
|
864
864
|
|
|
865
865
|
1. **On your phone:** WhatsApp → Settings → Linked Devices
|
|
866
|
-
2. **Remove** "
|
|
866
|
+
2. **Remove** "macOS" (Taskmaster's connection) from the list
|
|
867
867
|
3. On the setup page, tap the circular arrow next to WhatsApp
|
|
868
868
|
4. **Scan** the new QR code
|
|
869
869
|
|