@rubytech/taskmaster 1.0.64 → 1.0.65
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/pi-embedded-runner/history.js +19 -1
- package/dist/agents/pi-embedded-runner/run/attempt.js +23 -5
- package/dist/agents/pi-embedded-runner/run.js +6 -31
- package/dist/agents/pi-embedded-runner.js +1 -1
- package/dist/agents/system-prompt.js +20 -0
- package/dist/agents/taskmaster-tools.js +4 -0
- package/dist/agents/tool-policy.js +2 -0
- package/dist/agents/tools/message-history-tool.js +436 -0
- package/dist/agents/tools/sessions-history-tool.js +1 -0
- package/dist/build-info.json +3 -3
- package/dist/config/zod-schema.js +10 -0
- package/dist/control-ui/assets/index-DmifehTc.css +1 -0
- package/dist/control-ui/assets/index-o5Xs9S4u.js +3166 -0
- package/dist/control-ui/assets/index-o5Xs9S4u.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/config-reload.js +1 -0
- package/dist/gateway/control-ui.js +173 -0
- package/dist/gateway/net.js +16 -0
- package/dist/gateway/protocol/client-info.js +1 -0
- package/dist/gateway/protocol/schema/logs-chat.js +3 -0
- package/dist/gateway/protocol/schema/sessions-transcript.js +1 -3
- package/dist/gateway/public-chat/deliver-otp.js +9 -0
- package/dist/gateway/public-chat/otp.js +60 -0
- package/dist/gateway/public-chat/session.js +45 -0
- package/dist/gateway/server/ws-connection/message-handler.js +17 -4
- package/dist/gateway/server-chat.js +22 -0
- package/dist/gateway/server-http.js +21 -3
- package/dist/gateway/server-methods/chat.js +38 -5
- package/dist/gateway/server-methods/public-chat.js +110 -0
- package/dist/gateway/server-methods/sessions-transcript.js +29 -46
- package/dist/gateway/server-methods.js +17 -0
- package/dist/hooks/bundled/conversation-archive/handler.js +23 -6
- package/dist/plugins/runtime/index.js +2 -0
- package/dist/utils/message-channel.js +3 -0
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +185 -5
- package/dist/control-ui/assets/index-BPvR6pln.js +0 -3021
- package/dist/control-ui/assets/index-BPvR6pln.js.map +0 -1
- package/dist/control-ui/assets/index-mweBpmCT.css +0 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC handlers for public chat: OTP verification and session resolution.
|
|
3
|
+
*/
|
|
4
|
+
import { loadConfig } from "../../config/config.js";
|
|
5
|
+
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
6
|
+
import { requestOtp, verifyOtp } from "../public-chat/otp.js";
|
|
7
|
+
import { deliverOtp } from "../public-chat/deliver-otp.js";
|
|
8
|
+
import { buildPublicSessionKey, resolvePublicAgentId } from "../public-chat/session.js";
|
|
9
|
+
/** Minimal phone format check: starts with +, digits only, 7–15 digits. */
|
|
10
|
+
function isValidPhone(phone) {
|
|
11
|
+
return /^\+\d{7,15}$/.test(phone);
|
|
12
|
+
}
|
|
13
|
+
export const publicChatHandlers = {
|
|
14
|
+
/**
|
|
15
|
+
* Request an OTP code — sends a 6-digit code to the given phone via WhatsApp.
|
|
16
|
+
* Params: { phone: string }
|
|
17
|
+
*/
|
|
18
|
+
"public.otp.request": async ({ params, respond, context }) => {
|
|
19
|
+
const phone = typeof params.phone === "string" ? params.phone.trim() : "";
|
|
20
|
+
if (!phone || !isValidPhone(phone)) {
|
|
21
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
if (!cfg.publicChat?.enabled) {
|
|
26
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const result = requestOtp(phone);
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
respond(false, { retryAfterMs: result.retryAfterMs }, errorShape(ErrorCodes.INVALID_REQUEST, "rate limited — try again shortly"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await deliverOtp(phone, result.code);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
context.logGateway.warn(`public-chat OTP delivery failed: ${String(err)}`);
|
|
39
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to send verification code"));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
respond(true, { ok: true });
|
|
43
|
+
},
|
|
44
|
+
/**
|
|
45
|
+
* Verify an OTP code and return the session key.
|
|
46
|
+
* Params: { phone: string, code: string, name?: string }
|
|
47
|
+
*/
|
|
48
|
+
"public.otp.verify": async ({ params, respond }) => {
|
|
49
|
+
const phone = typeof params.phone === "string" ? params.phone.trim() : "";
|
|
50
|
+
const code = typeof params.code === "string" ? params.code.trim() : "";
|
|
51
|
+
const name = typeof params.name === "string" ? params.name.trim() : undefined;
|
|
52
|
+
if (!phone || !isValidPhone(phone)) {
|
|
53
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!code) {
|
|
57
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "code required"));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
if (!cfg.publicChat?.enabled) {
|
|
62
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const result = verifyOtp(phone, code);
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
const messages = {
|
|
68
|
+
not_found: "no pending verification for this number",
|
|
69
|
+
expired: "verification code expired",
|
|
70
|
+
max_attempts: "too many attempts — request a new code",
|
|
71
|
+
invalid: "incorrect code",
|
|
72
|
+
};
|
|
73
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, messages[result.error] ?? "verification failed"));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const agentId = resolvePublicAgentId(cfg);
|
|
77
|
+
const sessionKey = buildPublicSessionKey(agentId, phone);
|
|
78
|
+
respond(true, {
|
|
79
|
+
ok: true,
|
|
80
|
+
sessionKey,
|
|
81
|
+
agentId,
|
|
82
|
+
phone,
|
|
83
|
+
name,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a session key for anonymous public chat.
|
|
88
|
+
* Params: { cookieId: string }
|
|
89
|
+
*/
|
|
90
|
+
"public.session": async ({ params, respond }) => {
|
|
91
|
+
const cookieId = typeof params.cookieId === "string" ? params.cookieId.trim() : "";
|
|
92
|
+
if (!cookieId || cookieId.length < 8 || cookieId.length > 128) {
|
|
93
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cookieId"));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const cfg = loadConfig();
|
|
97
|
+
if (!cfg.publicChat?.enabled) {
|
|
98
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const agentId = resolvePublicAgentId(cfg);
|
|
102
|
+
const identifier = `anon-${cookieId}`;
|
|
103
|
+
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
104
|
+
respond(true, {
|
|
105
|
+
ok: true,
|
|
106
|
+
sessionKey,
|
|
107
|
+
agentId,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { loadConfig } from "../../config/config.js";
|
|
3
3
|
import { ErrorCodes, errorShape, formatValidationErrors, validateSessionsTranscriptParams, } from "../protocol/index.js";
|
|
4
|
-
import { loadCombinedSessionStoreForGateway, resolveSessionTranscriptCandidates, } from "../session-utils.js";
|
|
4
|
+
import { listAgentsForGateway, loadCombinedSessionStoreForGateway, resolveSessionTranscriptCandidates, } from "../session-utils.js";
|
|
5
5
|
const DEFAULT_LIMIT = 200;
|
|
6
|
-
const DEFAULT_MAX_BYTES_PER_FILE = 128_000;
|
|
7
|
-
const CONTENT_MAX_CHARS = 2048;
|
|
8
|
-
const MAX_SESSIONS = 20;
|
|
9
|
-
const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
10
6
|
function extractAgentId(sessionKey) {
|
|
11
7
|
// Keys look like "agent:public:dm:+447..." — second segment is the agent ID.
|
|
12
8
|
const parts = sessionKey.split(":");
|
|
13
9
|
return parts.length >= 2 ? parts[1] : "unknown";
|
|
14
10
|
}
|
|
15
|
-
function truncate(text, max) {
|
|
16
|
-
if (text.length <= max)
|
|
17
|
-
return text;
|
|
18
|
-
return text.slice(0, max);
|
|
19
|
-
}
|
|
20
11
|
function resolveTimestamp(entry, fileMtimeMs) {
|
|
21
12
|
if (typeof entry.timestamp === "string" && entry.timestamp)
|
|
22
13
|
return entry.timestamp;
|
|
@@ -39,7 +30,7 @@ function extractTextFromContentBlocks(blocks) {
|
|
|
39
30
|
}
|
|
40
31
|
return parts.join("\n");
|
|
41
32
|
}
|
|
42
|
-
function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs
|
|
33
|
+
function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs) {
|
|
43
34
|
const entries = [];
|
|
44
35
|
const ts = resolveTimestamp(line, fileMtimeMs);
|
|
45
36
|
const model = line.message?.model;
|
|
@@ -57,7 +48,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
57
48
|
agentId,
|
|
58
49
|
timestamp: ts,
|
|
59
50
|
type: "error",
|
|
60
|
-
content
|
|
51
|
+
content,
|
|
61
52
|
...(model ? { model } : {}),
|
|
62
53
|
});
|
|
63
54
|
return entries;
|
|
@@ -74,7 +65,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
74
65
|
agentId,
|
|
75
66
|
timestamp: ts,
|
|
76
67
|
type: "tool",
|
|
77
|
-
content
|
|
68
|
+
content,
|
|
78
69
|
...(line.toolName ? { toolName: line.toolName } : {}),
|
|
79
70
|
...(model ? { model } : {}),
|
|
80
71
|
});
|
|
@@ -93,7 +84,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
93
84
|
agentId,
|
|
94
85
|
timestamp: ts,
|
|
95
86
|
type: entryType,
|
|
96
|
-
content:
|
|
87
|
+
content: msg.content,
|
|
97
88
|
...(model ? { model } : {}),
|
|
98
89
|
});
|
|
99
90
|
return entries;
|
|
@@ -113,7 +104,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
113
104
|
agentId,
|
|
114
105
|
timestamp: ts,
|
|
115
106
|
type: "thinking",
|
|
116
|
-
content:
|
|
107
|
+
content: text,
|
|
117
108
|
...(model ? { model } : {}),
|
|
118
109
|
});
|
|
119
110
|
}
|
|
@@ -127,7 +118,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
127
118
|
agentId,
|
|
128
119
|
timestamp: ts,
|
|
129
120
|
type: "tool",
|
|
130
|
-
content
|
|
121
|
+
content,
|
|
131
122
|
...(toolName ? { toolName } : {}),
|
|
132
123
|
...(model ? { model } : {}),
|
|
133
124
|
});
|
|
@@ -143,7 +134,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
143
134
|
agentId,
|
|
144
135
|
timestamp: ts,
|
|
145
136
|
type: entryType,
|
|
146
|
-
content:
|
|
137
|
+
content: text,
|
|
147
138
|
...(model ? { model } : {}),
|
|
148
139
|
});
|
|
149
140
|
}
|
|
@@ -161,7 +152,7 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs,
|
|
|
161
152
|
agentId,
|
|
162
153
|
timestamp: ts,
|
|
163
154
|
type: entryType,
|
|
164
|
-
content:
|
|
155
|
+
content: text,
|
|
165
156
|
...(model ? { model } : {}),
|
|
166
157
|
});
|
|
167
158
|
}
|
|
@@ -215,46 +206,29 @@ export const sessionsTranscriptHandlers = {
|
|
|
215
206
|
}
|
|
216
207
|
const p = params;
|
|
217
208
|
const limit = p.limit ?? DEFAULT_LIMIT;
|
|
218
|
-
const maxBytesPerFile = p.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE;
|
|
219
209
|
const inputCursors = p.cursors ?? {};
|
|
220
|
-
const contentMaxChars = p.full ? Infinity : CONTENT_MAX_CHARS;
|
|
221
210
|
const agentFilter = p.agents && p.agents.length > 0 ? new Set(p.agents) : null;
|
|
222
211
|
try {
|
|
223
212
|
const cfg = loadConfig();
|
|
224
213
|
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
|
225
214
|
const storeKeys = Object.keys(store);
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
const cutoff = now - ACTIVE_WINDOW_MS;
|
|
229
|
-
const staleKeys = Object.entries(store)
|
|
230
|
-
.filter(([, entry]) => (entry.updatedAt ?? 0) < cutoff)
|
|
231
|
-
.map(([key, entry]) => `${key}(${entry.updatedAt ?? 0})`);
|
|
232
|
-
let activeSessions = Object.entries(store)
|
|
233
|
-
.filter(([, entry]) => {
|
|
234
|
-
const updatedAt = entry.updatedAt ?? 0;
|
|
235
|
-
return updatedAt >= cutoff;
|
|
236
|
-
})
|
|
237
|
-
.sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
238
|
-
.slice(0, MAX_SESSIONS);
|
|
215
|
+
// All sessions sorted by recency — no time cutoff, no count cap.
|
|
216
|
+
let sessions = Object.entries(store).sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
239
217
|
// Apply agent filter if provided
|
|
240
218
|
if (agentFilter) {
|
|
241
|
-
|
|
219
|
+
sessions = sessions.filter(([key]) => {
|
|
242
220
|
const agentId = extractAgentId(key);
|
|
243
221
|
return agentFilter.has(agentId);
|
|
244
222
|
});
|
|
245
223
|
}
|
|
246
|
-
|
|
247
|
-
context.logGateway.info(`sessions.transcript: storePath=${storePath} storeKeys=${storeKeys.length} active=${activeSessions.length} stale=${staleKeys.length} cutoffAge=24h`);
|
|
224
|
+
context.logGateway.info(`sessions.transcript: storePath=${storePath} storeKeys=${storeKeys.length} sessions=${sessions.length}`);
|
|
248
225
|
if (storeKeys.length === 0) {
|
|
249
226
|
context.logGateway.warn(`sessions.transcript: combined store is empty — no agents found or no session entries`);
|
|
250
227
|
}
|
|
251
|
-
else if (activeSessions.length === 0) {
|
|
252
|
-
context.logGateway.warn(`sessions.transcript: all ${storeKeys.length} sessions are stale (>24h): ${staleKeys.slice(0, 5).join(", ")}`);
|
|
253
|
-
}
|
|
254
228
|
const allEntries = [];
|
|
255
229
|
const cursors = {};
|
|
256
230
|
const agentIds = new Set();
|
|
257
|
-
for (const [sessionKey, sessionEntry] of
|
|
231
|
+
for (const [sessionKey, sessionEntry] of sessions) {
|
|
258
232
|
const sessionId = sessionEntry.sessionId;
|
|
259
233
|
if (!sessionId)
|
|
260
234
|
continue;
|
|
@@ -263,8 +237,7 @@ export const sessionsTranscriptHandlers = {
|
|
|
263
237
|
const sources = [{ sid: sessionId, file: sessionEntry.sessionFile }];
|
|
264
238
|
const prev = sessionEntry.previousSessions;
|
|
265
239
|
if (Array.isArray(prev)) {
|
|
266
|
-
|
|
267
|
-
for (const p of prev.slice(-3).reverse()) {
|
|
240
|
+
for (const p of prev.slice().reverse()) {
|
|
268
241
|
if (p.sessionId)
|
|
269
242
|
sources.push({ sid: p.sessionId, file: p.sessionFile });
|
|
270
243
|
}
|
|
@@ -294,7 +267,8 @@ export const sessionsTranscriptHandlers = {
|
|
|
294
267
|
result = await readTranscriptTail({
|
|
295
268
|
filePath,
|
|
296
269
|
cursor,
|
|
297
|
-
|
|
270
|
+
// Read entire file when no cursor — no byte cap
|
|
271
|
+
maxBytes: Infinity,
|
|
298
272
|
});
|
|
299
273
|
}
|
|
300
274
|
catch {
|
|
@@ -311,7 +285,7 @@ export const sessionsTranscriptHandlers = {
|
|
|
311
285
|
catch {
|
|
312
286
|
continue;
|
|
313
287
|
}
|
|
314
|
-
const expanded = expandLineToEntries(parsed, source.sid, sessionKey, agentId, result.fileMtimeMs
|
|
288
|
+
const expanded = expandLineToEntries(parsed, source.sid, sessionKey, agentId, result.fileMtimeMs);
|
|
315
289
|
allEntries.push(...expanded);
|
|
316
290
|
}
|
|
317
291
|
}
|
|
@@ -323,9 +297,18 @@ export const sessionsTranscriptHandlers = {
|
|
|
323
297
|
return tb - ta;
|
|
324
298
|
});
|
|
325
299
|
const entries = allEntries.slice(0, limit);
|
|
300
|
+
// Include configured agents so UI filter chips appear even when an
|
|
301
|
+
// agent has no sessions yet. When an agent filter is active (account
|
|
302
|
+
// scoping), only add config agents that match the filter — otherwise
|
|
303
|
+
// agents from other accounts leak into the chip list.
|
|
304
|
+
const { agents: configAgents } = listAgentsForGateway(cfg);
|
|
305
|
+
for (const a of configAgents) {
|
|
306
|
+
if (!agentFilter || agentFilter.has(a.id))
|
|
307
|
+
agentIds.add(a.id);
|
|
308
|
+
}
|
|
326
309
|
const agents = Array.from(agentIds).sort();
|
|
327
|
-
if (allEntries.length === 0 &&
|
|
328
|
-
context.logGateway.warn(`sessions.transcript: ${
|
|
310
|
+
if (allEntries.length === 0 && sessions.length > 0) {
|
|
311
|
+
context.logGateway.warn(`sessions.transcript: ${sessions.length} session(s) but 0 transcript entries — files may be missing or unreadable`);
|
|
329
312
|
}
|
|
330
313
|
respond(true, { entries, cursors, agents }, undefined);
|
|
331
314
|
}
|
|
@@ -32,6 +32,7 @@ import { usageHandlers } from "./server-methods/usage.js";
|
|
|
32
32
|
import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
|
33
33
|
import { webHandlers } from "./server-methods/web.js";
|
|
34
34
|
import { wizardHandlers } from "./server-methods/wizard.js";
|
|
35
|
+
import { publicChatHandlers } from "./server-methods/public-chat.js";
|
|
35
36
|
import { workspacesHandlers } from "./server-methods/workspaces.js";
|
|
36
37
|
const ADMIN_SCOPE = "operator.admin";
|
|
37
38
|
const READ_SCOPE = "operator.read";
|
|
@@ -40,6 +41,15 @@ const APPROVALS_SCOPE = "operator.approvals";
|
|
|
40
41
|
const PAIRING_SCOPE = "operator.pairing";
|
|
41
42
|
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
|
42
43
|
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]);
|
|
44
|
+
/** Methods accessible to public-chat role (no PIN, no gateway auth). */
|
|
45
|
+
const PUBLIC_ROLE_METHODS = new Set([
|
|
46
|
+
"chat.send",
|
|
47
|
+
"chat.history",
|
|
48
|
+
"chat.abort",
|
|
49
|
+
"public.otp.request",
|
|
50
|
+
"public.otp.verify",
|
|
51
|
+
"public.session",
|
|
52
|
+
]);
|
|
43
53
|
const PAIRING_METHODS = new Set([
|
|
44
54
|
"node.pair.request",
|
|
45
55
|
"node.pair.list",
|
|
@@ -115,6 +125,12 @@ function authorizeGatewayMethod(method, client) {
|
|
|
115
125
|
return null;
|
|
116
126
|
const role = client.connect.role ?? "operator";
|
|
117
127
|
const scopes = client.connect.scopes ?? [];
|
|
128
|
+
// Public role: only allow explicitly listed methods
|
|
129
|
+
if (role === "public") {
|
|
130
|
+
if (PUBLIC_ROLE_METHODS.has(method))
|
|
131
|
+
return null;
|
|
132
|
+
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized: public role cannot call ${method}`);
|
|
133
|
+
}
|
|
118
134
|
if (NODE_ROLE_METHODS.has(method)) {
|
|
119
135
|
if (role === "node")
|
|
120
136
|
return null;
|
|
@@ -218,6 +234,7 @@ export const coreGatewayHandlers = {
|
|
|
218
234
|
...memoryHandlers,
|
|
219
235
|
...recordsHandlers,
|
|
220
236
|
...workspacesHandlers,
|
|
237
|
+
...publicChatHandlers,
|
|
221
238
|
};
|
|
222
239
|
export async function handleGatewayRequest(opts) {
|
|
223
240
|
const { req, respond, client, isWebchatConnect, context } = opts;
|
|
@@ -65,11 +65,17 @@ function formatTime(timestamp) {
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* Format date for section header
|
|
68
|
+
* Format date for section header.
|
|
69
|
+
* Uses local date (consistent with formatTime) — NOT UTC.
|
|
70
|
+
* Previously used toISOString() which is UTC, causing date/time mismatches
|
|
71
|
+
* near midnight when local timezone differs from UTC.
|
|
69
72
|
*/
|
|
70
73
|
function formatDate(timestamp) {
|
|
71
74
|
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
|
72
|
-
|
|
75
|
+
const year = date.getFullYear();
|
|
76
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
77
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
78
|
+
return `${year}-${month}-${day}`;
|
|
73
79
|
}
|
|
74
80
|
/**
|
|
75
81
|
* Get monthly log filename
|
|
@@ -84,7 +90,7 @@ function getMonthlyLogFilename(timestamp) {
|
|
|
84
90
|
* Archive a message to the conversation log
|
|
85
91
|
*/
|
|
86
92
|
async function archiveMessage(params) {
|
|
87
|
-
const { workspaceDir, subdir, role, text, timestamp, fileHeader } = params;
|
|
93
|
+
const { workspaceDir, subdir, role, text, timestamp, fileHeader, channel } = params;
|
|
88
94
|
// Build path: memory/{subdir}/conversations/YYYY-MM.md
|
|
89
95
|
const conversationsDir = path.join(workspaceDir, "memory", subdir, "conversations");
|
|
90
96
|
await fs.mkdir(conversationsDir, { recursive: true });
|
|
@@ -121,7 +127,8 @@ async function archiveMessage(params) {
|
|
|
121
127
|
}
|
|
122
128
|
// Format the message - escape any markdown in the text
|
|
123
129
|
const cleanText = text?.trim() || "(no text)";
|
|
124
|
-
|
|
130
|
+
const channelTag = channel ? ` [${channel}]` : "";
|
|
131
|
+
entryParts.push(`### ${timeStr} — ${role}${channelTag}`);
|
|
125
132
|
entryParts.push(cleanText);
|
|
126
133
|
entryParts.push("");
|
|
127
134
|
const entry = entryParts.join("\n");
|
|
@@ -155,6 +162,8 @@ const archiveConversation = async (event) => {
|
|
|
155
162
|
}
|
|
156
163
|
// Get timestamp from context or event
|
|
157
164
|
const timestamp = context.timestamp ?? event.timestamp;
|
|
165
|
+
// Channel identifier from hook context (e.g. "whatsapp", "webchat", "imessage")
|
|
166
|
+
const channel = context.channel ?? undefined;
|
|
158
167
|
// Determine conversation type from session key and route to correct archive path
|
|
159
168
|
const peer = extractPeerFromSessionKey(event.sessionKey);
|
|
160
169
|
const groupId = peer ? null : extractGroupIdFromSessionKey(event.sessionKey);
|
|
@@ -165,7 +174,7 @@ const archiveConversation = async (event) => {
|
|
|
165
174
|
const subdir = isAdminAgent ? "admin" : `users/${peer}`;
|
|
166
175
|
const inboundRole = isAdminAgent ? "Admin" : "User";
|
|
167
176
|
const role = event.action === "inbound" ? inboundRole : "Assistant";
|
|
168
|
-
await archiveMessage({ workspaceDir, subdir, role, text, timestamp });
|
|
177
|
+
await archiveMessage({ workspaceDir, subdir, role, text, timestamp, channel });
|
|
169
178
|
}
|
|
170
179
|
else if (groupId) {
|
|
171
180
|
// Group message — archive under memory/groups/{groupId}/
|
|
@@ -193,12 +202,20 @@ const archiveConversation = async (event) => {
|
|
|
193
202
|
text,
|
|
194
203
|
timestamp,
|
|
195
204
|
fileHeader,
|
|
205
|
+
channel,
|
|
196
206
|
});
|
|
197
207
|
}
|
|
198
208
|
else if (isWebchat) {
|
|
199
209
|
// Webchat (control panel) — archive under memory/admin/conversations/
|
|
200
210
|
const role = event.action === "inbound" ? "Admin" : "Assistant";
|
|
201
|
-
await archiveMessage({
|
|
211
|
+
await archiveMessage({
|
|
212
|
+
workspaceDir,
|
|
213
|
+
subdir: "admin",
|
|
214
|
+
role,
|
|
215
|
+
text,
|
|
216
|
+
timestamp,
|
|
217
|
+
channel: channel ?? "webchat",
|
|
218
|
+
});
|
|
202
219
|
}
|
|
203
220
|
else {
|
|
204
221
|
// Unknown session key format — skip
|
|
@@ -11,6 +11,7 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di
|
|
|
11
11
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
|
12
12
|
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
|
13
13
|
import { createMemoryGetTool, createMemorySaveMediaTool, createMemorySearchTool, createMemoryWriteTool, } from "../../agents/tools/memory-tool.js";
|
|
14
|
+
import { createMessageHistoryTool } from "../../agents/tools/message-history-tool.js";
|
|
14
15
|
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
|
15
16
|
import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js";
|
|
16
17
|
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
|
|
@@ -123,6 +124,7 @@ export function createPluginRuntime() {
|
|
|
123
124
|
createMemorySaveMediaTool,
|
|
124
125
|
createMemorySearchTool,
|
|
125
126
|
createMemoryWriteTool,
|
|
127
|
+
createMessageHistoryTool,
|
|
126
128
|
registerMemoryCli,
|
|
127
129
|
},
|
|
128
130
|
channel: {
|
|
@@ -25,6 +25,9 @@ export function isWebchatClient(client) {
|
|
|
25
25
|
return true;
|
|
26
26
|
return normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.WEBCHAT_UI;
|
|
27
27
|
}
|
|
28
|
+
export function isPublicChatClient(client) {
|
|
29
|
+
return normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.PUBLIC_CHAT;
|
|
30
|
+
}
|
|
28
31
|
export function normalizeMessageChannel(raw) {
|
|
29
32
|
const normalized = raw?.trim().toLowerCase();
|
|
30
33
|
if (!normalized)
|
package/package.json
CHANGED
|
@@ -394,18 +394,25 @@ All outreach messages are sent as your assistant — customers reply normally an
|
|
|
394
394
|
|
|
395
395
|
### Conversations & Sessions
|
|
396
396
|
|
|
397
|
-
Your assistant
|
|
397
|
+
Your assistant has full access to conversation history across all channels — WhatsApp, webchat, iMessage, and group chats. It can find specific messages, review entire conversations, and give you an overview of all activity.
|
|
398
398
|
|
|
399
399
|
| Capability | Description |
|
|
400
400
|
|------------|-------------|
|
|
401
|
-
| List conversations | See who has been messaging and when |
|
|
401
|
+
| List conversations | See who has been messaging and when — across all channels |
|
|
402
402
|
| Review history | Read back through past conversations with a specific contact |
|
|
403
|
+
| Search messages | Find messages containing specific words or topics |
|
|
404
|
+
| Filter by channel | Show only WhatsApp, webchat, or iMessage messages |
|
|
405
|
+
| Filter by time | Narrow results to a specific date or time range |
|
|
406
|
+
| All activity | Get a summary of messages across all customers and groups at once |
|
|
403
407
|
| Send between sessions | Send a message to a customer outside of an active conversation |
|
|
404
|
-
| Check session status | See whether your assistant is currently in a conversation |
|
|
405
408
|
|
|
406
409
|
**Try asking:**
|
|
407
|
-
- "Who messaged today?"
|
|
408
|
-
- "What did Sarah ask about yesterday?"
|
|
410
|
+
- "Who messaged today?" — lists all conversations with recent activity
|
|
411
|
+
- "What did Sarah ask about yesterday?" — reviews a specific contact's history
|
|
412
|
+
- "Find any messages that mention boiler" — searches across conversations
|
|
413
|
+
- "Show me all WhatsApp messages from today" — filters by channel
|
|
414
|
+
- "What happened between 9am and 10am?" — filters by time range
|
|
415
|
+
- "Show me all customer messages from today" — aggregates across all conversations
|
|
409
416
|
- "Send a follow-up to the customer who asked about the boiler"
|
|
410
417
|
|
|
411
418
|
### Admin Management
|
|
@@ -624,6 +631,179 @@ You can filter by:
|
|
|
624
631
|
|
|
625
632
|
Both views support **auto-follow** (keeps the view scrolled to the latest entries) and **export** to download a log file.
|
|
626
633
|
|
|
634
|
+
### Changing Network Settings
|
|
635
|
+
|
|
636
|
+
You might need to change two network settings after your Pi is deployed:
|
|
637
|
+
|
|
638
|
+
- **Port** — The number at the end of your control panel URL (e.g., `http://taskmaster.local:18789`)
|
|
639
|
+
- **mDNS hostname** — The friendly name before `.local` (e.g., `taskmaster` in `taskmaster.local`)
|
|
640
|
+
|
|
641
|
+
#### What is the port and why would I change it?
|
|
642
|
+
|
|
643
|
+
The port is like an apartment number for your Taskmaster server. The default is 18789.
|
|
644
|
+
|
|
645
|
+
**When to change it:**
|
|
646
|
+
- Another device on your network is already using port 18789
|
|
647
|
+
- Your router or firewall blocks that port
|
|
648
|
+
- You're running multiple Taskmaster instances and need them on different ports
|
|
649
|
+
|
|
650
|
+
**What happens when you change it:** Your control panel URL changes. If you set the port to 19000, you'll access Taskmaster at `http://taskmaster.local:19000`.
|
|
651
|
+
|
|
652
|
+
#### What is the mDNS hostname and why would I change it?
|
|
653
|
+
|
|
654
|
+
The mDNS hostname is the friendly name you use to access your Pi on your local network. The default is `taskmaster`, so your control panel is at `taskmaster.local`.
|
|
655
|
+
|
|
656
|
+
**When to change it:**
|
|
657
|
+
- You're running multiple Taskmaster instances on the same network (they'll conflict if they all use `taskmaster.local`)
|
|
658
|
+
- You want custom branding (e.g., `dave.local`, `assistant.local`, `plumbing-helper.local`)
|
|
659
|
+
|
|
660
|
+
**What happens when you change it:** Your control panel URL changes. If you set the hostname to `dave`, you'll access Taskmaster at `http://dave.local:18789`.
|
|
661
|
+
|
|
662
|
+
#### How to make these changes
|
|
663
|
+
|
|
664
|
+
You have two options for accessing your Pi:
|
|
665
|
+
|
|
666
|
+
**Option 1: Direct Access (Keyboard + Monitor)**
|
|
667
|
+
1. Plug a keyboard and monitor into your Pi
|
|
668
|
+
2. Log in (default user: `taskmaster`, password: `taskmaster`)
|
|
669
|
+
3. Follow the instructions below
|
|
670
|
+
|
|
671
|
+
**Option 2: SSH (from another computer)**
|
|
672
|
+
1. Open Terminal (Mac/Linux) or PowerShell (Windows)
|
|
673
|
+
2. Connect to your Pi: `ssh admin@taskmaster.local`
|
|
674
|
+
3. Enter the password when prompted (default: `password`)
|
|
675
|
+
4. Follow the instructions below
|
|
676
|
+
|
|
677
|
+
#### Changing the Port
|
|
678
|
+
|
|
679
|
+
Once you're logged in (either method):
|
|
680
|
+
|
|
681
|
+
**Step 1:** Update the configuration
|
|
682
|
+
|
|
683
|
+
```bash
|
|
684
|
+
taskmaster config set gateway.port 19000
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
(Replace `19000` with whatever port you want)
|
|
688
|
+
|
|
689
|
+
**Step 2:** Reinstall the daemon service (this rewrites the service file with the new port)
|
|
690
|
+
|
|
691
|
+
```bash
|
|
692
|
+
taskmaster daemon install --force
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
> **Important:** Do not use `taskmaster daemon restart` here — that restarts the service with the old port. You must use `taskmaster daemon install --force` to pick up the new port from the configuration.
|
|
696
|
+
|
|
697
|
+
**Step 3:** Access the control panel at the new URL: `http://taskmaster.local:19000` (use your new port number)
|
|
698
|
+
|
|
699
|
+
#### Changing the mDNS Hostname
|
|
700
|
+
|
|
701
|
+
Once you're logged in (either method):
|
|
702
|
+
|
|
703
|
+
**Step 1:** Set the new hostname
|
|
704
|
+
|
|
705
|
+
```bash
|
|
706
|
+
sudo hostnamectl set-hostname dave
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
(Replace `dave` with whatever name you want — letters and hyphens only, no spaces)
|
|
710
|
+
|
|
711
|
+
> You will see a warning on every `sudo` command until Step 2 completes: `sudo: unable to resolve host dave: Name or service not known`. This is normal — the hostname was changed but the system hasn't been told where to find it yet. Every command still succeeds despite the warning.
|
|
712
|
+
|
|
713
|
+
**Step 2:** Update `/etc/hosts` to match
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
sudo sed -i "s/127\.0\.1\.1.*/127.0.1.1\tdave/" /etc/hosts
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
(Replace `dave` with the same name you used in Step 1. You'll see the same warning one last time — after this command, it's gone.)
|
|
720
|
+
|
|
721
|
+
**Step 3:** Restart the mDNS service
|
|
722
|
+
|
|
723
|
+
```bash
|
|
724
|
+
sudo systemctl restart avahi-daemon
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
> **If you're connected via SSH**, your connection will stop working after this step because the old hostname is no longer valid. Reconnect using the new hostname: `ssh admin@dave.local`
|
|
728
|
+
|
|
729
|
+
**Step 4:** Access the control panel at the new URL: `http://dave.local:18789` (use your new hostname)
|
|
730
|
+
|
|
731
|
+
> **Note:** This changes the hostname for the entire Pi, not just Taskmaster.
|
|
732
|
+
|
|
733
|
+
#### Changing Both
|
|
734
|
+
|
|
735
|
+
If you need to change both the port and hostname:
|
|
736
|
+
|
|
737
|
+
1. Change the port first (Steps 1–2 under "Changing the Port")
|
|
738
|
+
2. Change the hostname (Steps 1–2 under "Changing the mDNS Hostname")
|
|
739
|
+
3. Access the control panel at the new combined URL: `http://dave.local:19000` (use your new hostname + new port)
|
|
740
|
+
|
|
741
|
+
#### Verifying Your Changes
|
|
742
|
+
|
|
743
|
+
After completing the steps above, run these checks **on the Pi** to confirm everything is correct.
|
|
744
|
+
|
|
745
|
+
**Check the hostname is set:**
|
|
746
|
+
|
|
747
|
+
```bash
|
|
748
|
+
hostname
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
This should print your new hostname (e.g. `dave`).
|
|
752
|
+
|
|
753
|
+
**Check `/etc/hosts` matches:**
|
|
754
|
+
|
|
755
|
+
```bash
|
|
756
|
+
grep 127.0.1.1 /etc/hosts
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
This should show `127.0.1.1` followed by your new hostname (e.g. `127.0.1.1 dave`).
|
|
760
|
+
|
|
761
|
+
**Check avahi is broadcasting the new hostname:**
|
|
762
|
+
|
|
763
|
+
```bash
|
|
764
|
+
avahi-resolve -n dave.local
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
(Replace `dave` with your hostname.) This should print your Pi's IP address. If it says "Failed to resolve", restart avahi: `sudo systemctl restart avahi-daemon` and try again.
|
|
768
|
+
|
|
769
|
+
**Check the gateway is running on the correct port:**
|
|
770
|
+
|
|
771
|
+
```bash
|
|
772
|
+
taskmaster daemon status
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
This shows the port the gateway is listening on. Confirm it matches what you set.
|
|
776
|
+
|
|
777
|
+
**Test locally on the Pi:**
|
|
778
|
+
|
|
779
|
+
```bash
|
|
780
|
+
curl -s -o /dev/null -w "%{http_code}" http://localhost:18789
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
(Use your new port number.) If this prints `200`, the gateway is running. If it fails, restart: `taskmaster daemon restart`.
|
|
784
|
+
|
|
785
|
+
#### Flushing DNS Cache on Other Devices
|
|
786
|
+
|
|
787
|
+
The Pi broadcasts its new hostname immediately, but the device you're browsing from (your phone, laptop, etc.) may have cached the old name. If the control panel doesn't load from another device after the Pi checks above all pass, flush the DNS cache on that device:
|
|
788
|
+
|
|
789
|
+
| Device | Command |
|
|
790
|
+
|--------|---------|
|
|
791
|
+
| **Mac** | `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder` |
|
|
792
|
+
| **Windows** | `ipconfig /flushdns` (run in PowerShell or Command Prompt) |
|
|
793
|
+
| **Linux** | `avahi-resolve -n dave.local` (probes directly, bypasses cache) |
|
|
794
|
+
| **iPhone / iPad** | Toggle Airplane Mode on and off, or toggle WiFi off and on |
|
|
795
|
+
| **Android** | Toggle WiFi off and on |
|
|
796
|
+
|
|
797
|
+
After flushing, open your browser and navigate to the new URL (e.g. `http://dave.local:19000`). If you still can't connect, try a hard refresh (Ctrl+Shift+R or Cmd+Shift+R) or open a new private/incognito window to rule out browser caching.
|
|
798
|
+
|
|
799
|
+
#### Still Can't Connect?
|
|
800
|
+
|
|
801
|
+
If the Pi checks pass but the control panel still doesn't load from another device:
|
|
802
|
+
|
|
803
|
+
1. **Use the IP address directly** — on the Pi, run `hostname -I` to get its IP address, then open `http://192.168.1.XXX:18789` (use your IP and port) from the other device. If this works, the problem is mDNS resolution, not the gateway.
|
|
804
|
+
2. **Check you're on the same network** — mDNS only works between devices on the same WiFi/LAN. It won't work across VLANs or guest networks.
|
|
805
|
+
3. **Contact support** with: what you changed (port/hostname), the output of `hostname`, `grep 127.0.1.1 /etc/hosts`, and `taskmaster daemon status`.
|
|
806
|
+
|
|
627
807
|
---
|
|
628
808
|
|
|
629
809
|
## Troubleshooting
|