@rubytech/taskmaster 1.0.36 → 1.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auto-reply/reply/dispatch-from-config.js +53 -1
- package/dist/auto-reply/reply/get-reply-run.js +2 -1
- package/dist/browser/chrome.js +26 -3
- package/dist/build-info.json +3 -3
- package/dist/cli/provision-cli.js +1 -1
- package/dist/control-ui/assets/index-Ceb3FTmS.css +1 -0
- package/dist/control-ui/assets/index-gQeHDI6a.js +2890 -0
- package/dist/control-ui/assets/index-gQeHDI6a.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/chat-sanitize.js +24 -7
- package/dist/gateway/server-methods/chat.js +100 -26
- package/dist/gateway/server-methods/memory.js +30 -1
- package/dist/gateway/server-methods/system.js +18 -43
- package/dist/gateway/server-methods.js +2 -0
- package/dist/hooks/bundled/conversation-archive/handler.js +15 -2
- package/dist/memory/audit.js +86 -0
- package/dist/memory/manager.js +40 -1
- package/dist/memory/memory-schema.js +18 -0
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +10 -0
- package/templates/beagle/agents/admin/AGENTS.md +13 -0
- package/templates/customer/agents/admin/AGENTS.md +13 -0
- package/templates/taskmaster/agents/admin/AGENTS.md +23 -0
- package/templates/tradesupport/agents/admin/AGENTS.md +13 -0
- package/dist/control-ui/assets/index-BQEnHucA.css +0 -1
- package/dist/control-ui/assets/index-DgT0m3bj.js +0 -2856
- package/dist/control-ui/assets/index-DgT0m3bj.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-gQeHDI6a.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Ceb3FTmS.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<taskmaster-app></taskmaster-app>
|
|
@@ -15,6 +15,9 @@ const ENVELOPE_CHANNELS = [
|
|
|
15
15
|
"BlueBubbles",
|
|
16
16
|
];
|
|
17
17
|
const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
|
|
18
|
+
// Internal annotations prepended by buildInboundMediaNote / get-reply-run
|
|
19
|
+
const MEDIA_ATTACHED_LINE = /^\s*\[media attached(?:\s+\d+\/\d+)?:\s*[^\]]+\]\s*$/i;
|
|
20
|
+
const MEDIA_REPLY_HINT = /^\s*To send an image back, prefer the message tool\b/;
|
|
18
21
|
function looksLikeEnvelopeHeader(header) {
|
|
19
22
|
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header))
|
|
20
23
|
return true;
|
|
@@ -23,13 +26,16 @@ function looksLikeEnvelopeHeader(header) {
|
|
|
23
26
|
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
|
24
27
|
}
|
|
25
28
|
export function stripEnvelope(text) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
let result = text;
|
|
30
|
+
const match = result.match(ENVELOPE_PREFIX);
|
|
31
|
+
if (match) {
|
|
32
|
+
const header = match[1] ?? "";
|
|
33
|
+
if (looksLikeEnvelopeHeader(header)) {
|
|
34
|
+
result = result.slice(match[0].length);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
result = stripMediaAnnotations(result);
|
|
38
|
+
return result;
|
|
33
39
|
}
|
|
34
40
|
function stripMessageIdHints(text) {
|
|
35
41
|
if (!text.includes("[message_id:"))
|
|
@@ -38,6 +44,17 @@ function stripMessageIdHints(text) {
|
|
|
38
44
|
const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
|
|
39
45
|
return filtered.length === lines.length ? text : filtered.join("\n");
|
|
40
46
|
}
|
|
47
|
+
function stripMediaAnnotations(text) {
|
|
48
|
+
if (!text.includes("[media attached"))
|
|
49
|
+
return text;
|
|
50
|
+
const lines = text.split(/\r?\n/);
|
|
51
|
+
const filtered = lines.filter((line) => !MEDIA_ATTACHED_LINE.test(line) && !MEDIA_REPLY_HINT.test(line));
|
|
52
|
+
if (filtered.length === lines.length)
|
|
53
|
+
return text;
|
|
54
|
+
// Also strip the "[media attached: N files]" header line
|
|
55
|
+
const result = filtered.filter((line) => !/^\s*\[media attached:\s*\d+\s+files?\]\s*$/i.test(line));
|
|
56
|
+
return result.join("\n").trim();
|
|
57
|
+
}
|
|
41
58
|
function stripEnvelopeFromContent(content) {
|
|
42
59
|
let changed = false;
|
|
43
60
|
const next = content.map((item) => {
|
|
@@ -10,9 +10,9 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
|
|
10
10
|
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
|
11
11
|
import { extractShortModelName, } from "../../auto-reply/reply/response-prefix-template.js";
|
|
12
12
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
|
13
|
+
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
|
13
14
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
|
14
15
|
import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
|
|
15
|
-
import { parseMessageWithAttachments } from "../chat-attachments.js";
|
|
16
16
|
import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
|
|
17
17
|
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
|
|
18
18
|
import { capArrayByJsonBytes, loadSessionEntry, readSessionMessages, resolveSessionModelRef, } from "../session-utils.js";
|
|
@@ -250,35 +250,74 @@ export const chatHandlers = {
|
|
|
250
250
|
// Separate document attachments (PDFs, text files) from image attachments
|
|
251
251
|
const imageAttachments = normalizedAttachments.filter((a) => a.type !== "document");
|
|
252
252
|
const documentAttachments = normalizedAttachments.filter((a) => a.type === "document");
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const parsed = await parseMessageWithAttachments(p.message, imageAttachments, {
|
|
258
|
-
maxBytes: 5_000_000,
|
|
259
|
-
log: context.logGateway,
|
|
260
|
-
});
|
|
261
|
-
parsedMessage = parsed.message;
|
|
262
|
-
parsedImages = parsed.images;
|
|
263
|
-
}
|
|
264
|
-
catch (err) {
|
|
265
|
-
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
// Save document attachments to workspace uploads dir (persistent, accessible by agent)
|
|
270
|
-
const savedDocPaths = [];
|
|
271
|
-
if (documentAttachments.length > 0) {
|
|
253
|
+
// Resolve workspace uploads dir for all attachments (persistent, no TTL).
|
|
254
|
+
// Both images and documents are saved as plain files — same as every other channel.
|
|
255
|
+
let uploadsDir = null;
|
|
256
|
+
if (normalizedAttachments.length > 0) {
|
|
272
257
|
const { cfg: sessionCfg } = loadSessionEntry(p.sessionKey);
|
|
273
258
|
const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: sessionCfg });
|
|
274
259
|
const workspaceDir = resolveAgentWorkspaceDir(sessionCfg, agentId);
|
|
275
|
-
|
|
260
|
+
uploadsDir = path.join(workspaceDir, "uploads");
|
|
276
261
|
try {
|
|
277
262
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
278
263
|
}
|
|
279
264
|
catch {
|
|
280
265
|
/* ignore if exists */
|
|
281
266
|
}
|
|
267
|
+
}
|
|
268
|
+
// Save image attachments to workspace uploads dir (persistent, accessible by agent).
|
|
269
|
+
// The agent runner detects file path references via [media attached: ...] and
|
|
270
|
+
// loads them from disk at inference time — no inline base64 in transcripts.
|
|
271
|
+
const savedImagePaths = [];
|
|
272
|
+
const savedImageTypes = [];
|
|
273
|
+
if (imageAttachments.length > 0 && uploadsDir) {
|
|
274
|
+
for (const att of imageAttachments) {
|
|
275
|
+
if (!att.content || typeof att.content !== "string")
|
|
276
|
+
continue;
|
|
277
|
+
try {
|
|
278
|
+
let b64 = att.content.trim();
|
|
279
|
+
const dataUrlMatch = /^data:[^;]+;base64,(.*)$/.exec(b64);
|
|
280
|
+
if (dataUrlMatch)
|
|
281
|
+
b64 = dataUrlMatch[1];
|
|
282
|
+
const buffer = Buffer.from(b64, "base64");
|
|
283
|
+
// Derive extension from mime type
|
|
284
|
+
const mimeBase = att.mimeType?.split(";")[0]?.trim();
|
|
285
|
+
const extMap = {
|
|
286
|
+
"image/jpeg": ".jpg",
|
|
287
|
+
"image/png": ".png",
|
|
288
|
+
"image/gif": ".gif",
|
|
289
|
+
"image/webp": ".webp",
|
|
290
|
+
"image/heic": ".heic",
|
|
291
|
+
"image/heif": ".heif",
|
|
292
|
+
"image/svg+xml": ".svg",
|
|
293
|
+
"image/avif": ".avif",
|
|
294
|
+
};
|
|
295
|
+
const ext = (mimeBase && extMap[mimeBase]) ?? ".jpg";
|
|
296
|
+
const uuid = randomUUID();
|
|
297
|
+
let safeName;
|
|
298
|
+
if (att.fileName) {
|
|
299
|
+
const base = path
|
|
300
|
+
.parse(att.fileName)
|
|
301
|
+
.name.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
302
|
+
.slice(0, 60);
|
|
303
|
+
safeName = base ? `${base}---${uuid}${ext}` : `${uuid}${ext}`;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
safeName = `${uuid}${ext}`;
|
|
307
|
+
}
|
|
308
|
+
const destPath = path.join(uploadsDir, safeName);
|
|
309
|
+
fs.writeFileSync(destPath, buffer);
|
|
310
|
+
savedImagePaths.push(destPath);
|
|
311
|
+
savedImageTypes.push(mimeBase ?? "image/png");
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
context.logGateway.warn(`chat image save failed: ${String(err)}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Save document attachments to workspace uploads dir (persistent, accessible by agent)
|
|
319
|
+
const savedDocPaths = [];
|
|
320
|
+
if (documentAttachments.length > 0 && uploadsDir) {
|
|
282
321
|
for (const doc of documentAttachments) {
|
|
283
322
|
if (!doc.content || typeof doc.content !== "string")
|
|
284
323
|
continue;
|
|
@@ -354,14 +393,14 @@ export const chatHandlers = {
|
|
|
354
393
|
status: "started",
|
|
355
394
|
};
|
|
356
395
|
respond(true, ackPayload, undefined, { runId: clientRunId });
|
|
357
|
-
const trimmedMessage =
|
|
396
|
+
const trimmedMessage = p.message.trim();
|
|
358
397
|
const injectThinking = Boolean(p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"));
|
|
359
|
-
const commandBody = injectThinking ? `/think ${p.thinking} ${
|
|
398
|
+
const commandBody = injectThinking ? `/think ${p.thinking} ${p.message}` : p.message;
|
|
360
399
|
// If documents were saved, prepend file paths to message so the agent knows about them
|
|
361
400
|
const docNote = savedDocPaths.length > 0
|
|
362
401
|
? savedDocPaths.map((p) => `[file: ${p}]`).join("\n") + "\n\n"
|
|
363
402
|
: "";
|
|
364
|
-
const messageWithDocs = docNote +
|
|
403
|
+
const messageWithDocs = docNote + p.message;
|
|
365
404
|
const clientInfo = client?.connect?.client;
|
|
366
405
|
const ctx = {
|
|
367
406
|
Body: messageWithDocs,
|
|
@@ -379,11 +418,30 @@ export const chatHandlers = {
|
|
|
379
418
|
SenderId: clientInfo?.id,
|
|
380
419
|
SenderName: clientInfo?.displayName,
|
|
381
420
|
SenderUsername: clientInfo?.displayName,
|
|
421
|
+
// Image/media paths — same pattern as WhatsApp. buildInboundMediaNote()
|
|
422
|
+
// will generate [media attached: ...] annotations that the agent runner
|
|
423
|
+
// detects and loads from disk at inference time.
|
|
424
|
+
MediaPaths: savedImagePaths.length > 0 ? savedImagePaths : undefined,
|
|
425
|
+
MediaPath: savedImagePaths[0],
|
|
426
|
+
MediaTypes: savedImageTypes.length > 0 ? savedImageTypes : undefined,
|
|
427
|
+
MediaType: savedImageTypes[0],
|
|
382
428
|
};
|
|
383
429
|
const agentId = resolveSessionAgentId({
|
|
384
430
|
sessionKey: p.sessionKey,
|
|
385
431
|
config: cfg,
|
|
386
432
|
});
|
|
433
|
+
// Fire message:inbound hook for conversation archiving.
|
|
434
|
+
// Include image paths so the archive references the attached media.
|
|
435
|
+
const imageNote = savedImagePaths.length > 0 ? savedImagePaths.map((ip) => `[image: ${ip}]`).join("\n") : "";
|
|
436
|
+
const archiveText = [p.message, imageNote].filter(Boolean).join("\n").trim();
|
|
437
|
+
void triggerInternalHook(createInternalHookEvent("message", "inbound", p.sessionKey, {
|
|
438
|
+
text: archiveText || undefined,
|
|
439
|
+
timestamp: now,
|
|
440
|
+
chatType: "direct",
|
|
441
|
+
agentId,
|
|
442
|
+
channel: "webchat",
|
|
443
|
+
cfg,
|
|
444
|
+
}));
|
|
387
445
|
let prefixContext = {
|
|
388
446
|
identityName: resolveIdentityName(cfg, agentId),
|
|
389
447
|
};
|
|
@@ -419,6 +477,7 @@ export const chatHandlers = {
|
|
|
419
477
|
},
|
|
420
478
|
});
|
|
421
479
|
let agentRunStarted = false;
|
|
480
|
+
context.logGateway.info(`webchat dispatch: sessionKey=${p.sessionKey} runId=${clientRunId} body=${messageWithDocs.length}ch images=${savedImagePaths.length} docs=${savedDocPaths.length}`);
|
|
422
481
|
void dispatchInboundMessage({
|
|
423
482
|
ctx,
|
|
424
483
|
cfg,
|
|
@@ -426,10 +485,10 @@ export const chatHandlers = {
|
|
|
426
485
|
replyOptions: {
|
|
427
486
|
runId: clientRunId,
|
|
428
487
|
abortSignal: abortController.signal,
|
|
429
|
-
images: parsedImages.length > 0 ? parsedImages : undefined,
|
|
430
488
|
disableBlockStreaming: true,
|
|
431
|
-
onAgentRunStart: () => {
|
|
489
|
+
onAgentRunStart: (runId) => {
|
|
432
490
|
agentRunStarted = true;
|
|
491
|
+
context.logGateway.info(`webchat agent run started: sessionKey=${p.sessionKey} runId=${runId}`);
|
|
433
492
|
},
|
|
434
493
|
onModelSelected: (ctx) => {
|
|
435
494
|
prefixContext.provider = ctx.provider;
|
|
@@ -440,6 +499,8 @@ export const chatHandlers = {
|
|
|
440
499
|
},
|
|
441
500
|
})
|
|
442
501
|
.then(() => {
|
|
502
|
+
const { entry: postEntry } = loadSessionEntry(p.sessionKey);
|
|
503
|
+
context.logGateway.info(`webchat dispatch done: sessionKey=${p.sessionKey} agentRunStarted=${agentRunStarted} sessionId=${postEntry?.sessionId ?? "none"} sessionFile=${postEntry?.sessionFile ?? "none"}`);
|
|
443
504
|
if (!agentRunStarted) {
|
|
444
505
|
const combinedReply = finalReplyParts
|
|
445
506
|
.map((part) => part.trim())
|
|
@@ -479,6 +540,18 @@ export const chatHandlers = {
|
|
|
479
540
|
message,
|
|
480
541
|
});
|
|
481
542
|
}
|
|
543
|
+
// Fire message:outbound hook for conversation archiving
|
|
544
|
+
const outboundText = finalReplyParts.join("\n\n").trim();
|
|
545
|
+
if (outboundText) {
|
|
546
|
+
void triggerInternalHook(createInternalHookEvent("message", "outbound", p.sessionKey, {
|
|
547
|
+
text: outboundText,
|
|
548
|
+
timestamp: Date.now(),
|
|
549
|
+
chatType: "direct",
|
|
550
|
+
agentId,
|
|
551
|
+
channel: "webchat",
|
|
552
|
+
cfg,
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
482
555
|
context.dedupe.set(`chat:${clientRunId}`, {
|
|
483
556
|
ts: Date.now(),
|
|
484
557
|
ok: true,
|
|
@@ -486,6 +559,7 @@ export const chatHandlers = {
|
|
|
486
559
|
});
|
|
487
560
|
})
|
|
488
561
|
.catch((err) => {
|
|
562
|
+
context.logGateway.warn(`webchat dispatch failed: sessionKey=${p.sessionKey} runId=${clientRunId} error=${formatForLog(err)}`);
|
|
489
563
|
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
|
490
564
|
context.dedupe.set(`chat:${clientRunId}`, {
|
|
491
565
|
ts: Date.now(),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
1
|
+
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
2
2
|
import { loadConfig } from "../../config/config.js";
|
|
3
|
+
import { clearAuditEntries, getUnreviewedEntries } from "../../memory/audit.js";
|
|
3
4
|
import { getMemorySearchManager } from "../../memory/index.js";
|
|
4
5
|
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
5
6
|
export const memoryHandlers = {
|
|
@@ -59,4 +60,32 @@ export const memoryHandlers = {
|
|
|
59
60
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
60
61
|
}
|
|
61
62
|
},
|
|
63
|
+
"memory.audit": async ({ params, respond }) => {
|
|
64
|
+
try {
|
|
65
|
+
const cfg = loadConfig();
|
|
66
|
+
const agentId = typeof params.agentId === "string" && params.agentId.trim()
|
|
67
|
+
? params.agentId.trim()
|
|
68
|
+
: resolveDefaultAgentId(cfg);
|
|
69
|
+
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
70
|
+
const entries = getUnreviewedEntries(workspaceDir);
|
|
71
|
+
respond(true, { ok: true, entries });
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"memory.auditClear": async ({ params, respond }) => {
|
|
78
|
+
try {
|
|
79
|
+
const cfg = loadConfig();
|
|
80
|
+
const agentId = typeof params.agentId === "string" && params.agentId.trim()
|
|
81
|
+
? params.agentId.trim()
|
|
82
|
+
: resolveDefaultAgentId(cfg);
|
|
83
|
+
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
84
|
+
const result = clearAuditEntries(workspaceDir);
|
|
85
|
+
respond(true, { ok: true, cleared: result.cleared });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
62
91
|
};
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
|
|
2
|
-
import { uninstallCommand } from "../../commands/uninstall.js";
|
|
3
|
-
import { resolveGatewayService } from "../../daemon/service.js";
|
|
4
|
-
import { defaultRuntime } from "../../runtime.js";
|
|
5
3
|
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
|
|
6
4
|
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
|
|
7
5
|
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
|
|
@@ -124,46 +122,23 @@ export const systemHandlers = {
|
|
|
124
122
|
},
|
|
125
123
|
"system.uninstall": async ({ params, respond, context }) => {
|
|
126
124
|
const purge = params.purge === true;
|
|
127
|
-
const validScopes = new Set(["service", "state", "workspace", "app"]);
|
|
128
|
-
const scopes = Array.isArray(params.scopes)
|
|
129
|
-
? params.scopes.filter((s) => validScopes.has(s))
|
|
130
|
-
: ["service", "state", "workspace"];
|
|
131
125
|
const log = context.logGateway;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
catch (err) {
|
|
152
|
-
log.error(`system.uninstall failed: ${String(err)}`);
|
|
153
|
-
}
|
|
154
|
-
// Uninstall the service files without stopping — we ARE the service.
|
|
155
|
-
// process.exit below handles the actual stop.
|
|
156
|
-
if (scopes.includes("service")) {
|
|
157
|
-
try {
|
|
158
|
-
const service = resolveGatewayService();
|
|
159
|
-
await service.uninstall({ env: process.env, stdout: process.stdout });
|
|
160
|
-
log.info("Gateway service uninstalled");
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
log.error(`Service uninstall failed: ${String(err)}`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
log.info("system.uninstall complete, exiting gateway");
|
|
167
|
-
process.exit(0);
|
|
126
|
+
// Resolve the taskmaster binary path — same binary that's running the gateway
|
|
127
|
+
const bin = process.argv[1] ?? "taskmaster";
|
|
128
|
+
const args = ["uninstall", "--all", "--yes"];
|
|
129
|
+
if (purge)
|
|
130
|
+
args.push("--purge");
|
|
131
|
+
log.info(`system.uninstall: spawning detached: ${bin} ${args.join(" ")}`);
|
|
132
|
+
// Spawn the uninstall as a detached process that outlives the gateway.
|
|
133
|
+
// The CLI command will stop the gateway service, remove all data, and
|
|
134
|
+
// optionally remove the npm package — all from a separate process so
|
|
135
|
+
// there's no self-SIGTERM problem.
|
|
136
|
+
const child = spawn(process.execPath, [bin, ...args], {
|
|
137
|
+
detached: true,
|
|
138
|
+
stdio: "ignore",
|
|
139
|
+
env: { ...process.env },
|
|
140
|
+
});
|
|
141
|
+
child.unref();
|
|
142
|
+
respond(true, { ok: true, pid: child.pid }, undefined);
|
|
168
143
|
},
|
|
169
144
|
};
|
|
@@ -90,6 +90,7 @@ const READ_METHODS = new Set([
|
|
|
90
90
|
"workspaces.list",
|
|
91
91
|
"workspaces.scan",
|
|
92
92
|
"memory.status",
|
|
93
|
+
"memory.audit",
|
|
93
94
|
]);
|
|
94
95
|
const WRITE_METHODS = new Set([
|
|
95
96
|
"send",
|
|
@@ -176,6 +177,7 @@ function authorizeGatewayMethod(method, client) {
|
|
|
176
177
|
method === "workspaces.create" ||
|
|
177
178
|
method === "workspaces.remove" ||
|
|
178
179
|
method === "memory.reindex" ||
|
|
180
|
+
method === "memory.auditClear" ||
|
|
179
181
|
method === "system.uninstall") {
|
|
180
182
|
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
|
|
181
183
|
}
|
|
@@ -31,6 +31,13 @@ function extractPeerFromSessionKey(sessionKey) {
|
|
|
31
31
|
}
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Detect webchat session key format: agent:{agentId}:main
|
|
36
|
+
*/
|
|
37
|
+
function isWebchatSessionKey(sessionKey) {
|
|
38
|
+
const parts = sessionKey.toLowerCase().split(":").filter(Boolean);
|
|
39
|
+
return parts.length === 3 && parts[0] === "agent" && parts[2] === "main";
|
|
40
|
+
}
|
|
34
41
|
/**
|
|
35
42
|
* Extract group ID from session key
|
|
36
43
|
*
|
|
@@ -148,9 +155,10 @@ const archiveConversation = async (event) => {
|
|
|
148
155
|
}
|
|
149
156
|
// Get timestamp from context or event
|
|
150
157
|
const timestamp = context.timestamp ?? event.timestamp;
|
|
151
|
-
//
|
|
158
|
+
// Determine conversation type from session key and route to correct archive path
|
|
152
159
|
const peer = extractPeerFromSessionKey(event.sessionKey);
|
|
153
160
|
const groupId = peer ? null : extractGroupIdFromSessionKey(event.sessionKey);
|
|
161
|
+
const isWebchat = !peer && !groupId && isWebchatSessionKey(event.sessionKey);
|
|
154
162
|
if (peer) {
|
|
155
163
|
// Admin DMs archive to memory/admin/conversations/ (not accessible by public agent).
|
|
156
164
|
// Public DMs archive to memory/users/{peer}/conversations/.
|
|
@@ -187,8 +195,13 @@ const archiveConversation = async (event) => {
|
|
|
187
195
|
fileHeader,
|
|
188
196
|
});
|
|
189
197
|
}
|
|
198
|
+
else if (isWebchat) {
|
|
199
|
+
// Webchat (control panel) — archive under memory/admin/conversations/
|
|
200
|
+
const role = event.action === "inbound" ? "Admin" : "Assistant";
|
|
201
|
+
await archiveMessage({ workspaceDir, subdir: "admin", role, text, timestamp });
|
|
202
|
+
}
|
|
190
203
|
else {
|
|
191
|
-
//
|
|
204
|
+
// Unknown session key format — skip
|
|
192
205
|
return;
|
|
193
206
|
}
|
|
194
207
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory write audit trail.
|
|
3
|
+
*
|
|
4
|
+
* Tracks writes to shared/ and public/ memory folders so the business owner
|
|
5
|
+
* can review what the agent stored in externally-visible locations.
|
|
6
|
+
*
|
|
7
|
+
* Storage: JSON file at {workspaceDir}/.memory-audit.json
|
|
8
|
+
* Not placed inside memory/ to avoid being indexed by the memory search system.
|
|
9
|
+
*
|
|
10
|
+
* Integration: Called from syncMemoryFiles() in the memory manager, right after
|
|
11
|
+
* the "file updated/added" log line. Runs after the watcher detects changes and
|
|
12
|
+
* during the sync pass — no interference with upstream processing.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
const AUDIT_FILENAME = ".memory-audit.json";
|
|
17
|
+
/** Paths that are excluded from audit — expected operational writes. */
|
|
18
|
+
const EXCLUDED_PREFIXES = ["memory/shared/events/"];
|
|
19
|
+
/**
|
|
20
|
+
* Returns true if a memory write path should be audited.
|
|
21
|
+
* Auditable paths: memory/shared/** and memory/public/** (excluding exemptions).
|
|
22
|
+
*/
|
|
23
|
+
export function isAuditablePath(relPath) {
|
|
24
|
+
const normalized = relPath.replace(/\\/g, "/").toLowerCase();
|
|
25
|
+
if (!normalized.startsWith("memory/shared/") && !normalized.startsWith("memory/public/")) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
for (const prefix of EXCLUDED_PREFIXES) {
|
|
29
|
+
if (normalized.startsWith(prefix))
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
function auditFilePath(workspaceDir) {
|
|
35
|
+
return path.join(workspaceDir, AUDIT_FILENAME);
|
|
36
|
+
}
|
|
37
|
+
function readAuditFile(workspaceDir) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(auditFilePath(workspaceDir), "utf-8");
|
|
40
|
+
const data = JSON.parse(raw);
|
|
41
|
+
return {
|
|
42
|
+
entries: Array.isArray(data.entries) ? data.entries : [],
|
|
43
|
+
lastReviewedAt: typeof data.lastReviewedAt === "number" ? data.lastReviewedAt : 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { entries: [], lastReviewedAt: 0 };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeAuditFile(workspaceDir, data) {
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(auditFilePath(workspaceDir), JSON.stringify(data, null, 2), "utf-8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Audit is best-effort — don't fail writes over audit persistence
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Record an audit entry for a memory write.
|
|
60
|
+
*/
|
|
61
|
+
export function recordAuditEntry(workspaceDir, entry) {
|
|
62
|
+
const audit = readAuditFile(workspaceDir);
|
|
63
|
+
audit.entries.push(entry);
|
|
64
|
+
// Cap at 500 entries to prevent unbounded growth.
|
|
65
|
+
if (audit.entries.length > 500) {
|
|
66
|
+
audit.entries = audit.entries.slice(-500);
|
|
67
|
+
}
|
|
68
|
+
writeAuditFile(workspaceDir, audit);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get unreviewed audit entries (entries added after the last review).
|
|
72
|
+
*/
|
|
73
|
+
export function getUnreviewedEntries(workspaceDir) {
|
|
74
|
+
const audit = readAuditFile(workspaceDir);
|
|
75
|
+
return audit.entries.filter((e) => e.timestamp > audit.lastReviewedAt);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Mark all current entries as reviewed.
|
|
79
|
+
*/
|
|
80
|
+
export function clearAuditEntries(workspaceDir) {
|
|
81
|
+
const audit = readAuditFile(workspaceDir);
|
|
82
|
+
const unreviewed = audit.entries.filter((e) => e.timestamp > audit.lastReviewedAt);
|
|
83
|
+
audit.lastReviewedAt = Date.now();
|
|
84
|
+
writeAuditFile(workspaceDir, audit);
|
|
85
|
+
return { cleared: unreviewed.length };
|
|
86
|
+
}
|
package/dist/memory/manager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fsSync from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import chokidar from "chokidar";
|
|
@@ -9,6 +10,7 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.j
|
|
|
9
10
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
10
11
|
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
|
11
12
|
import { resolveUserPath } from "../utils.js";
|
|
13
|
+
import { isAuditablePath, recordAuditEntry } from "./audit.js";
|
|
12
14
|
import { createEmbeddingProvider, } from "./embeddings.js";
|
|
13
15
|
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
|
|
14
16
|
import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js";
|
|
@@ -322,7 +324,25 @@ export class MemoryIndexManager {
|
|
|
322
324
|
maxEntries: params.settings.cache.maxEntries,
|
|
323
325
|
};
|
|
324
326
|
this.fts = { enabled: params.settings.query.hybrid.enabled, available: false };
|
|
325
|
-
|
|
327
|
+
try {
|
|
328
|
+
this.ensureSchema();
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
// Database is corrupted (e.g. orphaned FTS5 metadata blocking writes).
|
|
332
|
+
// Delete and rebuild from scratch.
|
|
333
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
+
log.warn(`schema init failed, rebuilding database: ${msg}`);
|
|
335
|
+
try {
|
|
336
|
+
this.db.close();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
const dbPath = resolveUserPath(this.settings.store.path);
|
|
342
|
+
this.removeIndexFilesSync(dbPath);
|
|
343
|
+
this.db = this.openDatabase();
|
|
344
|
+
this.ensureSchema();
|
|
345
|
+
}
|
|
326
346
|
this.vector = {
|
|
327
347
|
enabled: params.settings.store.vector.enabled,
|
|
328
348
|
available: null,
|
|
@@ -534,6 +554,7 @@ export class MemoryIndexManager {
|
|
|
534
554
|
}
|
|
535
555
|
// Mark memory as dirty so it gets re-indexed
|
|
536
556
|
this.dirty = true;
|
|
557
|
+
// Audit trail is recorded in syncMemoryFiles after the watcher detects changes
|
|
537
558
|
return { path: relPath, bytesWritten: Buffer.byteLength(params.content, "utf-8") };
|
|
538
559
|
}
|
|
539
560
|
/**
|
|
@@ -846,6 +867,17 @@ export class MemoryIndexManager {
|
|
|
846
867
|
const suffixes = ["", "-wal", "-shm"];
|
|
847
868
|
await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
|
|
848
869
|
}
|
|
870
|
+
removeIndexFilesSync(basePath) {
|
|
871
|
+
const suffixes = ["", "-wal", "-shm"];
|
|
872
|
+
for (const suffix of suffixes) {
|
|
873
|
+
try {
|
|
874
|
+
fsSync.rmSync(`${basePath}${suffix}`, { force: true });
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
/* ignore */
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
849
881
|
ensureSchema() {
|
|
850
882
|
const result = ensureMemoryIndexSchema({
|
|
851
883
|
db: this.db,
|
|
@@ -1137,6 +1169,13 @@ export class MemoryIndexManager {
|
|
|
1137
1169
|
}
|
|
1138
1170
|
const action = record ? "updated" : "added";
|
|
1139
1171
|
log.info(`file ${action} (${this.agentId}): ${entry.path}`);
|
|
1172
|
+
if (isAuditablePath(entry.path)) {
|
|
1173
|
+
recordAuditEntry(this.workspaceDir, {
|
|
1174
|
+
path: entry.path,
|
|
1175
|
+
timestamp: Date.now(),
|
|
1176
|
+
agentId: this.agentId,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1140
1179
|
await this.indexFile(entry, { source: "memory" });
|
|
1141
1180
|
if (params.progress) {
|
|
1142
1181
|
params.progress.completed += 1;
|
|
@@ -64,6 +64,9 @@ export function ensureMemoryIndexSchema(params) {
|
|
|
64
64
|
// Leaving them in the DB can cause "no such module: fts5" errors on
|
|
65
65
|
// unrelated operations if SQLite touches the virtual table machinery.
|
|
66
66
|
dropOrphanedFtsTables(params.db, params.ftsTable);
|
|
67
|
+
// Verify the database is still writable after cleanup. FTS5 virtual table
|
|
68
|
+
// metadata left in sqlite_master can corrupt the database for all writes.
|
|
69
|
+
verifyWritable(params.db);
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
ensureColumn(params.db, "files", "source", "TEXT NOT NULL DEFAULT 'memory'");
|
|
@@ -72,6 +75,21 @@ export function ensureMemoryIndexSchema(params) {
|
|
|
72
75
|
params.db.exec(`CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);`);
|
|
73
76
|
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
|
|
74
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Verify the database is writable by performing a test write.
|
|
80
|
+
* If FTS5 cleanup left orphaned virtual table metadata in sqlite_master,
|
|
81
|
+
* SQLite may refuse all writes. Throws so the caller can delete and rebuild.
|
|
82
|
+
*/
|
|
83
|
+
function verifyWritable(db) {
|
|
84
|
+
try {
|
|
85
|
+
db.prepare(`INSERT INTO meta (key, value) VALUES ('_write_check', '1') ON CONFLICT(key) DO UPDATE SET value = '1'`).run();
|
|
86
|
+
db.prepare(`DELETE FROM meta WHERE key = '_write_check'`).run();
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
throw new Error(`database not writable after FTS cleanup (needs rebuild): ${msg}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
75
93
|
/**
|
|
76
94
|
* When fts5 module is unavailable but the virtual table and its shadow tables
|
|
77
95
|
* exist from a previous run, drop them to prevent "no such module" errors.
|