@memtensor/memos-cloud-openclaw-plugin 0.1.12-beta.0 → 0.1.12

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.
@@ -1,713 +1,713 @@
1
-
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
- import { createServer } from "node:http";
5
- import { homedir } from "node:os";
6
- import { dirname, join } from "node:path";
7
- import { fileURLToPath } from "node:url";
8
- import { Script } from "node:vm";
9
- import { getConfigResolution } from "./memos-cloud-api.js";
10
-
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
-
13
- const PLUGIN_ID = "memos-cloud-openclaw-plugin";
14
- const UI_HOST = "127.0.0.1";
15
- const UI_BASE_PORT = 38463;
16
- const UI_PORT_ATTEMPTS = 24;
17
- const GLOBAL_STATE_KEY = "__memosCloudConfigUiState";
18
- const ASSET_DIR = join(__dirname, "config-ui");
19
- const ANSI_BOLD = "\x1b[1m";
20
- const ANSI_CYAN = "\x1b[36m";
21
- const ANSI_GREEN = "\x1b[32m";
22
- const ANSI_RESET = "\x1b[0m";
23
- const DEFAULT_GATEWAY_READY_PORT = 18789;
24
-
25
- const FIELD_GROUPS = [
26
- { id: "connection", title: "Connection", description: "MemOS endpoint, authentication, and identity mapping." },
27
- { id: "session", title: "Session And Recall", description: "Conversation id strategy, recall scope, and injection behavior." },
28
- { id: "capture", title: "Capture And Storage", description: "What gets written back to MemOS after each agent run." },
29
- { id: "agent", title: "Agent Isolation", description: "Multi-agent isolation, app metadata, and sharing permissions." },
30
- { id: "filter", title: "Recall Filter", description: "Optional model-based second-pass filtering before memories are injected." },
31
- { id: "advanced", title: "Advanced", description: "Timeouts, throttling, and low-level controls." },
32
- ];
33
-
34
- const FIELD_DEFINITIONS = [
35
- { key: "baseUrl", group: "connection", type: "string", label: "MemOS Base URL", description: "Base URL for the MemOS OpenMem API.", placeholder: "https://memos.memtensor.cn/api/openmem/v1" },
36
- { key: "apiKey", group: "connection", type: "secret", label: "MemOS API Key", description: "Token auth key. Leave inherited to use env files.", placeholder: "mpg-..." },
37
- { key: "userId", group: "connection", type: "string", label: "User ID", description: "Unique identifier of the user associated with added messages and queried memories.", placeholder: "openclaw-user" },
38
- { key: "useDirectSessionUserId", group: "connection", type: "boolean", label: "Use Direct Session User ID", description: "Use direct-session user id from session key when available." },
39
- { key: "conversationId", group: "session", type: "string", label: "Conversation ID Override", description: "Unique identifier of the conversation. Reusing the same value keeps turns in the same context." },
40
- { key: "conversationIdPrefix", group: "session", type: "string", label: "Conversation Prefix", description: "Prepended to the derived conversation id." },
41
- { key: "conversationIdSuffix", group: "session", type: "string", label: "Conversation Suffix", description: "Appended to the derived conversation id." },
42
- { key: "conversationSuffixMode", group: "session", type: "enum", label: "Suffix Mode", description: "Choose whether /new increments a numeric suffix.", options: [{ value: "none", label: "none" }, { value: "counter", label: "counter" }] },
43
- { key: "resetOnNew", group: "session", type: "boolean", label: "Reset On /new", description: "Requires hooks.internal.enabled when counter suffix mode is used." },
44
- { key: "queryPrefix", group: "session", type: "textarea", rows: 4, label: "Query Prefix", description: "Extra text prepended to query before retrieval.", placeholder: "important user context preferences decisions " },
45
- { key: "maxQueryChars", group: "session", type: "integer", label: "Max Query Chars", description: "Limit the query text length before sending recall search.", placeholder: "0" },
46
- { key: "recallEnabled", group: "session", type: "boolean", label: "Recall Enabled", description: "Enable before_agent_start memory recall." },
47
- { key: "recallGlobal", group: "session", type: "boolean", label: "Global Recall", description: "When enabled, query is sent without conversation_id, so current-session weighting is not emphasized." },
48
- { key: "maxItemChars", group: "session", type: "integer", label: "Max Item Chars", description: "Maximum characters kept when injecting each recalled memory item into context.", placeholder: "8000" },
49
- { key: "memoryLimitNumber", group: "session", type: "integer", label: "Memory Limit", description: "Maximum number of recalled memories. Default is 9, max is 25.", placeholder: "9" },
50
- { key: "preferenceLimitNumber", group: "session", type: "integer", label: "Preference Limit", description: "Maximum number of recalled preference memories. Default is 9, max is 25.", placeholder: "9" },
51
- { key: "includePreference", group: "session", type: "boolean", label: "Include Preferences", description: "Whether to enable preference memory recall." },
52
- { key: "includeToolMemory", group: "session", type: "boolean", label: "Include Tool Memory", description: "Whether to enable tool memory recall." },
53
- { key: "toolMemoryLimitNumber", group: "session", type: "integer", label: "Tool Memory Limit", description: "Maximum number of tool memories returned. Effective only when tool memory recall is enabled.", placeholder: "6" },
54
- { key: "relativity", group: "session", type: "number", label: "Relativity Threshold", description: "Recall relevance threshold from 0 to 1. Set to 0 to disable relevance filtering.", placeholder: "0.45", step: "0.01" },
55
- { key: "filter", group: "session", type: "json", rows: 7, label: "Search Filter (JSON)", description: "Filter conditions used before retrieval. Supports agent_id, app_id, time fields, info fields, and and/or/gte/lte/gt/lt.", placeholder: '{\n "agent_id": "assistant-1"\n}' },
56
- { key: "knowledgebaseIds", group: "session", type: "stringArray", rows: 4, label: "Knowledge Base IDs", description: "Restrict the knowledgebase scope for this search. Use one ID per line, or all.", placeholder: "kb-001\nkb-002" },
57
- { key: "addEnabled", group: "capture", type: "boolean", label: "Add Enabled", description: "Enable adding message arrays and writing resulting memories at agent_end." },
58
- { key: "captureStrategy", group: "capture", type: "enum", label: "Capture Strategy", description: "Choose whether messages contains only the last turn or the full session.", options: [{ value: "last_turn", label: "last_turn" }, { value: "full_session", label: "full_session" }] },
59
- { key: "maxMessageChars", group: "capture", type: "integer", label: "Max Message Chars", description: "Maximum characters kept per stored message before building the messages array.", placeholder: "20000" },
60
- { key: "includeAssistant", group: "capture", type: "boolean", label: "Include Assistant", description: "Include assistant replies in the messages array." },
61
- { key: "tags", group: "capture", type: "stringArray", rows: 4, label: "Tags", description: "Custom tags used to classify added messages. One value per line.", placeholder: "openclaw" },
62
- { key: "info", group: "capture", type: "json", rows: 7, label: "Info Payload (JSON)", description: "Structured metadata merged into info for filtering, tracing, and source tracking.", placeholder: '{\n "channel": "webchat"\n}' },
63
- { key: "asyncMode", group: "capture", type: "boolean", label: "Async Mode", description: "Add memories asynchronously in the background to avoid blocking the call chain." },
64
- { key: "agentId", group: "agent", type: "string", label: "Static Agent ID", description: "Unique identifier of the Agent associated with added messages and retrieved memories." },
65
- { key: "multiAgentMode", group: "agent", type: "boolean", label: "Multi-Agent Mode", description: "Isolate recall and add payloads by ctx.agentId when available." },
66
- { key: "allowedAgents", group: "agent", type: "stringArray", rows: 4, label: "Allowed Agents", description: "Only listed agent ids are allowed to recall and add; empty means all agents." },
67
- { key: "agentOverrides", group: "agent", type: "json", rows: 10, label: "Agent Overrides (JSON)", description: "Per-agent overrides. Key is agent id, value is an object of supported override fields.", placeholder: '{\n "assistant-1": {\n "knowledgebaseIds": ["kb-001"],\n "recallEnabled": true\n }\n}' },
68
- { key: "appId", group: "agent", type: "string", label: "App ID", description: "Unique identifier of the App associated with added messages and retrieved memories." },
69
- { key: "allowPublic", group: "agent", type: "boolean", label: "Allow Public", description: "Allow generated memories to be written to the public memory store." },
70
- { key: "allowKnowledgebaseIds", group: "agent", type: "stringArray", rows: 4, label: "Allowed Knowledge Base IDs", description: "Knowledgebase scope where generated memories are allowed to be written. One ID per line.", placeholder: "kb-public\nkb-team" },
71
- { key: "recallFilterEnabled", group: "filter", type: "boolean", label: "Recall Filter Enabled", description: "Enable second-pass model filtering for recall candidates." },
72
- { key: "recallFilterBaseUrl", group: "filter", type: "string", label: "Filter Base URL", description: "OpenAI-compatible endpoint used for recall filtering.", placeholder: "http://127.0.0.1:11434/v1" },
73
- { key: "recallFilterApiKey", group: "filter", type: "secret", label: "Filter API Key", description: "Optional bearer token for the recall filter model endpoint." },
74
- { key: "recallFilterModel", group: "filter", type: "string", label: "Filter Model", description: "Model name used by the recall filter endpoint.", placeholder: "qwen2.5:7b" },
75
- { key: "recallFilterTimeoutMs", group: "filter", type: "integer", label: "Filter Timeout (ms)", description: "Request timeout for the recall filter model.", placeholder: "6000" },
76
- { key: "recallFilterRetries", group: "filter", type: "integer", label: "Filter Retries", description: "Retry count when the recall filter request fails.", placeholder: "0" },
77
- { key: "recallFilterCandidateLimit", group: "filter", type: "integer", label: "Candidate Limit", description: "Per-category candidate limit before filtering.", placeholder: "30" },
78
- { key: "recallFilterMaxItemChars", group: "filter", type: "integer", label: "Filter Max Item Chars", description: "Maximum characters kept per candidate item before filtering.", placeholder: "500" },
79
- { key: "recallFilterFailOpen", group: "filter", type: "boolean", label: "Fail Open", description: "Fall back to unfiltered recall if the filter model errors." },
80
- { key: "timeoutMs", group: "advanced", type: "integer", label: "MemOS Timeout (ms)", description: "Timeout used for MemOS API requests.", placeholder: "5000" },
81
- { key: "retries", group: "advanced", type: "integer", label: "MemOS Retries", description: "Retry count for MemOS API requests.", placeholder: "1" },
82
- { key: "throttleMs", group: "advanced", type: "integer", label: "Throttle (ms)", description: "Skip add/message when the previous capture happened too recently.", placeholder: "0" },
83
- ];
84
-
85
- function getGlobalState() {
86
- if (!globalThis[GLOBAL_STATE_KEY]) {
87
- globalThis[GLOBAL_STATE_KEY] = {
88
- promise: null,
89
- service: null,
90
- cleanupInstalled: false,
91
- restartHookInstalled: false,
92
- restartTimer: null,
93
- restartPending: false,
94
- recyclePromise: null,
95
- shuttingDown: false,
96
- child: null,
97
- };
98
- }
99
- return globalThis[GLOBAL_STATE_KEY];
100
- }
101
-
102
- function isPlainObject(value) {
103
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
104
- }
105
-
106
- function deepClone(value) {
107
- if (value === undefined) return undefined;
108
- return JSON.parse(JSON.stringify(value));
109
- }
110
-
111
- function sanitizeStructuredValue(value, depth = 0) {
112
- if (depth > 16) throw new Error("Config payload is too deeply nested.");
113
- if (value === null) return null;
114
- if (typeof value === "string" || typeof value === "boolean") return value;
115
- if (typeof value === "number") {
116
- if (!Number.isFinite(value)) throw new Error("Config payload contains a non-finite number.");
117
- return value;
118
- }
119
- if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item, depth + 1));
120
- if (isPlainObject(value)) {
121
- const next = {};
122
- for (const [key, child] of Object.entries(value)) {
123
- const normalized = sanitizeStructuredValue(child, depth + 1);
124
- if (normalized !== undefined) next[key] = normalized;
125
- }
126
- return next;
127
- }
128
- if (value === undefined) return undefined;
129
- throw new Error("Config payload contains an unsupported value type.");
130
- }
131
-
132
- function sortForHash(value) {
133
- if (Array.isArray(value)) return value.map((item) => sortForHash(item));
134
- if (isPlainObject(value)) {
135
- return Object.keys(value)
136
- .sort()
137
- .reduce((acc, key) => {
138
- acc[key] = sortForHash(value[key]);
139
- return acc;
140
- }, {});
141
- }
142
- return value;
143
- }
144
-
145
- function createRevision(value) {
146
- return createHash("sha1").update(JSON.stringify(sortForHash(value))).digest("hex").slice(0, 12);
147
- }
148
-
149
- function detectRuntimeProfile() {
150
- const scriptPath = String(process.argv[1] || "").toLowerCase();
151
- const execPath = String(process.execPath || "").toLowerCase();
152
-
153
- if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) {
154
- return { id: "moltbot", displayName: "Moltbot", cliName: "moltbot", configPath: join(homedir(), ".moltbot", "moltbot.json") };
155
- }
156
- if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) {
157
- return { id: "clawdbot", displayName: "ClawDBot", cliName: "clawdbot", configPath: join(homedir(), ".clawdbot", "clawdbot.json") };
158
- }
159
- return { id: "openclaw", displayName: "OpenClaw", cliName: "openclaw", configPath: join(homedir(), ".openclaw", "openclaw.json") };
160
- }
161
-
162
- function parsePositiveInteger(value, fallback) {
163
- const parsed = Number(value);
164
- if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
165
- return fallback;
166
- }
167
-
168
- function resolveGatewayReadyProbeTarget(rootConfig = {}) {
169
- const gateway = isPlainObject(rootConfig?.gateway) ? rootConfig.gateway : {};
170
- const port = parsePositiveInteger(gateway.port, DEFAULT_GATEWAY_READY_PORT);
171
- const bind = typeof gateway.bind === "string" ? gateway.bind.trim().toLowerCase() : "";
172
- const customBindHost = typeof gateway.customBindHost === "string" ? gateway.customBindHost.trim() : "";
173
- const host = bind === "custom" && customBindHost ? customBindHost : "127.0.0.1";
174
- return { host, port, url: `http://${host}:${port}/ready` };
175
- }
176
-
177
- export async function waitForGatewayReady(rootConfig = {}, log = console, options = {}) {
178
- const timeoutMs = parsePositiveInteger(options.timeoutMs, 45000);
179
- const intervalMs = parsePositiveInteger(options.intervalMs, 300);
180
- const deadline = Date.now() + timeoutMs;
181
- const target = resolveGatewayReadyProbeTarget(rootConfig);
182
-
183
- while (Date.now() < deadline) {
184
- try {
185
- const response = await fetch(target.url, {
186
- method: "GET",
187
- cache: "no-store",
188
- });
189
- if (response.ok) {
190
- let body = null;
191
- try {
192
- body = await response.json();
193
- } catch {
194
- body = null;
195
- }
196
- if (!body || body.ready !== false) return true;
197
- }
198
- } catch {
199
- // Ignore probe failures until timeout expires.
200
- }
201
-
202
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
203
- }
204
-
205
- log.warn?.(`[memos-cloud] Gateway readiness probe timed out at ${target.url}; config UI will not start yet.`);
206
- return false;
207
- }
208
-
209
- function shouldStartConfigUi() {
210
- const args = process.argv.map((value) => String(value || "").toLowerCase());
211
- const gatewayIndex = args.lastIndexOf("gateway");
212
- if (gatewayIndex === -1) return false;
213
-
214
- const nextArg = args[gatewayIndex + 1];
215
- if (!nextArg || nextArg.startsWith("-")) return true;
216
- return nextArg === "start" || nextArg === "restart";
217
- }
218
-
219
- function stripBom(text) {
220
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
221
- }
222
-
223
- function parseJson5File(text, filePath) {
224
- const source = stripBom(String(text || "")).trim();
225
- if (!source) return {};
226
-
227
- try {
228
- const parsed = JSON.parse(source);
229
- if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
230
- return parsed;
231
- } catch {
232
- const script = new Script(`(${source}\n)`, { filename: filePath });
233
- const parsed = script.runInNewContext(Object.create(null), { timeout: 500 });
234
- if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
235
- return parsed;
236
- }
237
- }
238
-
239
- function hasIncludeDirective(value, depth = 0) {
240
- if (depth > 8) return false;
241
- if (Array.isArray(value)) return value.some((item) => hasIncludeDirective(item, depth + 1));
242
- if (!isPlainObject(value)) return false;
243
- if (Object.prototype.hasOwnProperty.call(value, "$include")) return true;
244
- return Object.values(value).some((child) => hasIncludeDirective(child, depth + 1));
245
- }
246
-
247
- function readGatewayConfig(profile) {
248
- let root = {};
249
- let fileExists = true;
250
- let stat = null;
251
-
252
- try {
253
- root = parseJson5File(readFileSync(profile.configPath, "utf8"), profile.configPath);
254
- stat = statSync(profile.configPath);
255
- } catch (error) {
256
- if (error?.code !== "ENOENT") throw error;
257
- fileExists = false;
258
- }
259
-
260
- const plugins = isPlainObject(root.plugins) ? root.plugins : {};
261
- const entries = isPlainObject(plugins.entries) ? plugins.entries : {};
262
- const entry = isPlainObject(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : null;
263
- const config = entry && isPlainObject(entry.config) ? deepClone(entry.config) : {};
264
- const enabled = entry ? entry.enabled !== false : true;
265
-
266
- return {
267
- profile,
268
- fileExists,
269
- configPath: profile.configPath,
270
- entryExists: Boolean(entry),
271
- enabled,
272
- config,
273
- root,
274
- hasInclude: hasIncludeDirective(root.plugins) || hasIncludeDirective(root),
275
- revision: createRevision({
276
- config,
277
- enabled,
278
- entryExists: Boolean(entry),
279
- fileExists,
280
- mtimeMs: stat?.mtimeMs ?? 0,
281
- size: stat?.size ?? 0,
282
- }),
283
- };
284
- }
285
-
286
- function writeGatewayConfig(profile, payload) {
287
- const current = readGatewayConfig(profile);
288
- const nextRoot = isPlainObject(current.root) ? deepClone(current.root) : {};
289
- if (!isPlainObject(nextRoot.plugins)) nextRoot.plugins = {};
290
- if (!isPlainObject(nextRoot.plugins.entries)) nextRoot.plugins.entries = {};
291
-
292
- const entry = { enabled: payload.enabled !== false };
293
- const config = sanitizeStructuredValue(payload.config ?? {});
294
- if (!isPlainObject(config)) throw new Error("Config payload must be an object.");
295
- if (Object.keys(config).length > 0) entry.config = config;
296
-
297
- nextRoot.plugins.entries[PLUGIN_ID] = entry;
298
-
299
- mkdirSync(dirname(profile.configPath), { recursive: true });
300
- writeFileSync(profile.configPath, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf8");
301
- return readGatewayConfig(profile);
302
- }
303
-
304
- function buildStatePayload(service) {
305
- const state = readGatewayConfig(service.profile);
306
- const resolution = getConfigResolution(state.config);
307
- return {
308
- runtime: state.profile.id,
309
- runtimeDisplayName: state.profile.displayName,
310
- configPath: state.configPath,
311
- entryExists: state.entryExists,
312
- fileExists: state.fileExists,
313
- enabled: state.enabled,
314
- config: state.config,
315
- resolvedConfig: resolution.resolved,
316
- fieldMeta: resolution.fieldMeta,
317
- revision: state.revision,
318
- hasInclude: state.hasInclude,
319
- bootId: service.bootId,
320
- assetRevision: getAssetRevision(),
321
- port: service.port,
322
- url: service.url,
323
- };
324
- }
325
-
326
- function getCachedStatePayload(service, maxAgeMs = 1200) {
327
- const now = Date.now();
328
- if (service.stateCache && now - service.stateCache.createdAt < maxAgeMs) {
329
- return service.stateCache.payload;
330
- }
331
- const payload = buildStatePayload(service);
332
- service.stateCache = {
333
- createdAt: now,
334
- payload,
335
- };
336
- return payload;
337
- }
338
-
339
- function getCachedHeartbeatPayload(service, maxAgeMs = 1200) {
340
- const now = Date.now();
341
- if (service.heartbeatCache && now - service.heartbeatCache.createdAt < maxAgeMs) {
342
- return {
343
- ...service.heartbeatCache.payload,
344
- timestamp: now,
345
- };
346
- }
347
- const payload = {
348
- ok: true,
349
- runtime: service.profile.id,
350
- bootId: service.bootId,
351
- assetRevision: getAssetRevision(),
352
- };
353
- service.heartbeatCache = {
354
- createdAt: now,
355
- payload,
356
- };
357
- return {
358
- ...payload,
359
- timestamp: now,
360
- };
361
- }
362
-
363
- function loadAssetTemplate(name) {
364
- return readFileSync(join(ASSET_DIR, name), "utf8");
365
- }
366
-
367
- function getAssetRevision() {
368
- const files = ["index.html", "app.js", "app.css"];
369
- const parts = [];
370
- for (const name of files) {
371
- try {
372
- const stat = statSync(join(ASSET_DIR, name));
373
- parts.push(`${name}:${stat.mtimeMs}:${stat.size}`);
374
- } catch {
375
- parts.push(`${name}:missing`);
376
- }
377
- }
378
- return createRevision(parts);
379
- }
380
-
381
- function replaceTokens(template, tokenMap) {
382
- let output = template;
383
- for (const [token, value] of Object.entries(tokenMap)) {
384
- output = output.split(token).join(value);
385
- }
386
- return output;
387
- }
388
-
389
- function jsonString(value) {
390
- return JSON.stringify(value).replace(/</g, "\\u003c");
391
- }
392
-
393
- function getAccessibleUrls(port) {
394
- return [`http://127.0.0.1:${port}`];
395
- }
396
-
397
- function centerText(text, width) {
398
- const left = Math.max(0, Math.floor((width - text.length) / 2));
399
- const right = Math.max(0, width - text.length - left);
400
- return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
401
- }
402
-
403
- function padBoxLine(content, visibleLength, width) {
404
- return `${ANSI_GREEN}| ${ANSI_RESET}${content}${" ".repeat(Math.max(0, width - visibleLength))}${ANSI_GREEN} |${ANSI_RESET}`;
405
- }
406
-
407
- function renderConfigAddressBanner(port) {
408
- const urls = getAccessibleUrls(port);
409
- const titleArt = [
410
- " __ __ ______ __ __ ____ _____ ",
411
- " | \\/ | | ____| | \\/ | / __ \\ / ____| ",
412
- " | \\ / | | |__ | \\ / | | | | | | (___ ",
413
- " | |\\/| | | __| | |\\/| | | | | | \\___ \\ ",
414
- " | | | | | |____ | | | | | |__| | ____) | ",
415
- " |_| |_| |______| |_| |_| \\____/ |_____/ "
416
- ];
417
- const heading = "Plugin Configuration";
418
- const urlLines = urls.map((url, index) => ` [${index + 1}] ${url}`);
419
- const plainLines = [
420
- "",
421
- ...titleArt,
422
- "",
423
- heading,
424
- "",
425
- "Plugin configuration page is ready.",
426
- "Open one of the following URLs in your browser:",
427
- "",
428
- ...urlLines,
429
- "",
430
- "Tip: keep this window open while you finish the setup.",
431
- "",
432
- ];
433
- const contentWidth = plainLines.reduce((max, line) => Math.max(max, line.length), 0);
434
- const centeredTitleArt = titleArt.map((line) => centerText(line, contentWidth));
435
- const centeredHeading = centerText(heading, contentWidth);
436
- const visibleLineWidths = [
437
- 0,
438
- ...centeredTitleArt.map(() => contentWidth),
439
- 0,
440
- contentWidth,
441
- contentWidth,
442
- "Plugin configuration page is ready.".length,
443
- "Open one of the following URLs in your browser:".length,
444
- 0,
445
- ...urlLines.map((line) => line.length),
446
- 0,
447
- "Tip: keep this window open while you finish the setup.".length,
448
- 0,
449
- ];
450
- const separator = "-".repeat(contentWidth);
451
- const coloredLines = [
452
- "",
453
- ...centeredTitleArt.map((line) => `${ANSI_BOLD}${ANSI_CYAN}${line}${ANSI_RESET}`),
454
- "",
455
- `${ANSI_BOLD}${centeredHeading}${ANSI_RESET}`,
456
- `${ANSI_GREEN}${separator}${ANSI_RESET}`,
457
- "Plugin configuration page is ready.",
458
- "Open one of the following URLs in your browser:",
459
- "",
460
- ...urlLines.map((line) => `${ANSI_BOLD}${ANSI_GREEN}${line}${ANSI_RESET}`),
461
- "",
462
- "Tip: keep this window open while you finish the setup.",
463
- "",
464
- ];
465
- const horizontalBorder = `+${"=".repeat(contentWidth + 2)}+`;
466
-
467
- return `\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}\n${coloredLines
468
- .map((line, index) => padBoxLine(line, visibleLineWidths[index], contentWidth))
469
- .join("\n")}\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}`;
470
- }
471
-
472
- function renderHtml(service) {
473
- return replaceTokens(loadAssetTemplate("index.html"), {
474
- "__PLUGIN_ID__": PLUGIN_ID,
475
- "__APP_JS_URL__": `/app.js?token=${service.token}`,
476
- });
477
- }
478
-
479
- function renderAppJs(service) {
480
- return replaceTokens(loadAssetTemplate("app.js"), {
481
- "__CONFIG_UI_TOKEN__": jsonString(service.token),
482
- "__FIELD_GROUPS__": jsonString(FIELD_GROUPS),
483
- "__FIELD_DEFINITIONS__": jsonString(FIELD_DEFINITIONS),
484
- });
485
- }
486
-
487
- // Legacy restart functions removed for sandbox compliance
488
-
489
- function listenOnPort(server, host, port) {
490
- return new Promise((resolve, reject) => {
491
- const onError = (error) => {
492
- cleanup();
493
- reject(error);
494
- };
495
- const onListening = () => {
496
- cleanup();
497
- resolve(port);
498
- };
499
- const cleanup = () => {
500
- server.off("error", onError);
501
- server.off("listening", onListening);
502
- };
503
- server.once("error", onError);
504
- server.once("listening", onListening);
505
- server.listen(port, host);
506
- });
507
- }
508
-
509
- async function bindWithFallback(server) {
510
- for (let offset = 0; offset < UI_PORT_ATTEMPTS; offset += 1) {
511
- const port = UI_BASE_PORT + offset;
512
- try {
513
- await listenOnPort(server, UI_HOST, port);
514
- return port;
515
- } catch (error) {
516
- if (error?.code !== "EADDRINUSE") throw error;
517
- }
518
- }
519
- throw new Error(`Could not bind config UI after trying ${UI_PORT_ATTEMPTS} ports from ${UI_BASE_PORT}.`);
520
- }
521
-
522
- function readRequestBody(req) {
523
- return new Promise((resolve, reject) => {
524
- let body = "";
525
- req.setEncoding("utf8");
526
- req.on("data", (chunk) => {
527
- body += chunk;
528
- if (body.length > 1024 * 1024) {
529
- reject(new Error("Request body too large."));
530
- req.destroy();
531
- }
532
- });
533
- req.on("end", () => resolve(body));
534
- req.on("error", reject);
535
- });
536
- }
537
-
538
- function sendJson(res, statusCode, payload) {
539
- res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
540
- res.end(JSON.stringify(payload));
541
- }
542
-
543
- function sendText(res, statusCode, message) {
544
- res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
545
- res.end(message);
546
- }
547
-
548
- function isAuthorized(req, service) {
549
- return req.headers["x-memos-config-token"] === service.token;
550
- }
551
-
552
- async function createService(log) {
553
- const profile = detectRuntimeProfile();
554
- const token = randomBytes(24).toString("hex");
555
- const bootId = randomBytes(10).toString("hex");
556
-
557
- const service = { profile, token, bootId, port: 0, url: "", server: null, stateCache: null, heartbeatCache: null };
558
-
559
- const server = createServer(async (req, res) => {
560
- try {
561
- const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
562
-
563
- if (requestUrl.pathname === "/favicon.ico") {
564
- res.writeHead(204);
565
- res.end();
566
- return;
567
- }
568
-
569
- if (requestUrl.pathname === "/icon.svg") {
570
- res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "no-store" });
571
- res.end(loadAssetTemplate("icon.svg"));
572
- return;
573
- }
574
-
575
- if (requestUrl.pathname === "/app.css") {
576
- res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-store" });
577
- res.end(loadAssetTemplate("app.css"));
578
- return;
579
- }
580
-
581
- if (requestUrl.pathname === "/app.js") {
582
- if (requestUrl.searchParams.get("token") !== service.token) {
583
- sendText(res, 403, "Forbidden");
584
- return;
585
- }
586
- res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store" });
587
- res.end(renderAppJs(service));
588
- return;
589
- }
590
-
591
- if (requestUrl.pathname === "/api/heartbeat" && req.method === "GET") {
592
- sendJson(res, 200, getCachedHeartbeatPayload(service));
593
- return;
594
- }
595
-
596
- if (requestUrl.pathname.startsWith("/api/")) {
597
- if (!isAuthorized(req, service)) {
598
- sendText(res, 403, "Forbidden");
599
- return;
600
- }
601
-
602
- if (requestUrl.pathname === "/api/state" && req.method === "GET") {
603
- sendJson(res, 200, getCachedStatePayload(service));
604
- return;
605
- }
606
-
607
- if (requestUrl.pathname === "/api/save" && req.method === "POST") {
608
- let parsed = {};
609
- try {
610
- parsed = JSON.parse((await readRequestBody(req)) || "{}");
611
- } catch {
612
- sendText(res, 400, "Invalid JSON payload.");
613
- return;
614
- }
615
- if (!isPlainObject(parsed)) {
616
- sendText(res, 400, "Invalid JSON payload.");
617
- return;
618
- }
619
- if (typeof parsed.enabled !== "boolean") {
620
- sendText(res, 400, "Payload.enabled must be a boolean.");
621
- return;
622
- }
623
- if (!isPlainObject(parsed.config)) {
624
- sendText(res, 400, "Payload.config must be an object.");
625
- return;
626
- }
627
-
628
- const nextState = writeGatewayConfig(profile, parsed);
629
- service.stateCache = null;
630
- service.heartbeatCache = null;
631
- sendJson(res, 200, { ok: true, state: buildStatePayload({ ...service, profile: nextState.profile }) });
632
- return;
633
- }
634
-
635
- // Legacy `/api/restart` has been entirely removed
636
- sendText(res, 404, "Not found");
637
- return;
638
- }
639
-
640
- if (requestUrl.pathname !== "/") {
641
- sendText(res, 404, "Not found");
642
- return;
643
- }
644
-
645
- res.writeHead(200, {
646
- "Content-Type": "text/html; charset=utf-8",
647
- "Cache-Control": "no-store",
648
- "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;",
649
- });
650
- res.end(renderHtml(service));
651
- } catch (error) {
652
- sendText(res, 500, `Internal error: ${String(error?.message || error)}`);
653
- }
654
- });
655
-
656
- service.server = server;
657
- service.port = await bindWithFallback(server);
658
- service.url = `http://${UI_HOST}:${service.port}`;
659
-
660
- setTimeout(() => {
661
- console.log(renderConfigAddressBanner(service.port));
662
- }, 1200);
663
- return service;
664
- }
665
-
666
- export function ensureConfigUiService(log = console) {
667
- if (!shouldStartConfigUi()) {
668
- return Promise.resolve(null);
669
- }
670
-
671
- const globalState = getGlobalState();
672
- if (globalState.promise) return globalState.promise;
673
-
674
- globalState.promise = createService(log)
675
- .then((service) => {
676
- globalState.service = service;
677
- return service;
678
- })
679
- .catch((error) => {
680
- globalState.service = null;
681
- globalState.promise = null;
682
- throw error;
683
- });
684
-
685
- return globalState.promise;
686
- }
687
-
688
- export async function closeConfigUiService(options = {}) {
689
- const globalState = getGlobalState();
690
-
691
- if (!globalState.service) {
692
- globalState.promise = null;
693
- return;
694
- }
695
-
696
- const { service } = globalState;
697
- globalState.service = null;
698
- globalState.promise = null;
699
-
700
- if (service?.server) {
701
- await new Promise((resolve) => {
702
- try {
703
- service.server.close(() => resolve());
704
- } catch {
705
- resolve();
706
- }
707
- });
708
- }
709
- }
710
-
711
- export async function runConfigUiChildProcess(log = console) {
712
- // no-op
713
- }
1
+
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { Script } from "node:vm";
9
+ import { getConfigResolution } from "./memos-cloud-api.js";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ const PLUGIN_ID = "memos-cloud-openclaw-plugin";
14
+ const UI_HOST = "127.0.0.1";
15
+ const UI_BASE_PORT = 38463;
16
+ const UI_PORT_ATTEMPTS = 24;
17
+ const GLOBAL_STATE_KEY = "__memosCloudConfigUiState";
18
+ const ASSET_DIR = join(__dirname, "config-ui");
19
+ const ANSI_BOLD = "\x1b[1m";
20
+ const ANSI_CYAN = "\x1b[36m";
21
+ const ANSI_GREEN = "\x1b[32m";
22
+ const ANSI_RESET = "\x1b[0m";
23
+ const DEFAULT_GATEWAY_READY_PORT = 18789;
24
+
25
+ const FIELD_GROUPS = [
26
+ { id: "connection", title: "Connection", description: "MemOS endpoint, authentication, and identity mapping." },
27
+ { id: "session", title: "Session And Recall", description: "Conversation id strategy, recall scope, and injection behavior." },
28
+ { id: "capture", title: "Capture And Storage", description: "What gets written back to MemOS after each agent run." },
29
+ { id: "agent", title: "Agent Isolation", description: "Multi-agent isolation, app metadata, and sharing permissions." },
30
+ { id: "filter", title: "Recall Filter", description: "Optional model-based second-pass filtering before memories are injected." },
31
+ { id: "advanced", title: "Advanced", description: "Timeouts, throttling, and low-level controls." },
32
+ ];
33
+
34
+ const FIELD_DEFINITIONS = [
35
+ { key: "baseUrl", group: "connection", type: "string", label: "MemOS Base URL", description: "Base URL for the MemOS OpenMem API.", placeholder: "https://memos.memtensor.cn/api/openmem/v1" },
36
+ { key: "apiKey", group: "connection", type: "secret", label: "MemOS API Key", description: "Token auth key. Leave inherited to use env files.", placeholder: "mpg-..." },
37
+ { key: "userId", group: "connection", type: "string", label: "User ID", description: "Unique identifier of the user associated with added messages and queried memories.", placeholder: "openclaw-user" },
38
+ { key: "useDirectSessionUserId", group: "connection", type: "boolean", label: "Use Direct Session User ID", description: "Use direct-session user id from session key when available." },
39
+ { key: "conversationId", group: "session", type: "string", label: "Conversation ID Override", description: "Unique identifier of the conversation. Reusing the same value keeps turns in the same context." },
40
+ { key: "conversationIdPrefix", group: "session", type: "string", label: "Conversation Prefix", description: "Prepended to the derived conversation id." },
41
+ { key: "conversationIdSuffix", group: "session", type: "string", label: "Conversation Suffix", description: "Appended to the derived conversation id." },
42
+ { key: "conversationSuffixMode", group: "session", type: "enum", label: "Suffix Mode", description: "Choose whether /new increments a numeric suffix.", options: [{ value: "none", label: "none" }, { value: "counter", label: "counter" }] },
43
+ { key: "resetOnNew", group: "session", type: "boolean", label: "Reset On /new", description: "Requires hooks.internal.enabled when counter suffix mode is used." },
44
+ { key: "queryPrefix", group: "session", type: "textarea", rows: 4, label: "Query Prefix", description: "Extra text prepended to query before retrieval.", placeholder: "important user context preferences decisions " },
45
+ { key: "maxQueryChars", group: "session", type: "integer", label: "Max Query Chars", description: "Limit the query text length before sending recall search.", placeholder: "0" },
46
+ { key: "recallEnabled", group: "session", type: "boolean", label: "Recall Enabled", description: "Enable before_agent_start memory recall." },
47
+ { key: "recallGlobal", group: "session", type: "boolean", label: "Global Recall", description: "When enabled, query is sent without conversation_id, so current-session weighting is not emphasized." },
48
+ { key: "maxItemChars", group: "session", type: "integer", label: "Max Item Chars", description: "Maximum characters kept when injecting each recalled memory item into context.", placeholder: "8000" },
49
+ { key: "memoryLimitNumber", group: "session", type: "integer", label: "Memory Limit", description: "Maximum number of recalled memories. Default is 9, max is 25.", placeholder: "9" },
50
+ { key: "preferenceLimitNumber", group: "session", type: "integer", label: "Preference Limit", description: "Maximum number of recalled preference memories. Default is 9, max is 25.", placeholder: "9" },
51
+ { key: "includePreference", group: "session", type: "boolean", label: "Include Preferences", description: "Whether to enable preference memory recall." },
52
+ { key: "includeToolMemory", group: "session", type: "boolean", label: "Include Tool Memory", description: "Whether to enable tool memory recall." },
53
+ { key: "toolMemoryLimitNumber", group: "session", type: "integer", label: "Tool Memory Limit", description: "Maximum number of tool memories returned. Effective only when tool memory recall is enabled.", placeholder: "6" },
54
+ { key: "relativity", group: "session", type: "number", label: "Relativity Threshold", description: "Recall relevance threshold from 0 to 1. Set to 0 to disable relevance filtering.", placeholder: "0.45", step: "0.01" },
55
+ { key: "filter", group: "session", type: "json", rows: 7, label: "Search Filter (JSON)", description: "Filter conditions used before retrieval. Supports agent_id, app_id, time fields, info fields, and and/or/gte/lte/gt/lt.", placeholder: '{\n "agent_id": "assistant-1"\n}' },
56
+ { key: "knowledgebaseIds", group: "session", type: "stringArray", rows: 4, label: "Knowledge Base IDs", description: "Restrict the knowledgebase scope for this search. Use one ID per line, or all.", placeholder: "kb-001\nkb-002" },
57
+ { key: "addEnabled", group: "capture", type: "boolean", label: "Add Enabled", description: "Enable adding message arrays and writing resulting memories at agent_end." },
58
+ { key: "captureStrategy", group: "capture", type: "enum", label: "Capture Strategy", description: "Choose whether messages contains only the last turn or the full session.", options: [{ value: "last_turn", label: "last_turn" }, { value: "full_session", label: "full_session" }] },
59
+ { key: "maxMessageChars", group: "capture", type: "integer", label: "Max Message Chars", description: "Maximum characters kept per stored message before building the messages array.", placeholder: "20000" },
60
+ { key: "includeAssistant", group: "capture", type: "boolean", label: "Include Assistant", description: "Include assistant replies in the messages array." },
61
+ { key: "tags", group: "capture", type: "stringArray", rows: 4, label: "Tags", description: "Custom tags used to classify added messages. One value per line.", placeholder: "openclaw" },
62
+ { key: "info", group: "capture", type: "json", rows: 7, label: "Info Payload (JSON)", description: "Structured metadata merged into info for filtering, tracing, and source tracking.", placeholder: '{\n "channel": "webchat"\n}' },
63
+ { key: "asyncMode", group: "capture", type: "boolean", label: "Async Mode", description: "Add memories asynchronously in the background to avoid blocking the call chain." },
64
+ { key: "agentId", group: "agent", type: "string", label: "Static Agent ID", description: "Unique identifier of the Agent associated with added messages and retrieved memories." },
65
+ { key: "multiAgentMode", group: "agent", type: "boolean", label: "Multi-Agent Mode", description: "Isolate recall and add payloads by ctx.agentId when available." },
66
+ { key: "allowedAgents", group: "agent", type: "stringArray", rows: 4, label: "Allowed Agents", description: "Only listed agent ids are allowed to recall and add; empty means all agents." },
67
+ { key: "agentOverrides", group: "agent", type: "json", rows: 10, label: "Agent Overrides (JSON)", description: "Per-agent overrides. Key is agent id, value is an object of supported override fields.", placeholder: '{\n "assistant-1": {\n "knowledgebaseIds": ["kb-001"],\n "recallEnabled": true\n }\n}' },
68
+ { key: "appId", group: "agent", type: "string", label: "App ID", description: "Unique identifier of the App associated with added messages and retrieved memories." },
69
+ { key: "allowPublic", group: "agent", type: "boolean", label: "Allow Public", description: "Allow generated memories to be written to the public memory store." },
70
+ { key: "allowKnowledgebaseIds", group: "agent", type: "stringArray", rows: 4, label: "Allowed Knowledge Base IDs", description: "Knowledgebase scope where generated memories are allowed to be written. One ID per line.", placeholder: "kb-public\nkb-team" },
71
+ { key: "recallFilterEnabled", group: "filter", type: "boolean", label: "Recall Filter Enabled", description: "Enable second-pass model filtering for recall candidates." },
72
+ { key: "recallFilterBaseUrl", group: "filter", type: "string", label: "Filter Base URL", description: "OpenAI-compatible endpoint used for recall filtering.", placeholder: "http://127.0.0.1:11434/v1" },
73
+ { key: "recallFilterApiKey", group: "filter", type: "secret", label: "Filter API Key", description: "Optional bearer token for the recall filter model endpoint." },
74
+ { key: "recallFilterModel", group: "filter", type: "string", label: "Filter Model", description: "Model name used by the recall filter endpoint.", placeholder: "qwen2.5:7b" },
75
+ { key: "recallFilterTimeoutMs", group: "filter", type: "integer", label: "Filter Timeout (ms)", description: "Request timeout for the recall filter model.", placeholder: "6000" },
76
+ { key: "recallFilterRetries", group: "filter", type: "integer", label: "Filter Retries", description: "Retry count when the recall filter request fails.", placeholder: "0" },
77
+ { key: "recallFilterCandidateLimit", group: "filter", type: "integer", label: "Candidate Limit", description: "Per-category candidate limit before filtering.", placeholder: "30" },
78
+ { key: "recallFilterMaxItemChars", group: "filter", type: "integer", label: "Filter Max Item Chars", description: "Maximum characters kept per candidate item before filtering.", placeholder: "500" },
79
+ { key: "recallFilterFailOpen", group: "filter", type: "boolean", label: "Fail Open", description: "Fall back to unfiltered recall if the filter model errors." },
80
+ { key: "timeoutMs", group: "advanced", type: "integer", label: "MemOS Timeout (ms)", description: "Timeout used for MemOS API requests.", placeholder: "5000" },
81
+ { key: "retries", group: "advanced", type: "integer", label: "MemOS Retries", description: "Retry count for MemOS API requests.", placeholder: "1" },
82
+ { key: "throttleMs", group: "advanced", type: "integer", label: "Throttle (ms)", description: "Skip add/message when the previous capture happened too recently.", placeholder: "0" },
83
+ ];
84
+
85
+ function getGlobalState() {
86
+ if (!globalThis[GLOBAL_STATE_KEY]) {
87
+ globalThis[GLOBAL_STATE_KEY] = {
88
+ promise: null,
89
+ service: null,
90
+ cleanupInstalled: false,
91
+ restartHookInstalled: false,
92
+ restartTimer: null,
93
+ restartPending: false,
94
+ recyclePromise: null,
95
+ shuttingDown: false,
96
+ child: null,
97
+ };
98
+ }
99
+ return globalThis[GLOBAL_STATE_KEY];
100
+ }
101
+
102
+ function isPlainObject(value) {
103
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
104
+ }
105
+
106
+ function deepClone(value) {
107
+ if (value === undefined) return undefined;
108
+ return JSON.parse(JSON.stringify(value));
109
+ }
110
+
111
+ function sanitizeStructuredValue(value, depth = 0) {
112
+ if (depth > 16) throw new Error("Config payload is too deeply nested.");
113
+ if (value === null) return null;
114
+ if (typeof value === "string" || typeof value === "boolean") return value;
115
+ if (typeof value === "number") {
116
+ if (!Number.isFinite(value)) throw new Error("Config payload contains a non-finite number.");
117
+ return value;
118
+ }
119
+ if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item, depth + 1));
120
+ if (isPlainObject(value)) {
121
+ const next = {};
122
+ for (const [key, child] of Object.entries(value)) {
123
+ const normalized = sanitizeStructuredValue(child, depth + 1);
124
+ if (normalized !== undefined) next[key] = normalized;
125
+ }
126
+ return next;
127
+ }
128
+ if (value === undefined) return undefined;
129
+ throw new Error("Config payload contains an unsupported value type.");
130
+ }
131
+
132
+ function sortForHash(value) {
133
+ if (Array.isArray(value)) return value.map((item) => sortForHash(item));
134
+ if (isPlainObject(value)) {
135
+ return Object.keys(value)
136
+ .sort()
137
+ .reduce((acc, key) => {
138
+ acc[key] = sortForHash(value[key]);
139
+ return acc;
140
+ }, {});
141
+ }
142
+ return value;
143
+ }
144
+
145
+ function createRevision(value) {
146
+ return createHash("sha1").update(JSON.stringify(sortForHash(value))).digest("hex").slice(0, 12);
147
+ }
148
+
149
+ function detectRuntimeProfile() {
150
+ const scriptPath = String(process.argv[1] || "").toLowerCase();
151
+ const execPath = String(process.execPath || "").toLowerCase();
152
+
153
+ if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) {
154
+ return { id: "moltbot", displayName: "Moltbot", cliName: "moltbot", configPath: join(homedir(), ".moltbot", "moltbot.json") };
155
+ }
156
+ if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) {
157
+ return { id: "clawdbot", displayName: "ClawDBot", cliName: "clawdbot", configPath: join(homedir(), ".clawdbot", "clawdbot.json") };
158
+ }
159
+ return { id: "openclaw", displayName: "OpenClaw", cliName: "openclaw", configPath: join(homedir(), ".openclaw", "openclaw.json") };
160
+ }
161
+
162
+ function parsePositiveInteger(value, fallback) {
163
+ const parsed = Number(value);
164
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
165
+ return fallback;
166
+ }
167
+
168
+ function resolveGatewayReadyProbeTarget(rootConfig = {}) {
169
+ const gateway = isPlainObject(rootConfig?.gateway) ? rootConfig.gateway : {};
170
+ const port = parsePositiveInteger(gateway.port, DEFAULT_GATEWAY_READY_PORT);
171
+ const bind = typeof gateway.bind === "string" ? gateway.bind.trim().toLowerCase() : "";
172
+ const customBindHost = typeof gateway.customBindHost === "string" ? gateway.customBindHost.trim() : "";
173
+ const host = bind === "custom" && customBindHost ? customBindHost : "127.0.0.1";
174
+ return { host, port, url: `http://${host}:${port}/ready` };
175
+ }
176
+
177
+ export async function waitForGatewayReady(rootConfig = {}, log = console, options = {}) {
178
+ const timeoutMs = parsePositiveInteger(options.timeoutMs, 45000);
179
+ const intervalMs = parsePositiveInteger(options.intervalMs, 300);
180
+ const deadline = Date.now() + timeoutMs;
181
+ const target = resolveGatewayReadyProbeTarget(rootConfig);
182
+
183
+ while (Date.now() < deadline) {
184
+ try {
185
+ const response = await fetch(target.url, {
186
+ method: "GET",
187
+ cache: "no-store",
188
+ });
189
+ if (response.ok) {
190
+ let body = null;
191
+ try {
192
+ body = await response.json();
193
+ } catch {
194
+ body = null;
195
+ }
196
+ if (!body || body.ready !== false) return true;
197
+ }
198
+ } catch {
199
+ // Ignore probe failures until timeout expires.
200
+ }
201
+
202
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
203
+ }
204
+
205
+ log.warn?.(`[memos-cloud] Gateway readiness probe timed out at ${target.url}; config UI will not start yet.`);
206
+ return false;
207
+ }
208
+
209
+ function shouldStartConfigUi() {
210
+ const args = process.argv.map((value) => String(value || "").toLowerCase());
211
+ const gatewayIndex = args.lastIndexOf("gateway");
212
+ if (gatewayIndex === -1) return false;
213
+
214
+ const nextArg = args[gatewayIndex + 1];
215
+ if (!nextArg || nextArg.startsWith("-")) return true;
216
+ return nextArg === "start" || nextArg === "restart";
217
+ }
218
+
219
+ function stripBom(text) {
220
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
221
+ }
222
+
223
+ function parseJson5File(text, filePath) {
224
+ const source = stripBom(String(text || "")).trim();
225
+ if (!source) return {};
226
+
227
+ try {
228
+ const parsed = JSON.parse(source);
229
+ if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
230
+ return parsed;
231
+ } catch {
232
+ const script = new Script(`(${source}\n)`, { filename: filePath });
233
+ const parsed = script.runInNewContext(Object.create(null), { timeout: 500 });
234
+ if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
235
+ return parsed;
236
+ }
237
+ }
238
+
239
+ function hasIncludeDirective(value, depth = 0) {
240
+ if (depth > 8) return false;
241
+ if (Array.isArray(value)) return value.some((item) => hasIncludeDirective(item, depth + 1));
242
+ if (!isPlainObject(value)) return false;
243
+ if (Object.prototype.hasOwnProperty.call(value, "$include")) return true;
244
+ return Object.values(value).some((child) => hasIncludeDirective(child, depth + 1));
245
+ }
246
+
247
+ function readGatewayConfig(profile) {
248
+ let root = {};
249
+ let fileExists = true;
250
+ let stat = null;
251
+
252
+ try {
253
+ root = parseJson5File(readFileSync(profile.configPath, "utf8"), profile.configPath);
254
+ stat = statSync(profile.configPath);
255
+ } catch (error) {
256
+ if (error?.code !== "ENOENT") throw error;
257
+ fileExists = false;
258
+ }
259
+
260
+ const plugins = isPlainObject(root.plugins) ? root.plugins : {};
261
+ const entries = isPlainObject(plugins.entries) ? plugins.entries : {};
262
+ const entry = isPlainObject(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : null;
263
+ const config = entry && isPlainObject(entry.config) ? deepClone(entry.config) : {};
264
+ const enabled = entry ? entry.enabled !== false : true;
265
+
266
+ return {
267
+ profile,
268
+ fileExists,
269
+ configPath: profile.configPath,
270
+ entryExists: Boolean(entry),
271
+ enabled,
272
+ config,
273
+ root,
274
+ hasInclude: hasIncludeDirective(root.plugins) || hasIncludeDirective(root),
275
+ revision: createRevision({
276
+ config,
277
+ enabled,
278
+ entryExists: Boolean(entry),
279
+ fileExists,
280
+ mtimeMs: stat?.mtimeMs ?? 0,
281
+ size: stat?.size ?? 0,
282
+ }),
283
+ };
284
+ }
285
+
286
+ function writeGatewayConfig(profile, payload) {
287
+ const current = readGatewayConfig(profile);
288
+ const nextRoot = isPlainObject(current.root) ? deepClone(current.root) : {};
289
+ if (!isPlainObject(nextRoot.plugins)) nextRoot.plugins = {};
290
+ if (!isPlainObject(nextRoot.plugins.entries)) nextRoot.plugins.entries = {};
291
+
292
+ const entry = { enabled: payload.enabled !== false };
293
+ const config = sanitizeStructuredValue(payload.config ?? {});
294
+ if (!isPlainObject(config)) throw new Error("Config payload must be an object.");
295
+ if (Object.keys(config).length > 0) entry.config = config;
296
+
297
+ nextRoot.plugins.entries[PLUGIN_ID] = entry;
298
+
299
+ mkdirSync(dirname(profile.configPath), { recursive: true });
300
+ writeFileSync(profile.configPath, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf8");
301
+ return readGatewayConfig(profile);
302
+ }
303
+
304
+ function buildStatePayload(service) {
305
+ const state = readGatewayConfig(service.profile);
306
+ const resolution = getConfigResolution(state.config);
307
+ return {
308
+ runtime: state.profile.id,
309
+ runtimeDisplayName: state.profile.displayName,
310
+ configPath: state.configPath,
311
+ entryExists: state.entryExists,
312
+ fileExists: state.fileExists,
313
+ enabled: state.enabled,
314
+ config: state.config,
315
+ resolvedConfig: resolution.resolved,
316
+ fieldMeta: resolution.fieldMeta,
317
+ revision: state.revision,
318
+ hasInclude: state.hasInclude,
319
+ bootId: service.bootId,
320
+ assetRevision: getAssetRevision(),
321
+ port: service.port,
322
+ url: service.url,
323
+ };
324
+ }
325
+
326
+ function getCachedStatePayload(service, maxAgeMs = 1200) {
327
+ const now = Date.now();
328
+ if (service.stateCache && now - service.stateCache.createdAt < maxAgeMs) {
329
+ return service.stateCache.payload;
330
+ }
331
+ const payload = buildStatePayload(service);
332
+ service.stateCache = {
333
+ createdAt: now,
334
+ payload,
335
+ };
336
+ return payload;
337
+ }
338
+
339
+ function getCachedHeartbeatPayload(service, maxAgeMs = 1200) {
340
+ const now = Date.now();
341
+ if (service.heartbeatCache && now - service.heartbeatCache.createdAt < maxAgeMs) {
342
+ return {
343
+ ...service.heartbeatCache.payload,
344
+ timestamp: now,
345
+ };
346
+ }
347
+ const payload = {
348
+ ok: true,
349
+ runtime: service.profile.id,
350
+ bootId: service.bootId,
351
+ assetRevision: getAssetRevision(),
352
+ };
353
+ service.heartbeatCache = {
354
+ createdAt: now,
355
+ payload,
356
+ };
357
+ return {
358
+ ...payload,
359
+ timestamp: now,
360
+ };
361
+ }
362
+
363
+ function loadAssetTemplate(name) {
364
+ return readFileSync(join(ASSET_DIR, name), "utf8");
365
+ }
366
+
367
+ function getAssetRevision() {
368
+ const files = ["index.html", "app.js", "app.css"];
369
+ const parts = [];
370
+ for (const name of files) {
371
+ try {
372
+ const stat = statSync(join(ASSET_DIR, name));
373
+ parts.push(`${name}:${stat.mtimeMs}:${stat.size}`);
374
+ } catch {
375
+ parts.push(`${name}:missing`);
376
+ }
377
+ }
378
+ return createRevision(parts);
379
+ }
380
+
381
+ function replaceTokens(template, tokenMap) {
382
+ let output = template;
383
+ for (const [token, value] of Object.entries(tokenMap)) {
384
+ output = output.split(token).join(value);
385
+ }
386
+ return output;
387
+ }
388
+
389
+ function jsonString(value) {
390
+ return JSON.stringify(value).replace(/</g, "\\u003c");
391
+ }
392
+
393
+ function getAccessibleUrls(port) {
394
+ return [`http://127.0.0.1:${port}`];
395
+ }
396
+
397
+ function centerText(text, width) {
398
+ const left = Math.max(0, Math.floor((width - text.length) / 2));
399
+ const right = Math.max(0, width - text.length - left);
400
+ return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
401
+ }
402
+
403
+ function padBoxLine(content, visibleLength, width) {
404
+ return `${ANSI_GREEN}| ${ANSI_RESET}${content}${" ".repeat(Math.max(0, width - visibleLength))}${ANSI_GREEN} |${ANSI_RESET}`;
405
+ }
406
+
407
+ function renderConfigAddressBanner(port) {
408
+ const urls = getAccessibleUrls(port);
409
+ const titleArt = [
410
+ " __ __ ______ __ __ ____ _____ ",
411
+ " | \\/ | | ____| | \\/ | / __ \\ / ____| ",
412
+ " | \\ / | | |__ | \\ / | | | | | | (___ ",
413
+ " | |\\/| | | __| | |\\/| | | | | | \\___ \\ ",
414
+ " | | | | | |____ | | | | | |__| | ____) | ",
415
+ " |_| |_| |______| |_| |_| \\____/ |_____/ "
416
+ ];
417
+ const heading = "Plugin Configuration";
418
+ const urlLines = urls.map((url, index) => ` [${index + 1}] ${url}`);
419
+ const plainLines = [
420
+ "",
421
+ ...titleArt,
422
+ "",
423
+ heading,
424
+ "",
425
+ "Plugin configuration page is ready.",
426
+ "Open one of the following URLs in your browser:",
427
+ "",
428
+ ...urlLines,
429
+ "",
430
+ "Tip: keep this window open while you finish the setup.",
431
+ "",
432
+ ];
433
+ const contentWidth = plainLines.reduce((max, line) => Math.max(max, line.length), 0);
434
+ const centeredTitleArt = titleArt.map((line) => centerText(line, contentWidth));
435
+ const centeredHeading = centerText(heading, contentWidth);
436
+ const visibleLineWidths = [
437
+ 0,
438
+ ...centeredTitleArt.map(() => contentWidth),
439
+ 0,
440
+ contentWidth,
441
+ contentWidth,
442
+ "Plugin configuration page is ready.".length,
443
+ "Open one of the following URLs in your browser:".length,
444
+ 0,
445
+ ...urlLines.map((line) => line.length),
446
+ 0,
447
+ "Tip: keep this window open while you finish the setup.".length,
448
+ 0,
449
+ ];
450
+ const separator = "-".repeat(contentWidth);
451
+ const coloredLines = [
452
+ "",
453
+ ...centeredTitleArt.map((line) => `${ANSI_BOLD}${ANSI_CYAN}${line}${ANSI_RESET}`),
454
+ "",
455
+ `${ANSI_BOLD}${centeredHeading}${ANSI_RESET}`,
456
+ `${ANSI_GREEN}${separator}${ANSI_RESET}`,
457
+ "Plugin configuration page is ready.",
458
+ "Open one of the following URLs in your browser:",
459
+ "",
460
+ ...urlLines.map((line) => `${ANSI_BOLD}${ANSI_GREEN}${line}${ANSI_RESET}`),
461
+ "",
462
+ "Tip: keep this window open while you finish the setup.",
463
+ "",
464
+ ];
465
+ const horizontalBorder = `+${"=".repeat(contentWidth + 2)}+`;
466
+
467
+ return `\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}\n${coloredLines
468
+ .map((line, index) => padBoxLine(line, visibleLineWidths[index], contentWidth))
469
+ .join("\n")}\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}`;
470
+ }
471
+
472
+ function renderHtml(service) {
473
+ return replaceTokens(loadAssetTemplate("index.html"), {
474
+ "__PLUGIN_ID__": PLUGIN_ID,
475
+ "__APP_JS_URL__": `/app.js?token=${service.token}`,
476
+ });
477
+ }
478
+
479
+ function renderAppJs(service) {
480
+ return replaceTokens(loadAssetTemplate("app.js"), {
481
+ "__CONFIG_UI_TOKEN__": jsonString(service.token),
482
+ "__FIELD_GROUPS__": jsonString(FIELD_GROUPS),
483
+ "__FIELD_DEFINITIONS__": jsonString(FIELD_DEFINITIONS),
484
+ });
485
+ }
486
+
487
+ // Legacy restart functions removed for sandbox compliance
488
+
489
+ function listenOnPort(server, host, port) {
490
+ return new Promise((resolve, reject) => {
491
+ const onError = (error) => {
492
+ cleanup();
493
+ reject(error);
494
+ };
495
+ const onListening = () => {
496
+ cleanup();
497
+ resolve(port);
498
+ };
499
+ const cleanup = () => {
500
+ server.off("error", onError);
501
+ server.off("listening", onListening);
502
+ };
503
+ server.once("error", onError);
504
+ server.once("listening", onListening);
505
+ server.listen(port, host);
506
+ });
507
+ }
508
+
509
+ async function bindWithFallback(server) {
510
+ for (let offset = 0; offset < UI_PORT_ATTEMPTS; offset += 1) {
511
+ const port = UI_BASE_PORT + offset;
512
+ try {
513
+ await listenOnPort(server, UI_HOST, port);
514
+ return port;
515
+ } catch (error) {
516
+ if (error?.code !== "EADDRINUSE") throw error;
517
+ }
518
+ }
519
+ throw new Error(`Could not bind config UI after trying ${UI_PORT_ATTEMPTS} ports from ${UI_BASE_PORT}.`);
520
+ }
521
+
522
+ function readRequestBody(req) {
523
+ return new Promise((resolve, reject) => {
524
+ let body = "";
525
+ req.setEncoding("utf8");
526
+ req.on("data", (chunk) => {
527
+ body += chunk;
528
+ if (body.length > 1024 * 1024) {
529
+ reject(new Error("Request body too large."));
530
+ req.destroy();
531
+ }
532
+ });
533
+ req.on("end", () => resolve(body));
534
+ req.on("error", reject);
535
+ });
536
+ }
537
+
538
+ function sendJson(res, statusCode, payload) {
539
+ res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
540
+ res.end(JSON.stringify(payload));
541
+ }
542
+
543
+ function sendText(res, statusCode, message) {
544
+ res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
545
+ res.end(message);
546
+ }
547
+
548
+ function isAuthorized(req, service) {
549
+ return req.headers["x-memos-config-token"] === service.token;
550
+ }
551
+
552
+ async function createService(log) {
553
+ const profile = detectRuntimeProfile();
554
+ const token = randomBytes(24).toString("hex");
555
+ const bootId = randomBytes(10).toString("hex");
556
+
557
+ const service = { profile, token, bootId, port: 0, url: "", server: null, stateCache: null, heartbeatCache: null };
558
+
559
+ const server = createServer(async (req, res) => {
560
+ try {
561
+ const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
562
+
563
+ if (requestUrl.pathname === "/favicon.ico") {
564
+ res.writeHead(204);
565
+ res.end();
566
+ return;
567
+ }
568
+
569
+ if (requestUrl.pathname === "/icon.svg") {
570
+ res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "no-store" });
571
+ res.end(loadAssetTemplate("icon.svg"));
572
+ return;
573
+ }
574
+
575
+ if (requestUrl.pathname === "/app.css") {
576
+ res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-store" });
577
+ res.end(loadAssetTemplate("app.css"));
578
+ return;
579
+ }
580
+
581
+ if (requestUrl.pathname === "/app.js") {
582
+ if (requestUrl.searchParams.get("token") !== service.token) {
583
+ sendText(res, 403, "Forbidden");
584
+ return;
585
+ }
586
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store" });
587
+ res.end(renderAppJs(service));
588
+ return;
589
+ }
590
+
591
+ if (requestUrl.pathname === "/api/heartbeat" && req.method === "GET") {
592
+ sendJson(res, 200, getCachedHeartbeatPayload(service));
593
+ return;
594
+ }
595
+
596
+ if (requestUrl.pathname.startsWith("/api/")) {
597
+ if (!isAuthorized(req, service)) {
598
+ sendText(res, 403, "Forbidden");
599
+ return;
600
+ }
601
+
602
+ if (requestUrl.pathname === "/api/state" && req.method === "GET") {
603
+ sendJson(res, 200, getCachedStatePayload(service));
604
+ return;
605
+ }
606
+
607
+ if (requestUrl.pathname === "/api/save" && req.method === "POST") {
608
+ let parsed = {};
609
+ try {
610
+ parsed = JSON.parse((await readRequestBody(req)) || "{}");
611
+ } catch {
612
+ sendText(res, 400, "Invalid JSON payload.");
613
+ return;
614
+ }
615
+ if (!isPlainObject(parsed)) {
616
+ sendText(res, 400, "Invalid JSON payload.");
617
+ return;
618
+ }
619
+ if (typeof parsed.enabled !== "boolean") {
620
+ sendText(res, 400, "Payload.enabled must be a boolean.");
621
+ return;
622
+ }
623
+ if (!isPlainObject(parsed.config)) {
624
+ sendText(res, 400, "Payload.config must be an object.");
625
+ return;
626
+ }
627
+
628
+ const nextState = writeGatewayConfig(profile, parsed);
629
+ service.stateCache = null;
630
+ service.heartbeatCache = null;
631
+ sendJson(res, 200, { ok: true, state: buildStatePayload({ ...service, profile: nextState.profile }) });
632
+ return;
633
+ }
634
+
635
+ // Legacy `/api/restart` has been entirely removed
636
+ sendText(res, 404, "Not found");
637
+ return;
638
+ }
639
+
640
+ if (requestUrl.pathname !== "/") {
641
+ sendText(res, 404, "Not found");
642
+ return;
643
+ }
644
+
645
+ res.writeHead(200, {
646
+ "Content-Type": "text/html; charset=utf-8",
647
+ "Cache-Control": "no-store",
648
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;",
649
+ });
650
+ res.end(renderHtml(service));
651
+ } catch (error) {
652
+ sendText(res, 500, `Internal error: ${String(error?.message || error)}`);
653
+ }
654
+ });
655
+
656
+ service.server = server;
657
+ service.port = await bindWithFallback(server);
658
+ service.url = `http://${UI_HOST}:${service.port}`;
659
+
660
+ setTimeout(() => {
661
+ console.log(renderConfigAddressBanner(service.port));
662
+ }, 1200);
663
+ return service;
664
+ }
665
+
666
+ export function ensureConfigUiService(log = console) {
667
+ if (!shouldStartConfigUi()) {
668
+ return Promise.resolve(null);
669
+ }
670
+
671
+ const globalState = getGlobalState();
672
+ if (globalState.promise) return globalState.promise;
673
+
674
+ globalState.promise = createService(log)
675
+ .then((service) => {
676
+ globalState.service = service;
677
+ return service;
678
+ })
679
+ .catch((error) => {
680
+ globalState.service = null;
681
+ globalState.promise = null;
682
+ throw error;
683
+ });
684
+
685
+ return globalState.promise;
686
+ }
687
+
688
+ export async function closeConfigUiService(options = {}) {
689
+ const globalState = getGlobalState();
690
+
691
+ if (!globalState.service) {
692
+ globalState.promise = null;
693
+ return;
694
+ }
695
+
696
+ const { service } = globalState;
697
+ globalState.service = null;
698
+ globalState.promise = null;
699
+
700
+ if (service?.server) {
701
+ await new Promise((resolve) => {
702
+ try {
703
+ service.server.close(() => resolve());
704
+ } catch {
705
+ resolve();
706
+ }
707
+ });
708
+ }
709
+ }
710
+
711
+ export async function runConfigUiChildProcess(log = console) {
712
+ // no-op
713
+ }