@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.
Files changed (39) hide show
  1. package/dist/agents/pi-embedded-runner/history.js +19 -1
  2. package/dist/agents/pi-embedded-runner/run/attempt.js +23 -5
  3. package/dist/agents/pi-embedded-runner/run.js +6 -31
  4. package/dist/agents/pi-embedded-runner.js +1 -1
  5. package/dist/agents/system-prompt.js +20 -0
  6. package/dist/agents/taskmaster-tools.js +4 -0
  7. package/dist/agents/tool-policy.js +2 -0
  8. package/dist/agents/tools/message-history-tool.js +436 -0
  9. package/dist/agents/tools/sessions-history-tool.js +1 -0
  10. package/dist/build-info.json +3 -3
  11. package/dist/config/zod-schema.js +10 -0
  12. package/dist/control-ui/assets/index-DmifehTc.css +1 -0
  13. package/dist/control-ui/assets/index-o5Xs9S4u.js +3166 -0
  14. package/dist/control-ui/assets/index-o5Xs9S4u.js.map +1 -0
  15. package/dist/control-ui/index.html +2 -2
  16. package/dist/gateway/config-reload.js +1 -0
  17. package/dist/gateway/control-ui.js +173 -0
  18. package/dist/gateway/net.js +16 -0
  19. package/dist/gateway/protocol/client-info.js +1 -0
  20. package/dist/gateway/protocol/schema/logs-chat.js +3 -0
  21. package/dist/gateway/protocol/schema/sessions-transcript.js +1 -3
  22. package/dist/gateway/public-chat/deliver-otp.js +9 -0
  23. package/dist/gateway/public-chat/otp.js +60 -0
  24. package/dist/gateway/public-chat/session.js +45 -0
  25. package/dist/gateway/server/ws-connection/message-handler.js +17 -4
  26. package/dist/gateway/server-chat.js +22 -0
  27. package/dist/gateway/server-http.js +21 -3
  28. package/dist/gateway/server-methods/chat.js +38 -5
  29. package/dist/gateway/server-methods/public-chat.js +110 -0
  30. package/dist/gateway/server-methods/sessions-transcript.js +29 -46
  31. package/dist/gateway/server-methods.js +17 -0
  32. package/dist/hooks/bundled/conversation-archive/handler.js +23 -6
  33. package/dist/plugins/runtime/index.js +2 -0
  34. package/dist/utils/message-channel.js +3 -0
  35. package/package.json +1 -1
  36. package/taskmaster-docs/USER-GUIDE.md +185 -5
  37. package/dist/control-ui/assets/index-BPvR6pln.js +0 -3021
  38. package/dist/control-ui/assets/index-BPvR6pln.js.map +0 -1
  39. 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, maxChars = CONTENT_MAX_CHARS) {
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: truncate(content, maxChars),
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: truncate(content, maxChars),
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: truncate(msg.content, maxChars),
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: truncate(text, maxChars),
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: truncate(content, maxChars),
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: truncate(text, maxChars),
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: truncate(text, maxChars),
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
- // Filter to sessions updated within last 24 hours
227
- const now = Date.now();
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
- activeSessions = activeSessions.filter(([key]) => {
219
+ sessions = sessions.filter(([key]) => {
242
220
  const agentId = extractAgentId(key);
243
221
  return agentFilter.has(agentId);
244
222
  });
245
223
  }
246
- // Diagnostic: log store resolution so we can trace "missing session logs" reports.
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 activeSessions) {
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
- // Include recent previous sessions (newest first, limit 3 to bound IO)
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
- maxBytes: maxBytesPerFile,
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, contentMaxChars);
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 && activeSessions.length > 0) {
328
- context.logGateway.warn(`sessions.transcript: ${activeSessions.length} active session(s) but 0 transcript entries — files may be missing or unreadable`);
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
- return date.toISOString().split("T")[0]; // YYYY-MM-DD
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
- entryParts.push(`### ${timeStr} ${role}`);
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({ workspaceDir, subdir: "admin", role, text, timestamp });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.64",
3
+ "version": "1.0.65",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 can review conversation history and check what customers have been asking about.
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