@rubytech/taskmaster 1.0.94 → 1.0.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/taskmaster-tools.js +4 -4
- package/dist/agents/tool-policy.js +2 -2
- package/dist/agents/tools/contact-lookup-tool.js +45 -0
- package/dist/agents/tools/contact-update-tool.js +68 -0
- package/dist/agents/tools/memory-tool.js +10 -3
- package/dist/build-info.json +3 -3
- package/dist/cli/provision-seed.js +2 -2
- package/dist/control-ui/assets/index-6WdtDXJj.css +1 -0
- package/dist/control-ui/assets/index-lbNnMWBM.js +3508 -0
- package/dist/control-ui/assets/index-lbNnMWBM.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/chat-sanitize.js +121 -5
- package/dist/gateway/media-http.js +120 -0
- package/dist/gateway/protocol/schema/logs-chat.js +4 -0
- package/dist/gateway/public-chat-api.js +5 -3
- package/dist/gateway/server-http.js +3 -0
- package/dist/gateway/server-methods/chat.js +12 -5
- package/dist/gateway/server-methods/wifi.js +202 -0
- package/dist/gateway/server-methods.js +2 -0
- package/dist/infra/heartbeat-infra-alert.js +143 -0
- package/dist/infra/heartbeat-runner.js +13 -0
- package/dist/memory/manager.js +15 -8
- package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
- package/extensions/googlechat/node_modules/.bin/taskmaster +0 -0
- package/extensions/line/node_modules/.bin/taskmaster +0 -0
- package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
- package/extensions/matrix/node_modules/.bin/taskmaster +0 -0
- package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
- package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
- package/extensions/msteams/node_modules/.bin/taskmaster +0 -0
- package/extensions/nostr/node_modules/.bin/taskmaster +0 -0
- package/extensions/nostr/node_modules/.bin/tsc +0 -0
- package/extensions/nostr/node_modules/.bin/tsserver +0 -0
- package/extensions/zalo/node_modules/.bin/taskmaster +0 -0
- package/extensions/zalouser/node_modules/.bin/taskmaster +0 -0
- package/package.json +64 -54
- package/scripts/install.sh +0 -0
- package/taskmaster-docs/USER-GUIDE.md +1 -1
- package/dist/control-ui/assets/index-B7exVNNa.css +0 -1
- package/dist/control-ui/assets/index-DfQL37PU.js +0 -3379
- package/dist/control-ui/assets/index-DfQL37PU.js.map +0 -1
- package/templates/.DS_Store +0 -0
- package/templates/customer/.DS_Store +0 -0
- package/templates/customer/agents/.DS_Store +0 -0
- package/templates/taskmaster/.gitignore +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<title>Taskmaster Control</title>
|
|
7
7
|
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-lbNnMWBM.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-6WdtDXJj.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<taskmaster-app></taskmaster-app>
|
|
@@ -119,13 +119,18 @@ export function stripEnvelopeFromMessages(messages) {
|
|
|
119
119
|
return changed ? next : messages;
|
|
120
120
|
}
|
|
121
121
|
// ---------------------------------------------------------------------------
|
|
122
|
-
// Base64 image stripping
|
|
122
|
+
// Base64 image stripping & media URL references
|
|
123
123
|
// ---------------------------------------------------------------------------
|
|
124
|
-
// Images
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
124
|
+
// Images are stored as physical files on disk and referenced by path.
|
|
125
|
+
// When sending chat history to the UI, base64 image data is replaced with
|
|
126
|
+
// URL references to the /api/media endpoint. The UI renders these as <img>.
|
|
127
|
+
//
|
|
128
|
+
// Flow:
|
|
129
|
+
// 1. Extract file paths from [media attached: /path (type)] text annotations
|
|
130
|
+
// 2. Remove base64 image blocks from content
|
|
131
|
+
// 3. Add { type: "image", url: "/api/media?path=..." } blocks
|
|
128
132
|
// ---------------------------------------------------------------------------
|
|
133
|
+
import nodePath from "node:path";
|
|
129
134
|
function isBase64ImageBlock(block) {
|
|
130
135
|
if (!block || typeof block !== "object")
|
|
131
136
|
return false;
|
|
@@ -148,6 +153,33 @@ function isBase64ImageBlock(block) {
|
|
|
148
153
|
}
|
|
149
154
|
return false;
|
|
150
155
|
}
|
|
156
|
+
// Pattern: [media attached: /path (mime/type)] or [media attached 1/2: /path (mime/type) | url]
|
|
157
|
+
const MEDIA_PATH_PATTERN = /\[media attached(?:\s+\d+\/\d+)?:\s*(.+?)\s*\(([^)]+)\)(?:\s*\|[^\]]+)?\]/gi;
|
|
158
|
+
/**
|
|
159
|
+
* Parse [media attached: ...] annotations from text to extract file paths.
|
|
160
|
+
*/
|
|
161
|
+
function extractMediaRefs(text) {
|
|
162
|
+
if (!text.includes("[media attached"))
|
|
163
|
+
return [];
|
|
164
|
+
const refs = [];
|
|
165
|
+
let match;
|
|
166
|
+
MEDIA_PATH_PATTERN.lastIndex = 0;
|
|
167
|
+
while ((match = MEDIA_PATH_PATTERN.exec(text)) !== null) {
|
|
168
|
+
const absPath = match[1]?.trim();
|
|
169
|
+
const mimeType = match[2]?.trim();
|
|
170
|
+
if (absPath && mimeType) {
|
|
171
|
+
refs.push({ absPath, mimeType });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return refs;
|
|
175
|
+
}
|
|
176
|
+
function mediaRefToUrl(ref, workspaceRoot) {
|
|
177
|
+
const relPath = nodePath.relative(workspaceRoot, ref.absPath);
|
|
178
|
+
// Must stay within workspace (no ../ escapes)
|
|
179
|
+
if (relPath.startsWith("..") || nodePath.isAbsolute(relPath))
|
|
180
|
+
return null;
|
|
181
|
+
return `/api/media?path=${encodeURIComponent(relPath)}`;
|
|
182
|
+
}
|
|
151
183
|
function stripBase64FromContentBlocks(content) {
|
|
152
184
|
let changed = false;
|
|
153
185
|
const next = content.map((block) => {
|
|
@@ -193,3 +225,87 @@ export function stripBase64ImagesFromMessages(messages) {
|
|
|
193
225
|
});
|
|
194
226
|
return changed ? next : messages;
|
|
195
227
|
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Combined media sanitization for chat display
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
/**
|
|
232
|
+
* Sanitize media in chat messages for UI display.
|
|
233
|
+
* - Extracts file paths from [media attached: ...] text annotations
|
|
234
|
+
* - Removes base64 image blocks
|
|
235
|
+
* - Creates URL-based image references for the /api/media endpoint
|
|
236
|
+
*
|
|
237
|
+
* Must be called BEFORE stripEnvelopeFromMessages (which strips annotations).
|
|
238
|
+
*/
|
|
239
|
+
export function sanitizeMediaForChat(messages, workspaceRoot) {
|
|
240
|
+
if (messages.length === 0 || !workspaceRoot) {
|
|
241
|
+
// No workspace context — fall back to plain base64 stripping
|
|
242
|
+
return stripBase64ImagesFromMessages(messages);
|
|
243
|
+
}
|
|
244
|
+
let changed = false;
|
|
245
|
+
const next = messages.map((message) => {
|
|
246
|
+
const result = sanitizeMessageMedia(message, workspaceRoot);
|
|
247
|
+
if (result !== message)
|
|
248
|
+
changed = true;
|
|
249
|
+
return result;
|
|
250
|
+
});
|
|
251
|
+
return changed ? next : messages;
|
|
252
|
+
}
|
|
253
|
+
function sanitizeMessageMedia(message, workspaceRoot) {
|
|
254
|
+
if (!message || typeof message !== "object")
|
|
255
|
+
return message;
|
|
256
|
+
const entry = message;
|
|
257
|
+
// Collect media refs from text content (works for both string and array content)
|
|
258
|
+
const mediaRefs = extractMediaRefsFromMessage(entry);
|
|
259
|
+
// Build URL-based image blocks from annotations
|
|
260
|
+
const imageBlocks = [];
|
|
261
|
+
for (const ref of mediaRefs) {
|
|
262
|
+
const url = mediaRefToUrl(ref, workspaceRoot);
|
|
263
|
+
if (url) {
|
|
264
|
+
imageBlocks.push({ type: "image", url });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(entry.content)) {
|
|
268
|
+
// String content — no base64 blocks to strip, just add image blocks if found
|
|
269
|
+
if (imageBlocks.length === 0)
|
|
270
|
+
return message;
|
|
271
|
+
const textContent = typeof entry.content === "string" ? entry.content : "";
|
|
272
|
+
return {
|
|
273
|
+
...entry,
|
|
274
|
+
content: [{ type: "text", text: textContent }, ...imageBlocks],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Array content — remove base64 image blocks, add URL-based ones
|
|
278
|
+
let didChange = false;
|
|
279
|
+
const filtered = entry.content.filter((block) => {
|
|
280
|
+
if (isBase64ImageBlock(block)) {
|
|
281
|
+
didChange = true;
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
});
|
|
286
|
+
if (imageBlocks.length > 0) {
|
|
287
|
+
didChange = true;
|
|
288
|
+
filtered.push(...imageBlocks);
|
|
289
|
+
}
|
|
290
|
+
if (!didChange)
|
|
291
|
+
return message;
|
|
292
|
+
return { ...entry, content: filtered };
|
|
293
|
+
}
|
|
294
|
+
function extractMediaRefsFromMessage(entry) {
|
|
295
|
+
if (typeof entry.content === "string") {
|
|
296
|
+
return extractMediaRefs(entry.content);
|
|
297
|
+
}
|
|
298
|
+
if (Array.isArray(entry.content)) {
|
|
299
|
+
const refs = [];
|
|
300
|
+
for (const block of entry.content) {
|
|
301
|
+
if (!block || typeof block !== "object")
|
|
302
|
+
continue;
|
|
303
|
+
const b = block;
|
|
304
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
305
|
+
refs.push(...extractMediaRefs(b.text));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return refs;
|
|
309
|
+
}
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveAgentWorkspaceRoot } from "../agents/agent-scope.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Workspace media file endpoint
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Serves image files from the workspace root so that chat history can display
|
|
8
|
+
// inline images by URL instead of embedding base64 data in WebSocket messages.
|
|
9
|
+
//
|
|
10
|
+
// Route: GET /api/media?path=<workspace-relative-path>
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const ALLOWED_IMAGE_EXTENSIONS = new Set([
|
|
13
|
+
".png",
|
|
14
|
+
".jpg",
|
|
15
|
+
".jpeg",
|
|
16
|
+
".gif",
|
|
17
|
+
".webp",
|
|
18
|
+
".heic",
|
|
19
|
+
".heif",
|
|
20
|
+
".bmp",
|
|
21
|
+
".tiff",
|
|
22
|
+
".tif",
|
|
23
|
+
".pdf",
|
|
24
|
+
]);
|
|
25
|
+
function contentType(ext) {
|
|
26
|
+
switch (ext) {
|
|
27
|
+
case ".png":
|
|
28
|
+
return "image/png";
|
|
29
|
+
case ".jpg":
|
|
30
|
+
case ".jpeg":
|
|
31
|
+
return "image/jpeg";
|
|
32
|
+
case ".gif":
|
|
33
|
+
return "image/gif";
|
|
34
|
+
case ".webp":
|
|
35
|
+
return "image/webp";
|
|
36
|
+
case ".heic":
|
|
37
|
+
return "image/heic";
|
|
38
|
+
case ".heif":
|
|
39
|
+
return "image/heif";
|
|
40
|
+
case ".bmp":
|
|
41
|
+
return "image/bmp";
|
|
42
|
+
case ".tiff":
|
|
43
|
+
case ".tif":
|
|
44
|
+
return "image/tiff";
|
|
45
|
+
case ".pdf":
|
|
46
|
+
return "application/pdf";
|
|
47
|
+
default:
|
|
48
|
+
return "application/octet-stream";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isSafeRelativePath(relPath) {
|
|
52
|
+
if (!relPath)
|
|
53
|
+
return false;
|
|
54
|
+
const normalized = path.posix.normalize(relPath);
|
|
55
|
+
if (normalized.startsWith("../") || normalized === "..")
|
|
56
|
+
return false;
|
|
57
|
+
if (normalized.includes("\0"))
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
export function resolveWorkspaceRoot(config) {
|
|
62
|
+
return resolveAgentWorkspaceRoot(config, "admin");
|
|
63
|
+
}
|
|
64
|
+
export function handleMediaRequest(req, res, opts) {
|
|
65
|
+
const urlObj = new URL(req.url ?? "/", "http://localhost");
|
|
66
|
+
if (urlObj.pathname !== "/api/media")
|
|
67
|
+
return false;
|
|
68
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
69
|
+
res.statusCode = 405;
|
|
70
|
+
res.setHeader("Allow", "GET, HEAD");
|
|
71
|
+
res.end();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const relPath = urlObj.searchParams.get("path") ?? "";
|
|
75
|
+
if (!relPath || !isSafeRelativePath(relPath)) {
|
|
76
|
+
res.statusCode = 404;
|
|
77
|
+
res.end("Not Found");
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
81
|
+
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
82
|
+
res.statusCode = 403;
|
|
83
|
+
res.end("Forbidden");
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const workspaceRoot = resolveWorkspaceRoot(opts.config);
|
|
87
|
+
const filePath = path.resolve(workspaceRoot, relPath);
|
|
88
|
+
// Boundary check: must stay within workspace
|
|
89
|
+
if (!filePath.startsWith(workspaceRoot + path.sep) && filePath !== workspaceRoot) {
|
|
90
|
+
res.statusCode = 403;
|
|
91
|
+
res.end("Forbidden");
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = fs.statSync(filePath);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
res.statusCode = 404;
|
|
100
|
+
res.end("Not Found");
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
res.statusCode = 404;
|
|
105
|
+
res.end("Not Found");
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
res.statusCode = 200;
|
|
109
|
+
res.setHeader("Content-Type", contentType(ext));
|
|
110
|
+
res.setHeader("Content-Length", stat.size);
|
|
111
|
+
res.setHeader("Cache-Control", "private, max-age=86400");
|
|
112
|
+
if (req.method === "HEAD") {
|
|
113
|
+
res.end();
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const stream = fs.createReadStream(filePath);
|
|
117
|
+
stream.pipe(res);
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
@@ -17,6 +17,10 @@ export const LogsTailResultSchema = Type.Object({
|
|
|
17
17
|
export const ChatHistoryParamsSchema = Type.Object({
|
|
18
18
|
sessionKey: NonEmptyString,
|
|
19
19
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10_000 })),
|
|
20
|
+
/** Number of messages to skip from the end (newest) before applying limit.
|
|
21
|
+
* offset=0 (default) returns the most recent `limit` messages.
|
|
22
|
+
* offset=50 with limit=50 returns messages 51–100 from the end. */
|
|
23
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
20
24
|
/** When set, read from this specific session transcript instead of the current one. */
|
|
21
25
|
sessionId: Type.Optional(NonEmptyString),
|
|
22
26
|
/** When true, preserve envelope headers (channel, timestamp, sender metadata) on user messages.
|
|
@@ -36,7 +36,8 @@ import { requestOtp, verifyOtp } from "./public-chat/otp.js";
|
|
|
36
36
|
import { deliverOtp } from "./public-chat/deliver-otp.js";
|
|
37
37
|
import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
|
|
38
38
|
import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
|
|
39
|
-
import {
|
|
39
|
+
import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
|
|
40
|
+
import { resolveWorkspaceRoot } from "./media-http.js";
|
|
40
41
|
import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
// Helpers
|
|
@@ -597,7 +598,7 @@ async function handleChatHistory(req, res) {
|
|
|
597
598
|
sendInvalidRequest(res, "X-Session-Key header required");
|
|
598
599
|
return;
|
|
599
600
|
}
|
|
600
|
-
const { storePath, entry } = loadSessionEntry(sessionKey);
|
|
601
|
+
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
|
|
601
602
|
const sessionId = entry?.sessionId;
|
|
602
603
|
let rawMessages = [];
|
|
603
604
|
if (entry && storePath) {
|
|
@@ -620,7 +621,8 @@ async function handleChatHistory(req, res) {
|
|
|
620
621
|
const limitParam = url.searchParams.get("limit");
|
|
621
622
|
const requested = limitParam ? Math.min(10_000, Math.max(1, Number(limitParam) || 5000)) : 5000;
|
|
622
623
|
const messages = rawMessages.length > requested ? rawMessages.slice(-requested) : rawMessages;
|
|
623
|
-
const
|
|
624
|
+
const workspaceRoot = resolveWorkspaceRoot(cfg);
|
|
625
|
+
const sanitized = stripEnvelopeFromMessages(sanitizeMediaForChat(messages, workspaceRoot));
|
|
624
626
|
sendJson(res, 200, {
|
|
625
627
|
session_key: sessionKey,
|
|
626
628
|
messages: sanitized,
|
|
@@ -13,6 +13,7 @@ import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalize
|
|
|
13
13
|
import { applyHookMappings } from "./hooks-mapping.js";
|
|
14
14
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
|
15
15
|
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
|
16
|
+
import { handleMediaRequest } from "./media-http.js";
|
|
16
17
|
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
|
17
18
|
function sendJson(res, status, body) {
|
|
18
19
|
res.statusCode = status;
|
|
@@ -226,6 +227,8 @@ export function createGatewayHttpServer(opts) {
|
|
|
226
227
|
}))
|
|
227
228
|
return;
|
|
228
229
|
}
|
|
230
|
+
if (handleMediaRequest(req, res, { config: configSnapshot }))
|
|
231
|
+
return;
|
|
229
232
|
if (canvasHost) {
|
|
230
233
|
if (await handleA2uiHttpRequest(req, res))
|
|
231
234
|
return;
|
|
@@ -15,7 +15,8 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
|
|
15
15
|
import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
|
|
16
16
|
import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
|
|
17
17
|
import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from "../session-utils.js";
|
|
18
|
-
import {
|
|
18
|
+
import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
|
19
|
+
import { resolveWorkspaceRoot } from "../media-http.js";
|
|
19
20
|
import { formatForLog } from "../ws-log.js";
|
|
20
21
|
function resolveTranscriptPath(params) {
|
|
21
22
|
const { sessionId, storePath, sessionFile } = params;
|
|
@@ -138,7 +139,7 @@ export const chatHandlers = {
|
|
|
138
139
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`));
|
|
139
140
|
return;
|
|
140
141
|
}
|
|
141
|
-
const { sessionKey, limit, sessionId: requestedSessionId, preserveEnvelopes, } = params;
|
|
142
|
+
const { sessionKey, limit, offset, sessionId: requestedSessionId, preserveEnvelopes, } = params;
|
|
142
143
|
const publicError = validatePublicSessionAccess(client?.connect?.role, sessionKey);
|
|
143
144
|
if (publicError) {
|
|
144
145
|
respond(false, undefined, publicError);
|
|
@@ -184,13 +185,18 @@ export const chatHandlers = {
|
|
|
184
185
|
rawMessages.push(...current);
|
|
185
186
|
}
|
|
186
187
|
}
|
|
188
|
+
const totalMessages = rawMessages.length;
|
|
187
189
|
const hardMax = 10_000;
|
|
188
190
|
const defaultLimit = 5000;
|
|
189
191
|
const requested = typeof limit === "number" ? limit : defaultLimit;
|
|
190
192
|
const max = Math.min(hardMax, requested);
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const
|
|
193
|
+
const skip = typeof offset === "number" ? Math.min(offset, totalMessages) : 0;
|
|
194
|
+
const end = totalMessages - skip;
|
|
195
|
+
const start = Math.max(0, end - max);
|
|
196
|
+
const messages = start === 0 && end === totalMessages ? rawMessages : rawMessages.slice(start, end);
|
|
197
|
+
const workspaceRoot = resolveWorkspaceRoot(cfg);
|
|
198
|
+
const withMediaUrls = sanitizeMediaForChat(messages, workspaceRoot);
|
|
199
|
+
const sanitized = preserveEnvelopes ? withMediaUrls : stripEnvelopeFromMessages(withMediaUrls);
|
|
194
200
|
// Diagnostic: log resolution details so we can trace "lost history" reports.
|
|
195
201
|
const prevCount = entry?.previousSessions?.length ?? 0;
|
|
196
202
|
context.logGateway.info(`chat.history: sessionKey=${sessionKey} resolvedSessionId=${sessionId ?? "none"} storePath=${storePath ?? "none"} entryExists=${!!entry} previousSessions=${prevCount} rawMessages=${rawMessages.length} sent=${sanitized.length}`);
|
|
@@ -219,6 +225,7 @@ export const chatHandlers = {
|
|
|
219
225
|
sessionKey,
|
|
220
226
|
sessionId,
|
|
221
227
|
messages: sanitized,
|
|
228
|
+
totalMessages,
|
|
222
229
|
thinkingLevel,
|
|
223
230
|
modelProvider,
|
|
224
231
|
model: modelId,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC handlers for WiFi network management on Linux (Raspberry Pi).
|
|
3
|
+
* Uses nmcli (NetworkManager CLI) which ships with Raspberry Pi OS Bookworm.
|
|
4
|
+
* All methods require operator.admin scope (enforced by the catch-all in server-methods.ts).
|
|
5
|
+
*/
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { runExec } from "../../process/exec.js";
|
|
8
|
+
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
function requireLinux(respond) {
|
|
13
|
+
if (os.platform() !== "linux") {
|
|
14
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "WiFi management is only available on Linux"));
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
async function nmcliAvailable() {
|
|
20
|
+
try {
|
|
21
|
+
await runExec("nmcli", ["--version"], { timeoutMs: 3_000 });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse nmcli terse output for wifi list.
|
|
30
|
+
* Fields: IN-USE, SSID, SIGNAL, SECURITY
|
|
31
|
+
* Terse format uses `:` as separator; SSIDs containing `:` are escaped by nmcli
|
|
32
|
+
* with `\:`, so we split carefully.
|
|
33
|
+
*/
|
|
34
|
+
function parseWifiList(stdout) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const networks = [];
|
|
37
|
+
for (const line of stdout.split("\n")) {
|
|
38
|
+
if (!line.trim())
|
|
39
|
+
continue;
|
|
40
|
+
// nmcli terse output escapes colons in SSIDs as \:
|
|
41
|
+
// Split on unescaped colons by replacing \: with a placeholder first
|
|
42
|
+
const placeholder = "\x00";
|
|
43
|
+
const safe = line.replace(/\\:/g, placeholder);
|
|
44
|
+
const parts = safe.split(":");
|
|
45
|
+
if (parts.length < 4)
|
|
46
|
+
continue;
|
|
47
|
+
const inUse = parts[0].replace(new RegExp(placeholder, "g"), ":").trim() === "*";
|
|
48
|
+
const ssid = parts[1].replace(new RegExp(placeholder, "g"), ":").trim();
|
|
49
|
+
const signal = parseInt(parts[2], 10);
|
|
50
|
+
const security = parts.slice(3).join(":").replace(new RegExp(placeholder, "g"), ":").trim();
|
|
51
|
+
if (!ssid)
|
|
52
|
+
continue; // Skip hidden/empty SSIDs
|
|
53
|
+
if (seen.has(ssid))
|
|
54
|
+
continue; // Deduplicate
|
|
55
|
+
seen.add(ssid);
|
|
56
|
+
networks.push({
|
|
57
|
+
ssid,
|
|
58
|
+
signal: isNaN(signal) ? 0 : signal,
|
|
59
|
+
security: security || "Open",
|
|
60
|
+
active: inUse,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Sort by signal strength descending (strongest first)
|
|
64
|
+
networks.sort((a, b) => b.signal - a.signal);
|
|
65
|
+
return networks;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get the IPv4 address of the active WiFi interface.
|
|
69
|
+
*/
|
|
70
|
+
async function getWifiIp() {
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await runExec("nmcli", ["-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0"], {
|
|
73
|
+
timeoutMs: 5_000,
|
|
74
|
+
});
|
|
75
|
+
// Output like: IP4.ADDRESS[1]:192.168.1.100/24
|
|
76
|
+
for (const line of stdout.split("\n")) {
|
|
77
|
+
if (line.startsWith("IP4.ADDRESS")) {
|
|
78
|
+
const addr = line.split(":")[1]?.split("/")[0]?.trim();
|
|
79
|
+
if (addr)
|
|
80
|
+
return addr;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Fallback: not critical
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Handlers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
export const wifiHandlers = {
|
|
93
|
+
/**
|
|
94
|
+
* Return current WiFi connection status.
|
|
95
|
+
*/
|
|
96
|
+
"wifi.status": async ({ respond, context }) => {
|
|
97
|
+
if (!requireLinux(respond))
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
if (!(await nmcliAvailable())) {
|
|
101
|
+
respond(true, {
|
|
102
|
+
available: false,
|
|
103
|
+
connected: false,
|
|
104
|
+
ssid: null,
|
|
105
|
+
signal: null,
|
|
106
|
+
ip: null,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Check active WiFi connection
|
|
111
|
+
const { stdout } = await runExec("nmcli", ["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "dev", "wifi", "list"], { timeoutMs: 10_000 });
|
|
112
|
+
const networks = parseWifiList(stdout);
|
|
113
|
+
const active = networks.find((n) => n.active);
|
|
114
|
+
const ip = active ? await getWifiIp() : null;
|
|
115
|
+
respond(true, {
|
|
116
|
+
available: true,
|
|
117
|
+
connected: !!active,
|
|
118
|
+
ssid: active?.ssid ?? null,
|
|
119
|
+
signal: active?.signal ?? null,
|
|
120
|
+
ip,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
context.logGateway.warn(`wifi.status failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
125
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Failed to check WiFi status"));
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
/**
|
|
129
|
+
* Scan for available WiFi networks. Triggers a rescan.
|
|
130
|
+
*/
|
|
131
|
+
"wifi.scan": async ({ respond, context }) => {
|
|
132
|
+
if (!requireLinux(respond))
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
if (!(await nmcliAvailable())) {
|
|
136
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "NetworkManager (nmcli) is not installed"));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const { stdout } = await runExec("nmcli", ["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "dev", "wifi", "list", "--rescan", "yes"], { timeoutMs: 15_000 });
|
|
140
|
+
const networks = parseWifiList(stdout);
|
|
141
|
+
respond(true, { networks });
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
context.logGateway.warn(`wifi.scan failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
145
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Failed to scan WiFi networks"));
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
/**
|
|
149
|
+
* Connect to a WiFi network.
|
|
150
|
+
* Params: { ssid: string, password?: string }
|
|
151
|
+
*/
|
|
152
|
+
"wifi.connect": async ({ params, respond, context }) => {
|
|
153
|
+
if (!requireLinux(respond))
|
|
154
|
+
return;
|
|
155
|
+
const ssid = typeof params.ssid === "string" ? params.ssid.trim() : "";
|
|
156
|
+
const password = typeof params.password === "string" ? params.password : "";
|
|
157
|
+
if (!ssid) {
|
|
158
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "Missing required parameter: ssid"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
if (!(await nmcliAvailable())) {
|
|
163
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "NetworkManager (nmcli) is not installed"));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const args = ["dev", "wifi", "connect", ssid];
|
|
167
|
+
if (password) {
|
|
168
|
+
args.push("password", password);
|
|
169
|
+
}
|
|
170
|
+
const { stdout, stderr } = await runExec("nmcli", args, { timeoutMs: 30_000 });
|
|
171
|
+
// nmcli prints "Device 'wlan0' successfully activated..." on success
|
|
172
|
+
const combined = `${stdout}\n${stderr}`;
|
|
173
|
+
const success = combined.includes("successfully activated") || combined.includes("successfully added");
|
|
174
|
+
if (success) {
|
|
175
|
+
const ip = await getWifiIp();
|
|
176
|
+
respond(true, { connected: true, ssid, ip });
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// nmcli returned 0 but no success message — treat as unexpected
|
|
180
|
+
context.logGateway.warn(`wifi.connect: unexpected output: ${combined.slice(0, 300)}`);
|
|
181
|
+
respond(true, { connected: true, ssid, ip: null });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const errObj = err;
|
|
186
|
+
const detail = errObj.stderr?.trim() || errObj.message || "Connection failed";
|
|
187
|
+
context.logGateway.warn(`wifi.connect failed for "${ssid}": ${detail}`);
|
|
188
|
+
// Surface user-friendly error from nmcli stderr
|
|
189
|
+
let message = "Failed to connect to WiFi network";
|
|
190
|
+
if (detail.includes("Secrets were required")) {
|
|
191
|
+
message = "Incorrect password";
|
|
192
|
+
}
|
|
193
|
+
else if (detail.includes("No network with SSID")) {
|
|
194
|
+
message = "Network not found — try scanning again";
|
|
195
|
+
}
|
|
196
|
+
else if (detail.includes("not found")) {
|
|
197
|
+
message = "WiFi adapter not found";
|
|
198
|
+
}
|
|
199
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
@@ -34,6 +34,7 @@ import { webHandlers } from "./server-methods/web.js";
|
|
|
34
34
|
import { wizardHandlers } from "./server-methods/wizard.js";
|
|
35
35
|
import { publicChatHandlers } from "./server-methods/public-chat.js";
|
|
36
36
|
import { tailscaleHandlers } from "./server-methods/tailscale.js";
|
|
37
|
+
import { wifiHandlers } from "./server-methods/wifi.js";
|
|
37
38
|
import { workspacesHandlers } from "./server-methods/workspaces.js";
|
|
38
39
|
const ADMIN_SCOPE = "operator.admin";
|
|
39
40
|
const READ_SCOPE = "operator.read";
|
|
@@ -237,6 +238,7 @@ export const coreGatewayHandlers = {
|
|
|
237
238
|
...workspacesHandlers,
|
|
238
239
|
...publicChatHandlers,
|
|
239
240
|
...tailscaleHandlers,
|
|
241
|
+
...wifiHandlers,
|
|
240
242
|
};
|
|
241
243
|
export async function handleGatewayRequest(opts) {
|
|
242
244
|
const { req, respond, client, isWebchatConnect, context } = opts;
|