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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +23 -32
  2. package/README_ZH.md +309 -0
  3. package/clawdbot.plugin.json +1 -1
  4. package/index.js +28 -15
  5. package/lib/config-ui-server.js +967 -967
  6. package/lib/memos-cloud-api.js +11 -37
  7. package/lib/memos-core/filter.d.ts +5 -0
  8. package/lib/memos-core/filter.d.ts.map +1 -0
  9. package/lib/memos-core/filter.js +213 -0
  10. package/lib/memos-core/filter.js.map +1 -0
  11. package/lib/memos-core/index.d.ts +9 -0
  12. package/lib/memos-core/index.d.ts.map +1 -0
  13. package/lib/memos-core/index.js +8 -0
  14. package/lib/memos-core/index.js.map +1 -0
  15. package/lib/memos-core/memos-client.d.ts +23 -0
  16. package/lib/memos-core/memos-client.d.ts.map +1 -0
  17. package/lib/memos-core/memos-client.js +173 -0
  18. package/lib/memos-core/memos-client.js.map +1 -0
  19. package/lib/memos-core/memos-errors.d.ts +14 -0
  20. package/lib/memos-core/memos-errors.d.ts.map +1 -0
  21. package/lib/memos-core/memos-errors.js +14 -0
  22. package/lib/memos-core/memos-errors.js.map +1 -0
  23. package/lib/memos-core/memos-response.d.ts +4 -0
  24. package/lib/memos-core/memos-response.d.ts.map +1 -0
  25. package/lib/memos-core/memos-response.js +84 -0
  26. package/lib/memos-core/memos-response.js.map +1 -0
  27. package/lib/memos-core/payloads.d.ts +31 -0
  28. package/lib/memos-core/payloads.d.ts.map +1 -0
  29. package/lib/memos-core/payloads.js +42 -0
  30. package/lib/memos-core/payloads.js.map +1 -0
  31. package/lib/memos-core/recall-projection.d.ts +35 -0
  32. package/lib/memos-core/recall-projection.d.ts.map +1 -0
  33. package/lib/memos-core/recall-projection.js +95 -0
  34. package/lib/memos-core/recall-projection.js.map +1 -0
  35. package/lib/memos-core/serialization.d.ts +5 -0
  36. package/lib/memos-core/serialization.d.ts.map +1 -0
  37. package/lib/memos-core/serialization.js +11 -0
  38. package/lib/memos-core/serialization.js.map +1 -0
  39. package/lib/memos-core/types.d.ts +114 -0
  40. package/lib/memos-core/types.d.ts.map +1 -0
  41. package/lib/memos-core/types.js +2 -0
  42. package/lib/memos-core/types.js.map +1 -0
  43. package/moltbot.plugin.json +1 -1
  44. package/openclaw.plugin.json +1 -1
  45. package/package.json +19 -15
  46. package/telemetry.credentials.json +0 -5
@@ -1,578 +1,578 @@
1
-
1
+
2
2
  import { createHash, randomBytes } from "node:crypto";
3
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";
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
8
  import { Script } from "node:vm";
9
9
  import { checkForPluginUpdate, getPackageVersion } from "./check-update.js";
10
10
  import { getConfigResolution } from "./memos-cloud-api.js";
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
-
14
- const PLUGIN_ID = "memos-cloud-openclaw-plugin";
15
- const UI_HOST = "127.0.0.1";
16
- const UI_BASE_PORT = 38463;
17
- const UI_PORT_ATTEMPTS = 24;
18
- const GLOBAL_STATE_KEY = "__memosCloudConfigUiState";
19
- const ASSET_DIR = join(__dirname, "config-ui");
20
- const ANSI_BOLD = "\x1b[1m";
21
- const ANSI_CYAN = "\x1b[36m";
22
- const ANSI_GREEN = "\x1b[32m";
23
- const ANSI_YELLOW = "\x1b[33m";
24
- const ANSI_RESET = "\x1b[0m";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+
14
+ const PLUGIN_ID = "memos-cloud-openclaw-plugin";
15
+ const UI_HOST = "127.0.0.1";
16
+ const UI_BASE_PORT = 38463;
17
+ const UI_PORT_ATTEMPTS = 24;
18
+ const GLOBAL_STATE_KEY = "__memosCloudConfigUiState";
19
+ const ASSET_DIR = join(__dirname, "config-ui");
20
+ const ANSI_BOLD = "\x1b[1m";
21
+ const ANSI_CYAN = "\x1b[36m";
22
+ const ANSI_GREEN = "\x1b[32m";
23
+ const ANSI_YELLOW = "\x1b[33m";
24
+ const ANSI_RESET = "\x1b[0m";
25
25
  const DEFAULT_GATEWAY_READY_PORT = 18789;
26
26
  const UPDATE_STATUS_CACHE_MS = 30 * 60 * 1000;
27
-
28
- // Hook policies that the gateway requires non-bundled plugins to opt into.
29
- // Without these, the gateway blocks `agent_end` (and other conversation-access
30
- // hooks) at registration time. Keep this list in sync with the policies the
31
- // plugin actually relies on so we never drift from runtime behavior.
32
- const REQUIRED_HOOK_POLICIES = ["allowConversationAccess"];
33
-
34
- const FIELD_GROUPS = [
35
- { id: "connection", title: "Connection", description: "MemOS endpoint, authentication, and identity mapping." },
36
- { id: "session", title: "Session And Recall", description: "Conversation id strategy, recall scope, and injection behavior." },
37
- { id: "capture", title: "Capture And Storage", description: "What gets written back to MemOS after each agent run." },
38
- { id: "agent", title: "Agent Isolation", description: "Multi-agent isolation, app metadata, and sharing permissions." },
39
- { id: "filter", title: "Recall Filter", description: "Optional model-based second-pass filtering before memories are injected." },
40
- { id: "advanced", title: "Advanced", description: "Timeouts, throttling, and low-level controls." },
41
- ];
42
-
43
- const FIELD_DEFINITIONS = [
44
- { 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" },
45
- { key: "apiKey", group: "connection", type: "secret", label: "MemOS API Key", description: "Token auth key. Leave inherited to use env files.", placeholder: "mpg-..." },
46
- { 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" },
47
- { key: "useDirectSessionUserId", group: "connection", type: "boolean", label: "Use Direct Session User ID", description: "Use direct-session user id from session key when available." },
48
- { 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." },
49
- { key: "conversationIdPrefix", group: "session", type: "string", label: "Conversation Prefix", description: "Prepended to the derived conversation id." },
50
- { key: "conversationIdSuffix", group: "session", type: "string", label: "Conversation Suffix", description: "Appended to the derived conversation id." },
51
- { 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" }] },
52
- { key: "resetOnNew", group: "session", type: "boolean", label: "Reset On /new", description: "Requires hooks.internal.enabled when counter suffix mode is used." },
53
- { 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 " },
54
- { key: "maxQueryChars", group: "session", type: "integer", label: "Max Query Chars", description: "Limit the query text length before sending recall search.", placeholder: "0" },
55
- { key: "recallEnabled", group: "session", type: "boolean", label: "Recall Enabled", description: "Enable before_agent_start memory recall." },
56
- { 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." },
57
- { key: "maxItemChars", group: "session", type: "integer", label: "Max Item Chars", description: "Maximum characters kept when injecting each recalled memory item into context.", placeholder: "8000" },
58
- { key: "memoryLimitNumber", group: "session", type: "integer", label: "Memory Limit", description: "Maximum number of recalled memories. Default is 9, max is 25.", placeholder: "9" },
59
- { key: "preferenceLimitNumber", group: "session", type: "integer", label: "Preference Limit", description: "Maximum number of recalled preference memories. Default is 9, max is 25.", placeholder: "9" },
60
- { key: "includePreference", group: "session", type: "boolean", label: "Include Preferences", description: "Whether to enable preference memory recall." },
61
- { key: "includeToolMemory", group: "session", type: "boolean", label: "Include Tool Memory", description: "Whether to enable tool memory recall." },
62
- { 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" },
63
- { 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" },
64
- { 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}' },
65
- { 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" },
66
- { key: "addEnabled", group: "capture", type: "boolean", label: "Add Enabled", description: "Enable adding message arrays and writing resulting memories at agent_end." },
67
- { 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" }] },
68
- { key: "maxMessageChars", group: "capture", type: "integer", label: "Max Message Chars", description: "Maximum characters kept per stored message before building the messages array.", placeholder: "20000" },
69
- { key: "includeAssistant", group: "capture", type: "boolean", label: "Include Assistant", description: "Include assistant replies in the messages array." },
70
- { key: "tags", group: "capture", type: "stringArray", rows: 4, label: "Tags", description: "Custom tags used to classify added messages. One value per line.", placeholder: "openclaw" },
71
- { 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}' },
72
- { key: "asyncMode", group: "capture", type: "boolean", label: "Async Mode", description: "Add memories asynchronously in the background to avoid blocking the call chain." },
73
- { key: "agentId", group: "agent", type: "string", label: "Static Agent ID", description: "Unique identifier of the Agent associated with added messages and retrieved memories." },
74
- { key: "multiAgentMode", group: "agent", type: "boolean", label: "Multi-Agent Mode", description: "Isolate recall and add payloads by ctx.agentId when available." },
75
- { 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." },
76
- { 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}' },
77
- { key: "appId", group: "agent", type: "string", label: "App ID", description: "Unique identifier of the App associated with added messages and retrieved memories." },
78
- { key: "allowPublic", group: "agent", type: "boolean", label: "Allow Public", description: "Allow generated memories to be written to the public memory store." },
79
- { 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" },
80
- { key: "recallFilterEnabled", group: "filter", type: "boolean", label: "Recall Filter Enabled", description: "Enable second-pass model filtering for recall candidates." },
81
- { 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" },
82
- { key: "recallFilterApiKey", group: "filter", type: "secret", label: "Filter API Key", description: "Optional bearer token for the recall filter model endpoint." },
83
- { key: "recallFilterModel", group: "filter", type: "string", label: "Filter Model", description: "Model name used by the recall filter endpoint.", placeholder: "qwen2.5:7b" },
84
- { key: "recallFilterTimeoutMs", group: "filter", type: "integer", label: "Filter Timeout (ms)", description: "Request timeout for the recall filter model.", placeholder: "6000" },
85
- { key: "recallFilterRetries", group: "filter", type: "integer", label: "Filter Retries", description: "Retry count when the recall filter request fails.", placeholder: "0" },
86
- { key: "recallFilterCandidateLimit", group: "filter", type: "integer", label: "Candidate Limit", description: "Per-category candidate limit before filtering.", placeholder: "30" },
87
- { key: "recallFilterMaxItemChars", group: "filter", type: "integer", label: "Filter Max Item Chars", description: "Maximum characters kept per candidate item before filtering.", placeholder: "500" },
88
- { key: "recallFilterFailOpen", group: "filter", type: "boolean", label: "Fail Open", description: "Fall back to unfiltered recall if the filter model errors." },
89
- { key: "timeoutMs", group: "advanced", type: "integer", label: "MemOS Timeout (ms)", description: "Timeout used for MemOS API requests.", placeholder: "5000" },
90
- { key: "retries", group: "advanced", type: "integer", label: "MemOS Retries", description: "Retry count for MemOS API requests.", placeholder: "1" },
91
- { key: "throttleMs", group: "advanced", type: "integer", label: "Throttle (ms)", description: "Skip add/message when the previous capture happened too recently.", placeholder: "0" },
92
- ];
93
-
94
- function getGlobalState() {
95
- if (!globalThis[GLOBAL_STATE_KEY]) {
96
- globalThis[GLOBAL_STATE_KEY] = {
97
- promise: null,
98
- service: null,
99
- cleanupInstalled: false,
100
- restartHookInstalled: false,
101
- restartTimer: null,
102
- restartPending: false,
103
- recyclePromise: null,
104
- shuttingDown: false,
105
- child: null,
106
- };
107
- }
108
- return globalThis[GLOBAL_STATE_KEY];
109
- }
110
-
111
- function isPlainObject(value) {
112
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
113
- }
114
-
115
- function deepClone(value) {
116
- if (value === undefined) return undefined;
117
- return JSON.parse(JSON.stringify(value));
118
- }
119
-
120
- function sanitizeStructuredValue(value, depth = 0) {
121
- if (depth > 16) throw new Error("Config payload is too deeply nested.");
122
- if (value === null) return null;
123
- if (typeof value === "string" || typeof value === "boolean") return value;
124
- if (typeof value === "number") {
125
- if (!Number.isFinite(value)) throw new Error("Config payload contains a non-finite number.");
126
- return value;
127
- }
128
- if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item, depth + 1));
129
- if (isPlainObject(value)) {
130
- const next = {};
131
- for (const [key, child] of Object.entries(value)) {
132
- const normalized = sanitizeStructuredValue(child, depth + 1);
133
- if (normalized !== undefined) next[key] = normalized;
134
- }
135
- return next;
136
- }
137
- if (value === undefined) return undefined;
138
- throw new Error("Config payload contains an unsupported value type.");
139
- }
140
-
141
- function sortForHash(value) {
142
- if (Array.isArray(value)) return value.map((item) => sortForHash(item));
143
- if (isPlainObject(value)) {
144
- return Object.keys(value)
145
- .sort()
146
- .reduce((acc, key) => {
147
- acc[key] = sortForHash(value[key]);
148
- return acc;
149
- }, {});
150
- }
151
- return value;
152
- }
153
-
154
- function createRevision(value) {
155
- return createHash("sha1").update(JSON.stringify(sortForHash(value))).digest("hex").slice(0, 12);
156
- }
157
-
158
- function detectRuntimeProfile() {
159
- const scriptPath = String(process.argv[1] || "").toLowerCase();
160
- const execPath = String(process.execPath || "").toLowerCase();
161
-
162
- if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) {
163
- return { id: "moltbot", displayName: "Moltbot", cliName: "moltbot", configPath: join(homedir(), ".moltbot", "moltbot.json") };
164
- }
165
- if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) {
166
- return { id: "clawdbot", displayName: "ClawDBot", cliName: "clawdbot", configPath: join(homedir(), ".clawdbot", "clawdbot.json") };
167
- }
168
- return { id: "openclaw", displayName: "OpenClaw", cliName: "openclaw", configPath: join(homedir(), ".openclaw", "openclaw.json") };
169
- }
170
-
171
- /**
172
- * Detect the version of the host CLI (openclaw / moltbot / clawdbot).
173
- *
174
- * Strategy (in order of preference):
175
- * 1. Parse `@X.Y.Z` out of `process.argv[1]` — pnpm/npm global installs
176
- * always embed the version in the store path
177
- * (e.g. `.../openclaw@2026.4.26/node_modules/openclaw/openclaw.mjs`).
178
- * 2. Walk up from `process.argv[1]` looking for a `package.json` that has
179
- * a `version` field — covers local `node` invocations and bundled builds.
180
- * 3. Return `null` if neither succeeds.
181
- */
182
- export function detectHostVersion() {
183
- const scriptPath = String(process.argv[1] || "");
184
-
185
- const atMatch = scriptPath.match(/@(\d+\.\d+(?:\.\d+)?)/);
186
- if (atMatch) return atMatch[1];
187
-
188
- try {
189
- let dir = dirname(scriptPath);
190
- for (let i = 0; i < 8; i++) {
191
- try {
192
- const raw = readFileSync(join(dir, "package.json"), "utf8");
193
- const pkg = JSON.parse(raw);
194
- if (pkg.version && typeof pkg.version === "string") return pkg.version;
195
- } catch {
196
- // not found at this level, keep walking
197
- }
198
- const parent = dirname(dir);
199
- if (parent === dir) break;
200
- dir = parent;
201
- }
202
- } catch {
203
- // ignore
204
- }
205
-
206
- return null;
207
- }
208
-
209
- /**
210
- * Compare two CalVer / semver version strings component by component.
211
- * Returns -1 when a < b, 0 when equal, 1 when a > b.
212
- * Non-numeric components (e.g. pre-release suffixes) are ignored.
213
- */
214
- export function compareVersionStrings(a, b) {
215
- const partsA = String(a ?? "").split(".").map(Number);
216
- const partsB = String(b ?? "").split(".").map(Number);
217
- const len = Math.max(partsA.length, partsB.length);
218
- for (let i = 0; i < len; i++) {
219
- const na = Number.isFinite(partsA[i]) ? partsA[i] : 0;
220
- const nb = Number.isFinite(partsB[i]) ? partsB[i] : 0;
221
- if (na < nb) return -1;
222
- if (na > nb) return 1;
223
- }
224
- return 0;
225
- }
226
-
227
- function parsePositiveInteger(value, fallback) {
228
- const parsed = Number(value);
229
- if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
230
- return fallback;
231
- }
232
-
233
- function resolveGatewayReadyProbeTarget(rootConfig = {}) {
234
- const gateway = isPlainObject(rootConfig?.gateway) ? rootConfig.gateway : {};
235
- const port = parsePositiveInteger(gateway.port, DEFAULT_GATEWAY_READY_PORT);
236
- const bind = typeof gateway.bind === "string" ? gateway.bind.trim().toLowerCase() : "";
237
- const customBindHost = typeof gateway.customBindHost === "string" ? gateway.customBindHost.trim() : "";
238
- const host = bind === "custom" && customBindHost ? customBindHost : "127.0.0.1";
239
- return { host, port, url: `http://${host}:${port}/ready` };
240
- }
241
-
242
- export async function waitForGatewayReady(rootConfig = {}, log = console, options = {}) {
243
- const timeoutMs = parsePositiveInteger(options.timeoutMs, 45000);
244
- const intervalMs = parsePositiveInteger(options.intervalMs, 300);
245
- const deadline = Date.now() + timeoutMs;
246
- const target = resolveGatewayReadyProbeTarget(rootConfig);
247
-
248
- while (Date.now() < deadline) {
249
- try {
250
- const response = await fetch(target.url, {
251
- method: "GET",
252
- cache: "no-store",
253
- });
254
- if (response.ok) {
255
- let body = null;
256
- try {
257
- body = await response.json();
258
- } catch {
259
- body = null;
260
- }
261
- if (!body || body.ready !== false) return true;
262
- }
263
- } catch {
264
- // Ignore probe failures until timeout expires.
265
- }
266
-
267
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
268
- }
269
-
270
- log.warn?.(`[memos-cloud] Gateway readiness probe timed out at ${target.url}; config UI will not start yet.`);
271
- return false;
272
- }
273
-
274
- // Returns true when the current process was launched by an `openclaw gateway`
275
- // command that actually starts (or restarts) the long-lived gateway. Other
276
- // CLI entry points (`plugins install`, `security audit`, `--help`, ...) also
277
- // load this plugin to inspect/register it, but they perform their own
278
- // config commits and must not be racing with our hook-policy patcher.
279
- function isGatewayStartCommand() {
280
- const args = process.argv.map((value) => String(value || "").toLowerCase());
281
- const gatewayIndex = args.lastIndexOf("gateway");
282
- if (gatewayIndex === -1) return false;
283
-
284
- const nextArg = args[gatewayIndex + 1];
285
- if (!nextArg || nextArg.startsWith("-")) return true;
286
- return nextArg === "start" || nextArg === "restart" || nextArg === "run";
287
- }
288
-
289
- function shouldStartConfigUi() {
290
- return isGatewayStartCommand();
291
- }
292
-
293
- export function isGatewayRuntimeStartup() {
294
- return isGatewayStartCommand();
295
- }
296
-
297
- function stripBom(text) {
298
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
299
- }
300
-
301
- function parseJson5File(text, filePath) {
302
- const source = stripBom(String(text || "")).trim();
303
- if (!source) return {};
304
-
305
- try {
306
- const parsed = JSON.parse(source);
307
- if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
308
- return parsed;
309
- } catch {
310
- const script = new Script(`(${source}\n)`, { filename: filePath });
311
- const parsed = script.runInNewContext(Object.create(null), { timeout: 500 });
312
- if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
313
- return parsed;
314
- }
315
- }
316
-
317
- function hasIncludeDirective(value, depth = 0) {
318
- if (depth > 8) return false;
319
- if (Array.isArray(value)) return value.some((item) => hasIncludeDirective(item, depth + 1));
320
- if (!isPlainObject(value)) return false;
321
- if (Object.prototype.hasOwnProperty.call(value, "$include")) return true;
322
- return Object.values(value).some((child) => hasIncludeDirective(child, depth + 1));
323
- }
324
-
325
- function readGatewayConfig(profile) {
326
- let root = {};
327
- let fileExists = true;
328
- let stat = null;
329
-
330
- try {
331
- root = parseJson5File(readFileSync(profile.configPath, "utf8"), profile.configPath);
332
- stat = statSync(profile.configPath);
333
- } catch (error) {
334
- if (error?.code !== "ENOENT") throw error;
335
- fileExists = false;
336
- }
337
-
338
- const plugins = isPlainObject(root.plugins) ? root.plugins : {};
339
- const entries = isPlainObject(plugins.entries) ? plugins.entries : {};
340
- const entry = isPlainObject(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : null;
341
- const config = entry && isPlainObject(entry.config) ? deepClone(entry.config) : {};
342
- const enabled = entry ? entry.enabled !== false : true;
343
-
344
- return {
345
- profile,
346
- fileExists,
347
- configPath: profile.configPath,
348
- entryExists: Boolean(entry),
349
- enabled,
350
- config,
351
- root,
352
- hasInclude: hasIncludeDirective(root.plugins) || hasIncludeDirective(root),
353
- revision: createRevision({
354
- config,
355
- enabled,
356
- entryExists: Boolean(entry),
357
- fileExists,
358
- mtimeMs: stat?.mtimeMs ?? 0,
359
- size: stat?.size ?? 0,
360
- }),
361
- };
362
- }
363
-
364
- function writeGatewayConfig(profile, payload) {
365
- const current = readGatewayConfig(profile);
366
- const nextRoot = isPlainObject(current.root) ? deepClone(current.root) : {};
367
- if (!isPlainObject(nextRoot.plugins)) nextRoot.plugins = {};
368
- if (!isPlainObject(nextRoot.plugins.entries)) nextRoot.plugins.entries = {};
369
-
370
- // Preserve any entry-level fields the user already set (e.g. `hooks`,
371
- // policy opt-ins, future gateway metadata) so that saving from the config
372
- // UI never silently drops gateway-required settings such as
373
- // `hooks.allowConversationAccess`.
374
- const previousEntry = isPlainObject(nextRoot.plugins.entries[PLUGIN_ID])
375
- ? nextRoot.plugins.entries[PLUGIN_ID]
376
- : {};
377
- const entry = { ...previousEntry, enabled: payload.enabled !== false };
378
-
379
- const config = sanitizeStructuredValue(payload.config ?? {});
380
- if (!isPlainObject(config)) throw new Error("Config payload must be an object.");
381
- if (Object.keys(config).length > 0) {
382
- entry.config = config;
383
- } else {
384
- delete entry.config;
385
- }
386
-
387
- nextRoot.plugins.entries[PLUGIN_ID] = entry;
388
-
389
- mkdirSync(dirname(profile.configPath), { recursive: true });
390
- writeFileSync(profile.configPath, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf8");
391
- return readGatewayConfig(profile);
392
- }
393
-
394
- function getPluginEntry(root) {
395
- const plugins = isPlainObject(root?.plugins) ? root.plugins : null;
396
- if (!plugins) return null;
397
- const entries = isPlainObject(plugins.entries) ? plugins.entries : null;
398
- if (!entries) return null;
399
- return isPlainObject(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : null;
400
- }
401
-
402
- function getMissingHookPolicies(root, hookKeys) {
403
- const entry = getPluginEntry(root);
404
- const hooks = isPlainObject(entry?.hooks) ? entry.hooks : {};
405
- return hookKeys.filter((key) => hooks[key] !== true);
406
- }
407
-
408
- /**
409
- * Ensure the gateway config grants this plugin the typed-hook policies it
410
- * needs. The gateway blocks non-bundled plugins from registering hooks like
411
- * `agent_end` unless `plugins.entries.<id>.hooks.allowConversationAccess` is
412
- * explicitly set to `true`. Users hit this on every fresh install because the
413
- * key is not part of any default config template, so we patch it in
414
- * automatically on first load.
415
- *
416
- * Behavior:
417
- * - Fast-path: if `rootConfig` already has all required keys, do nothing
418
- * silently — no logs, no disk reads.
419
- * - Otherwise: read the on-disk config (to preserve unrelated edits made
420
- * after the gateway booted), merge in the missing keys, write it back,
421
- * and return `{ changed: true }` so the caller can ask the user to
422
- * restart the gateway. We only mutate the keys we own.
423
- * - If the config uses `$include` directives we cannot safely rewrite the
424
- * merged tree, so we just warn and leave the file untouched.
425
- *
426
- * NOTE: callers are responsible for deciding *when* to invoke this. In
427
- * particular it must NOT be called from short-lived CLI commands such as
428
- * `plugins install` that perform their own config commits — racing with
429
- * them triggers `ConfigMutationConflictError`. See `isGatewayRuntimeStartup`.
430
- */
431
- export function ensurePluginHookPolicy(rootConfig = null, log = console, options = {}) {
432
- const hookKeys = Array.isArray(options.hookKeys) && options.hookKeys.length > 0
433
- ? options.hookKeys
434
- : REQUIRED_HOOK_POLICIES;
435
-
436
- const profile = detectRuntimeProfile();
437
-
438
- if (isPlainObject(rootConfig) && getMissingHookPolicies(rootConfig, hookKeys).length === 0) {
439
- return { changed: false, configPath: profile.configPath, profile };
440
- }
441
-
442
- let current;
443
- try {
444
- current = readGatewayConfig(profile);
445
- } catch (error) {
446
- log.warn?.(
447
- `[memos-cloud] failed to read ${profile.configPath} while ensuring plugin hook policy: ${String(error?.message ?? error)}`,
448
- );
449
- return { changed: false, error, configPath: profile.configPath, profile };
450
- }
451
-
452
- if (current.hasInclude) {
453
- const missing = getMissingHookPolicies(current.root, hookKeys);
454
- if (missing.length > 0) {
455
- log.warn?.(
456
- `[memos-cloud] gateway config at ${profile.configPath} uses $include directives, ` +
457
- `cannot auto-enable required plugin hook policies: ${missing.join(", ")}. ` +
458
- `Add plugins.entries.${PLUGIN_ID}.hooks={${missing.map((k) => `${k}:true`).join(", ")}} manually and restart the gateway.`,
459
- );
460
- }
461
- return { changed: false, hasInclude: true, configPath: profile.configPath, profile };
462
- }
463
-
464
- const missingKeys = getMissingHookPolicies(current.root, hookKeys);
465
- if (missingKeys.length === 0) return { changed: false, configPath: profile.configPath, profile };
466
-
467
- const nextRoot = isPlainObject(current.root) ? deepClone(current.root) : {};
468
- if (!isPlainObject(nextRoot.plugins)) nextRoot.plugins = {};
469
- if (!isPlainObject(nextRoot.plugins.entries)) nextRoot.plugins.entries = {};
470
- if (!isPlainObject(nextRoot.plugins.entries[PLUGIN_ID])) {
471
- nextRoot.plugins.entries[PLUGIN_ID] = {};
472
- }
473
- const entry = nextRoot.plugins.entries[PLUGIN_ID];
474
- if (entry.enabled === undefined) entry.enabled = true;
475
- if (!isPlainObject(entry.hooks)) entry.hooks = {};
476
- for (const key of missingKeys) {
477
- entry.hooks[key] = true;
478
- }
479
-
480
- try {
481
- mkdirSync(dirname(profile.configPath), { recursive: true });
482
- writeFileSync(profile.configPath, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf8");
483
- } catch (error) {
484
- log.warn?.(
485
- `[memos-cloud] failed to write ${profile.configPath} to enable required plugin hook policies (${missingKeys.join(", ")}): ${String(error?.message ?? error)}`,
486
- );
487
- return { changed: false, error, configPath: profile.configPath, profile };
488
- }
489
-
490
- // Emit the eye-catching banner directly through stdout so the gateway's
491
- // log-line prefix (`[plugins] [memos-cloud] ...`) does not break up the
492
- // box drawing. Then also emit a single terse `log.warn` line so any
493
- // structured log aggregator / CI capture still gets the event.
494
- // console.log(renderHookPolicyRestartBanner(profile, missingKeys));
495
- log.warn?.(
496
- `[memos-cloud] hook policy patched in ${profile.configPath} (added: ${missingKeys.join(", ")}). ` +
497
- `Manual gateway restart required: \`${profile.cliName} gateway restart\`.`,
498
- );
499
-
500
- return { changed: true, addedKeys: missingKeys, configPath: profile.configPath, profile };
501
- }
502
-
503
- /**
504
- * Returns metadata describing how `ensurePluginHookPolicy` would behave for
505
- * the current runtime, without writing anything. Useful in tests and for
506
- * letting host code surface "we will write to <path>" diagnostics before any
507
- * IO actually happens.
508
- */
509
- export function describePluginHookPolicyTarget() {
510
- const profile = detectRuntimeProfile();
511
- return {
512
- runtime: profile.id,
513
- configPath: profile.configPath,
514
- hookKeys: [...REQUIRED_HOOK_POLICIES],
515
- };
516
- }
517
-
518
- function buildStatePayload(service) {
519
- const state = readGatewayConfig(service.profile);
520
- const resolution = getConfigResolution(state.config);
521
- return {
27
+
28
+ // Hook policies that the gateway requires non-bundled plugins to opt into.
29
+ // Without these, the gateway blocks `agent_end` (and other conversation-access
30
+ // hooks) at registration time. Keep this list in sync with the policies the
31
+ // plugin actually relies on so we never drift from runtime behavior.
32
+ const REQUIRED_HOOK_POLICIES = ["allowConversationAccess"];
33
+
34
+ const FIELD_GROUPS = [
35
+ { id: "connection", title: "Connection", description: "MemOS endpoint, authentication, and identity mapping." },
36
+ { id: "session", title: "Session And Recall", description: "Conversation id strategy, recall scope, and injection behavior." },
37
+ { id: "capture", title: "Capture And Storage", description: "What gets written back to MemOS after each agent run." },
38
+ { id: "agent", title: "Agent Isolation", description: "Multi-agent isolation, app metadata, and sharing permissions." },
39
+ { id: "filter", title: "Recall Filter", description: "Optional model-based second-pass filtering before memories are injected." },
40
+ { id: "advanced", title: "Advanced", description: "Timeouts, throttling, and low-level controls." },
41
+ ];
42
+
43
+ const FIELD_DEFINITIONS = [
44
+ { 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" },
45
+ { key: "apiKey", group: "connection", type: "secret", label: "MemOS API Key", description: "Token auth key. Leave inherited to use env files.", placeholder: "mpg-..." },
46
+ { 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" },
47
+ { key: "useDirectSessionUserId", group: "connection", type: "boolean", label: "Use Direct Session User ID", description: "Use direct-session user id from session key when available." },
48
+ { 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." },
49
+ { key: "conversationIdPrefix", group: "session", type: "string", label: "Conversation Prefix", description: "Prepended to the derived conversation id." },
50
+ { key: "conversationIdSuffix", group: "session", type: "string", label: "Conversation Suffix", description: "Appended to the derived conversation id." },
51
+ { 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" }] },
52
+ { key: "resetOnNew", group: "session", type: "boolean", label: "Reset On /new", description: "Requires hooks.internal.enabled when counter suffix mode is used." },
53
+ { 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 " },
54
+ { key: "maxQueryChars", group: "session", type: "integer", label: "Max Query Chars", description: "Limit the query text length before sending recall search.", placeholder: "0" },
55
+ { key: "recallEnabled", group: "session", type: "boolean", label: "Recall Enabled", description: "Enable before_agent_start memory recall." },
56
+ { 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." },
57
+ { key: "maxItemChars", group: "session", type: "integer", label: "Max Item Chars", description: "Maximum characters kept when injecting each recalled memory item into context.", placeholder: "8000" },
58
+ { key: "memoryLimitNumber", group: "session", type: "integer", label: "Memory Limit", description: "Maximum number of recalled memories. Default is 9, max is 25.", placeholder: "9" },
59
+ { key: "preferenceLimitNumber", group: "session", type: "integer", label: "Preference Limit", description: "Maximum number of recalled preference memories. Default is 9, max is 25.", placeholder: "9" },
60
+ { key: "includePreference", group: "session", type: "boolean", label: "Include Preferences", description: "Whether to enable preference memory recall." },
61
+ { key: "includeToolMemory", group: "session", type: "boolean", label: "Include Tool Memory", description: "Whether to enable tool memory recall." },
62
+ { 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" },
63
+ { 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" },
64
+ { 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}' },
65
+ { 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" },
66
+ { key: "addEnabled", group: "capture", type: "boolean", label: "Add Enabled", description: "Enable adding message arrays and writing resulting memories at agent_end." },
67
+ { 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" }] },
68
+ { key: "maxMessageChars", group: "capture", type: "integer", label: "Max Message Chars", description: "Maximum characters kept per stored message before building the messages array.", placeholder: "20000" },
69
+ { key: "includeAssistant", group: "capture", type: "boolean", label: "Include Assistant", description: "Include assistant replies in the messages array." },
70
+ { key: "tags", group: "capture", type: "stringArray", rows: 4, label: "Tags", description: "Custom tags used to classify added messages. One value per line.", placeholder: "openclaw" },
71
+ { 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}' },
72
+ { key: "asyncMode", group: "capture", type: "boolean", label: "Async Mode", description: "Add memories asynchronously in the background to avoid blocking the call chain." },
73
+ { key: "agentId", group: "agent", type: "string", label: "Static Agent ID", description: "Unique identifier of the Agent associated with added messages and retrieved memories." },
74
+ { key: "multiAgentMode", group: "agent", type: "boolean", label: "Multi-Agent Mode", description: "Isolate recall and add payloads by ctx.agentId when available." },
75
+ { 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." },
76
+ { 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}' },
77
+ { key: "appId", group: "agent", type: "string", label: "App ID", description: "Unique identifier of the App associated with added messages and retrieved memories." },
78
+ { key: "allowPublic", group: "agent", type: "boolean", label: "Allow Public", description: "Allow generated memories to be written to the public memory store." },
79
+ { 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" },
80
+ { key: "recallFilterEnabled", group: "filter", type: "boolean", label: "Recall Filter Enabled", description: "Enable second-pass model filtering for recall candidates." },
81
+ { 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" },
82
+ { key: "recallFilterApiKey", group: "filter", type: "secret", label: "Filter API Key", description: "Optional bearer token for the recall filter model endpoint." },
83
+ { key: "recallFilterModel", group: "filter", type: "string", label: "Filter Model", description: "Model name used by the recall filter endpoint.", placeholder: "qwen2.5:7b" },
84
+ { key: "recallFilterTimeoutMs", group: "filter", type: "integer", label: "Filter Timeout (ms)", description: "Request timeout for the recall filter model.", placeholder: "6000" },
85
+ { key: "recallFilterRetries", group: "filter", type: "integer", label: "Filter Retries", description: "Retry count when the recall filter request fails.", placeholder: "0" },
86
+ { key: "recallFilterCandidateLimit", group: "filter", type: "integer", label: "Candidate Limit", description: "Per-category candidate limit before filtering.", placeholder: "30" },
87
+ { key: "recallFilterMaxItemChars", group: "filter", type: "integer", label: "Filter Max Item Chars", description: "Maximum characters kept per candidate item before filtering.", placeholder: "500" },
88
+ { key: "recallFilterFailOpen", group: "filter", type: "boolean", label: "Fail Open", description: "Fall back to unfiltered recall if the filter model errors." },
89
+ { key: "timeoutMs", group: "advanced", type: "integer", label: "MemOS Timeout (ms)", description: "Timeout used for MemOS API requests.", placeholder: "5000" },
90
+ { key: "retries", group: "advanced", type: "integer", label: "MemOS Retries", description: "Retry count for MemOS API requests.", placeholder: "1" },
91
+ { key: "throttleMs", group: "advanced", type: "integer", label: "Throttle (ms)", description: "Skip add/message when the previous capture happened too recently.", placeholder: "0" },
92
+ ];
93
+
94
+ function getGlobalState() {
95
+ if (!globalThis[GLOBAL_STATE_KEY]) {
96
+ globalThis[GLOBAL_STATE_KEY] = {
97
+ promise: null,
98
+ service: null,
99
+ cleanupInstalled: false,
100
+ restartHookInstalled: false,
101
+ restartTimer: null,
102
+ restartPending: false,
103
+ recyclePromise: null,
104
+ shuttingDown: false,
105
+ child: null,
106
+ };
107
+ }
108
+ return globalThis[GLOBAL_STATE_KEY];
109
+ }
110
+
111
+ function isPlainObject(value) {
112
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
113
+ }
114
+
115
+ function deepClone(value) {
116
+ if (value === undefined) return undefined;
117
+ return JSON.parse(JSON.stringify(value));
118
+ }
119
+
120
+ function sanitizeStructuredValue(value, depth = 0) {
121
+ if (depth > 16) throw new Error("Config payload is too deeply nested.");
122
+ if (value === null) return null;
123
+ if (typeof value === "string" || typeof value === "boolean") return value;
124
+ if (typeof value === "number") {
125
+ if (!Number.isFinite(value)) throw new Error("Config payload contains a non-finite number.");
126
+ return value;
127
+ }
128
+ if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item, depth + 1));
129
+ if (isPlainObject(value)) {
130
+ const next = {};
131
+ for (const [key, child] of Object.entries(value)) {
132
+ const normalized = sanitizeStructuredValue(child, depth + 1);
133
+ if (normalized !== undefined) next[key] = normalized;
134
+ }
135
+ return next;
136
+ }
137
+ if (value === undefined) return undefined;
138
+ throw new Error("Config payload contains an unsupported value type.");
139
+ }
140
+
141
+ function sortForHash(value) {
142
+ if (Array.isArray(value)) return value.map((item) => sortForHash(item));
143
+ if (isPlainObject(value)) {
144
+ return Object.keys(value)
145
+ .sort()
146
+ .reduce((acc, key) => {
147
+ acc[key] = sortForHash(value[key]);
148
+ return acc;
149
+ }, {});
150
+ }
151
+ return value;
152
+ }
153
+
154
+ function createRevision(value) {
155
+ return createHash("sha1").update(JSON.stringify(sortForHash(value))).digest("hex").slice(0, 12);
156
+ }
157
+
158
+ function detectRuntimeProfile() {
159
+ const scriptPath = String(process.argv[1] || "").toLowerCase();
160
+ const execPath = String(process.execPath || "").toLowerCase();
161
+
162
+ if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) {
163
+ return { id: "moltbot", displayName: "Moltbot", cliName: "moltbot", configPath: join(homedir(), ".moltbot", "moltbot.json") };
164
+ }
165
+ if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) {
166
+ return { id: "clawdbot", displayName: "ClawDBot", cliName: "clawdbot", configPath: join(homedir(), ".clawdbot", "clawdbot.json") };
167
+ }
168
+ return { id: "openclaw", displayName: "OpenClaw", cliName: "openclaw", configPath: join(homedir(), ".openclaw", "openclaw.json") };
169
+ }
170
+
171
+ /**
172
+ * Detect the version of the host CLI (openclaw / moltbot / clawdbot).
173
+ *
174
+ * Strategy (in order of preference):
175
+ * 1. Parse `@X.Y.Z` out of `process.argv[1]` — pnpm/npm global installs
176
+ * always embed the version in the store path
177
+ * (e.g. `.../openclaw@2026.4.26/node_modules/openclaw/openclaw.mjs`).
178
+ * 2. Walk up from `process.argv[1]` looking for a `package.json` that has
179
+ * a `version` field — covers local `node` invocations and bundled builds.
180
+ * 3. Return `null` if neither succeeds.
181
+ */
182
+ export function detectHostVersion() {
183
+ const scriptPath = String(process.argv[1] || "");
184
+
185
+ const atMatch = scriptPath.match(/@(\d+\.\d+(?:\.\d+)?)/);
186
+ if (atMatch) return atMatch[1];
187
+
188
+ try {
189
+ let dir = dirname(scriptPath);
190
+ for (let i = 0; i < 8; i++) {
191
+ try {
192
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
193
+ const pkg = JSON.parse(raw);
194
+ if (pkg.version && typeof pkg.version === "string") return pkg.version;
195
+ } catch {
196
+ // not found at this level, keep walking
197
+ }
198
+ const parent = dirname(dir);
199
+ if (parent === dir) break;
200
+ dir = parent;
201
+ }
202
+ } catch {
203
+ // ignore
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Compare two CalVer / semver version strings component by component.
211
+ * Returns -1 when a < b, 0 when equal, 1 when a > b.
212
+ * Non-numeric components (e.g. pre-release suffixes) are ignored.
213
+ */
214
+ export function compareVersionStrings(a, b) {
215
+ const partsA = String(a ?? "").split(".").map(Number);
216
+ const partsB = String(b ?? "").split(".").map(Number);
217
+ const len = Math.max(partsA.length, partsB.length);
218
+ for (let i = 0; i < len; i++) {
219
+ const na = Number.isFinite(partsA[i]) ? partsA[i] : 0;
220
+ const nb = Number.isFinite(partsB[i]) ? partsB[i] : 0;
221
+ if (na < nb) return -1;
222
+ if (na > nb) return 1;
223
+ }
224
+ return 0;
225
+ }
226
+
227
+ function parsePositiveInteger(value, fallback) {
228
+ const parsed = Number(value);
229
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
230
+ return fallback;
231
+ }
232
+
233
+ function resolveGatewayReadyProbeTarget(rootConfig = {}) {
234
+ const gateway = isPlainObject(rootConfig?.gateway) ? rootConfig.gateway : {};
235
+ const port = parsePositiveInteger(gateway.port, DEFAULT_GATEWAY_READY_PORT);
236
+ const bind = typeof gateway.bind === "string" ? gateway.bind.trim().toLowerCase() : "";
237
+ const customBindHost = typeof gateway.customBindHost === "string" ? gateway.customBindHost.trim() : "";
238
+ const host = bind === "custom" && customBindHost ? customBindHost : "127.0.0.1";
239
+ return { host, port, url: `http://${host}:${port}/ready` };
240
+ }
241
+
242
+ export async function waitForGatewayReady(rootConfig = {}, log = console, options = {}) {
243
+ const timeoutMs = parsePositiveInteger(options.timeoutMs, 45000);
244
+ const intervalMs = parsePositiveInteger(options.intervalMs, 300);
245
+ const deadline = Date.now() + timeoutMs;
246
+ const target = resolveGatewayReadyProbeTarget(rootConfig);
247
+
248
+ while (Date.now() < deadline) {
249
+ try {
250
+ const response = await fetch(target.url, {
251
+ method: "GET",
252
+ cache: "no-store",
253
+ });
254
+ if (response.ok) {
255
+ let body = null;
256
+ try {
257
+ body = await response.json();
258
+ } catch {
259
+ body = null;
260
+ }
261
+ if (!body || body.ready !== false) return true;
262
+ }
263
+ } catch {
264
+ // Ignore probe failures until timeout expires.
265
+ }
266
+
267
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
268
+ }
269
+
270
+ log.warn?.(`[memos-cloud] Gateway readiness probe timed out at ${target.url}; config UI will not start yet.`);
271
+ return false;
272
+ }
273
+
274
+ // Returns true when the current process was launched by an `openclaw gateway`
275
+ // command that actually starts (or restarts) the long-lived gateway. Other
276
+ // CLI entry points (`plugins install`, `security audit`, `--help`, ...) also
277
+ // load this plugin to inspect/register it, but they perform their own
278
+ // config commits and must not be racing with our hook-policy patcher.
279
+ function isGatewayStartCommand() {
280
+ const args = process.argv.map((value) => String(value || "").toLowerCase());
281
+ const gatewayIndex = args.lastIndexOf("gateway");
282
+ if (gatewayIndex === -1) return false;
283
+
284
+ const nextArg = args[gatewayIndex + 1];
285
+ if (!nextArg || nextArg.startsWith("-")) return true;
286
+ return nextArg === "start" || nextArg === "restart" || nextArg === "run";
287
+ }
288
+
289
+ function shouldStartConfigUi() {
290
+ return isGatewayStartCommand();
291
+ }
292
+
293
+ export function isGatewayRuntimeStartup() {
294
+ return isGatewayStartCommand();
295
+ }
296
+
297
+ function stripBom(text) {
298
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
299
+ }
300
+
301
+ function parseJson5File(text, filePath) {
302
+ const source = stripBom(String(text || "")).trim();
303
+ if (!source) return {};
304
+
305
+ try {
306
+ const parsed = JSON.parse(source);
307
+ if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
308
+ return parsed;
309
+ } catch {
310
+ const script = new Script(`(${source}\n)`, { filename: filePath });
311
+ const parsed = script.runInNewContext(Object.create(null), { timeout: 500 });
312
+ if (!isPlainObject(parsed)) throw new Error("Root config must be an object.");
313
+ return parsed;
314
+ }
315
+ }
316
+
317
+ function hasIncludeDirective(value, depth = 0) {
318
+ if (depth > 8) return false;
319
+ if (Array.isArray(value)) return value.some((item) => hasIncludeDirective(item, depth + 1));
320
+ if (!isPlainObject(value)) return false;
321
+ if (Object.prototype.hasOwnProperty.call(value, "$include")) return true;
322
+ return Object.values(value).some((child) => hasIncludeDirective(child, depth + 1));
323
+ }
324
+
325
+ function readGatewayConfig(profile) {
326
+ let root = {};
327
+ let fileExists = true;
328
+ let stat = null;
329
+
330
+ try {
331
+ root = parseJson5File(readFileSync(profile.configPath, "utf8"), profile.configPath);
332
+ stat = statSync(profile.configPath);
333
+ } catch (error) {
334
+ if (error?.code !== "ENOENT") throw error;
335
+ fileExists = false;
336
+ }
337
+
338
+ const plugins = isPlainObject(root.plugins) ? root.plugins : {};
339
+ const entries = isPlainObject(plugins.entries) ? plugins.entries : {};
340
+ const entry = isPlainObject(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : null;
341
+ const config = entry && isPlainObject(entry.config) ? deepClone(entry.config) : {};
342
+ const enabled = entry ? entry.enabled !== false : true;
343
+
344
+ return {
345
+ profile,
346
+ fileExists,
347
+ configPath: profile.configPath,
348
+ entryExists: Boolean(entry),
349
+ enabled,
350
+ config,
351
+ root,
352
+ hasInclude: hasIncludeDirective(root.plugins) || hasIncludeDirective(root),
353
+ revision: createRevision({
354
+ config,
355
+ enabled,
356
+ entryExists: Boolean(entry),
357
+ fileExists,
358
+ mtimeMs: stat?.mtimeMs ?? 0,
359
+ size: stat?.size ?? 0,
360
+ }),
361
+ };
362
+ }
363
+
364
+ function writeGatewayConfig(profile, payload) {
365
+ const current = readGatewayConfig(profile);
366
+ const nextRoot = isPlainObject(current.root) ? deepClone(current.root) : {};
367
+ if (!isPlainObject(nextRoot.plugins)) nextRoot.plugins = {};
368
+ if (!isPlainObject(nextRoot.plugins.entries)) nextRoot.plugins.entries = {};
369
+
370
+ // Preserve any entry-level fields the user already set (e.g. `hooks`,
371
+ // policy opt-ins, future gateway metadata) so that saving from the config
372
+ // UI never silently drops gateway-required settings such as
373
+ // `hooks.allowConversationAccess`.
374
+ const previousEntry = isPlainObject(nextRoot.plugins.entries[PLUGIN_ID])
375
+ ? nextRoot.plugins.entries[PLUGIN_ID]
376
+ : {};
377
+ const entry = { ...previousEntry, enabled: payload.enabled !== false };
378
+
379
+ const config = sanitizeStructuredValue(payload.config ?? {});
380
+ if (!isPlainObject(config)) throw new Error("Config payload must be an object.");
381
+ if (Object.keys(config).length > 0) {
382
+ entry.config = config;
383
+ } else {
384
+ delete entry.config;
385
+ }
386
+
387
+ nextRoot.plugins.entries[PLUGIN_ID] = entry;
388
+
389
+ mkdirSync(dirname(profile.configPath), { recursive: true });
390
+ writeFileSync(profile.configPath, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf8");
391
+ return readGatewayConfig(profile);
392
+ }
393
+
394
+ function getPluginEntry(root) {
395
+ const plugins = isPlainObject(root?.plugins) ? root.plugins : null;
396
+ if (!plugins) return null;
397
+ const entries = isPlainObject(plugins.entries) ? plugins.entries : null;
398
+ if (!entries) return null;
399
+ return isPlainObject(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : null;
400
+ }
401
+
402
+ function getMissingHookPolicies(root, hookKeys) {
403
+ const entry = getPluginEntry(root);
404
+ const hooks = isPlainObject(entry?.hooks) ? entry.hooks : {};
405
+ return hookKeys.filter((key) => hooks[key] !== true);
406
+ }
407
+
408
+ /**
409
+ * Ensure the gateway config grants this plugin the typed-hook policies it
410
+ * needs. The gateway blocks non-bundled plugins from registering hooks like
411
+ * `agent_end` unless `plugins.entries.<id>.hooks.allowConversationAccess` is
412
+ * explicitly set to `true`. Users hit this on every fresh install because the
413
+ * key is not part of any default config template, so we patch it in
414
+ * automatically on first load.
415
+ *
416
+ * Behavior:
417
+ * - Fast-path: if `rootConfig` already has all required keys, do nothing
418
+ * silently — no logs, no disk reads.
419
+ * - Otherwise: read the on-disk config (to preserve unrelated edits made
420
+ * after the gateway booted), merge in the missing keys, write it back,
421
+ * and return `{ changed: true }` so the caller can ask the user to
422
+ * restart the gateway. We only mutate the keys we own.
423
+ * - If the config uses `$include` directives we cannot safely rewrite the
424
+ * merged tree, so we just warn and leave the file untouched.
425
+ *
426
+ * NOTE: callers are responsible for deciding *when* to invoke this. In
427
+ * particular it must NOT be called from short-lived CLI commands such as
428
+ * `plugins install` that perform their own config commits — racing with
429
+ * them triggers `ConfigMutationConflictError`. See `isGatewayRuntimeStartup`.
430
+ */
431
+ export function ensurePluginHookPolicy(rootConfig = null, log = console, options = {}) {
432
+ const hookKeys = Array.isArray(options.hookKeys) && options.hookKeys.length > 0
433
+ ? options.hookKeys
434
+ : REQUIRED_HOOK_POLICIES;
435
+
436
+ const profile = detectRuntimeProfile();
437
+
438
+ if (isPlainObject(rootConfig) && getMissingHookPolicies(rootConfig, hookKeys).length === 0) {
439
+ return { changed: false, configPath: profile.configPath, profile };
440
+ }
441
+
442
+ let current;
443
+ try {
444
+ current = readGatewayConfig(profile);
445
+ } catch (error) {
446
+ log.warn?.(
447
+ `[memos-cloud] failed to read ${profile.configPath} while ensuring plugin hook policy: ${String(error?.message ?? error)}`,
448
+ );
449
+ return { changed: false, error, configPath: profile.configPath, profile };
450
+ }
451
+
452
+ if (current.hasInclude) {
453
+ const missing = getMissingHookPolicies(current.root, hookKeys);
454
+ if (missing.length > 0) {
455
+ log.warn?.(
456
+ `[memos-cloud] gateway config at ${profile.configPath} uses $include directives, ` +
457
+ `cannot auto-enable required plugin hook policies: ${missing.join(", ")}. ` +
458
+ `Add plugins.entries.${PLUGIN_ID}.hooks={${missing.map((k) => `${k}:true`).join(", ")}} manually and restart the gateway.`,
459
+ );
460
+ }
461
+ return { changed: false, hasInclude: true, configPath: profile.configPath, profile };
462
+ }
463
+
464
+ const missingKeys = getMissingHookPolicies(current.root, hookKeys);
465
+ if (missingKeys.length === 0) return { changed: false, configPath: profile.configPath, profile };
466
+
467
+ const nextRoot = isPlainObject(current.root) ? deepClone(current.root) : {};
468
+ if (!isPlainObject(nextRoot.plugins)) nextRoot.plugins = {};
469
+ if (!isPlainObject(nextRoot.plugins.entries)) nextRoot.plugins.entries = {};
470
+ if (!isPlainObject(nextRoot.plugins.entries[PLUGIN_ID])) {
471
+ nextRoot.plugins.entries[PLUGIN_ID] = {};
472
+ }
473
+ const entry = nextRoot.plugins.entries[PLUGIN_ID];
474
+ if (entry.enabled === undefined) entry.enabled = true;
475
+ if (!isPlainObject(entry.hooks)) entry.hooks = {};
476
+ for (const key of missingKeys) {
477
+ entry.hooks[key] = true;
478
+ }
479
+
480
+ try {
481
+ mkdirSync(dirname(profile.configPath), { recursive: true });
482
+ writeFileSync(profile.configPath, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf8");
483
+ } catch (error) {
484
+ log.warn?.(
485
+ `[memos-cloud] failed to write ${profile.configPath} to enable required plugin hook policies (${missingKeys.join(", ")}): ${String(error?.message ?? error)}`,
486
+ );
487
+ return { changed: false, error, configPath: profile.configPath, profile };
488
+ }
489
+
490
+ // Emit the eye-catching banner directly through stdout so the gateway's
491
+ // log-line prefix (`[plugins] [memos-cloud] ...`) does not break up the
492
+ // box drawing. Then also emit a single terse `log.warn` line so any
493
+ // structured log aggregator / CI capture still gets the event.
494
+ // console.log(renderHookPolicyRestartBanner(profile, missingKeys));
495
+ log.warn?.(
496
+ `[memos-cloud] hook policy patched in ${profile.configPath} (added: ${missingKeys.join(", ")}). ` +
497
+ `Manual gateway restart required: \`${profile.cliName} gateway restart\`.`,
498
+ );
499
+
500
+ return { changed: true, addedKeys: missingKeys, configPath: profile.configPath, profile };
501
+ }
502
+
503
+ /**
504
+ * Returns metadata describing how `ensurePluginHookPolicy` would behave for
505
+ * the current runtime, without writing anything. Useful in tests and for
506
+ * letting host code surface "we will write to <path>" diagnostics before any
507
+ * IO actually happens.
508
+ */
509
+ export function describePluginHookPolicyTarget() {
510
+ const profile = detectRuntimeProfile();
511
+ return {
512
+ runtime: profile.id,
513
+ configPath: profile.configPath,
514
+ hookKeys: [...REQUIRED_HOOK_POLICIES],
515
+ };
516
+ }
517
+
518
+ function buildStatePayload(service) {
519
+ const state = readGatewayConfig(service.profile);
520
+ const resolution = getConfigResolution(state.config);
521
+ return {
522
522
  runtime: state.profile.id,
523
523
  runtimeDisplayName: state.profile.displayName,
524
524
  pluginVersion: getPackageVersion(),
525
525
  configPath: state.configPath,
526
- entryExists: state.entryExists,
527
- fileExists: state.fileExists,
528
- enabled: state.enabled,
529
- config: state.config,
530
- resolvedConfig: resolution.resolved,
531
- fieldMeta: resolution.fieldMeta,
532
- revision: state.revision,
533
- hasInclude: state.hasInclude,
534
- bootId: service.bootId,
535
- assetRevision: getAssetRevision(),
536
- port: service.port,
537
- url: service.url,
538
- };
539
- }
540
-
541
- function getCachedStatePayload(service, maxAgeMs = 1200) {
542
- const now = Date.now();
543
- if (service.stateCache && now - service.stateCache.createdAt < maxAgeMs) {
544
- return service.stateCache.payload;
545
- }
546
- const payload = buildStatePayload(service);
547
- service.stateCache = {
548
- createdAt: now,
549
- payload,
550
- };
551
- return payload;
552
- }
553
-
526
+ entryExists: state.entryExists,
527
+ fileExists: state.fileExists,
528
+ enabled: state.enabled,
529
+ config: state.config,
530
+ resolvedConfig: resolution.resolved,
531
+ fieldMeta: resolution.fieldMeta,
532
+ revision: state.revision,
533
+ hasInclude: state.hasInclude,
534
+ bootId: service.bootId,
535
+ assetRevision: getAssetRevision(),
536
+ port: service.port,
537
+ url: service.url,
538
+ };
539
+ }
540
+
541
+ function getCachedStatePayload(service, maxAgeMs = 1200) {
542
+ const now = Date.now();
543
+ if (service.stateCache && now - service.stateCache.createdAt < maxAgeMs) {
544
+ return service.stateCache.payload;
545
+ }
546
+ const payload = buildStatePayload(service);
547
+ service.stateCache = {
548
+ createdAt: now,
549
+ payload,
550
+ };
551
+ return payload;
552
+ }
553
+
554
554
  function getCachedHeartbeatPayload(service, maxAgeMs = 1200) {
555
- const now = Date.now();
556
- if (service.heartbeatCache && now - service.heartbeatCache.createdAt < maxAgeMs) {
557
- return {
558
- ...service.heartbeatCache.payload,
559
- timestamp: now,
560
- };
561
- }
562
- const payload = {
563
- ok: true,
564
- runtime: service.profile.id,
565
- bootId: service.bootId,
566
- assetRevision: getAssetRevision(),
567
- };
568
- service.heartbeatCache = {
569
- createdAt: now,
570
- payload,
571
- };
572
- return {
573
- ...payload,
574
- timestamp: now,
575
- };
555
+ const now = Date.now();
556
+ if (service.heartbeatCache && now - service.heartbeatCache.createdAt < maxAgeMs) {
557
+ return {
558
+ ...service.heartbeatCache.payload,
559
+ timestamp: now,
560
+ };
561
+ }
562
+ const payload = {
563
+ ok: true,
564
+ runtime: service.profile.id,
565
+ bootId: service.bootId,
566
+ assetRevision: getAssetRevision(),
567
+ };
568
+ service.heartbeatCache = {
569
+ createdAt: now,
570
+ payload,
571
+ };
572
+ return {
573
+ ...payload,
574
+ timestamp: now,
575
+ };
576
576
  }
577
577
 
578
578
  async function getCachedUpdateStatusPayload(service, maxAgeMs = UPDATE_STATUS_CACHE_MS, options = {}) {
@@ -608,260 +608,260 @@ async function getCachedUpdateStatusPayload(service, maxAgeMs = UPDATE_STATUS_CA
608
608
  }
609
609
 
610
610
  function loadAssetTemplate(name) {
611
- return readFileSync(join(ASSET_DIR, name), "utf8");
612
- }
613
-
614
- function getAssetRevision() {
615
- const files = ["index.html", "app.js", "app.css"];
616
- const parts = [];
617
- for (const name of files) {
618
- try {
619
- const stat = statSync(join(ASSET_DIR, name));
620
- parts.push(`${name}:${stat.mtimeMs}:${stat.size}`);
621
- } catch {
622
- parts.push(`${name}:missing`);
623
- }
624
- }
625
- return createRevision(parts);
626
- }
627
-
628
- function replaceTokens(template, tokenMap) {
629
- let output = template;
630
- for (const [token, value] of Object.entries(tokenMap)) {
631
- output = output.split(token).join(value);
632
- }
633
- return output;
634
- }
635
-
636
- function jsonString(value) {
637
- return JSON.stringify(value).replace(/</g, "\\u003c");
638
- }
639
-
640
- function getAccessibleUrls(port) {
641
- return [`http://127.0.0.1:${port}`];
642
- }
643
-
644
- function centerText(text, width) {
645
- const left = Math.max(0, Math.floor((width - text.length) / 2));
646
- const right = Math.max(0, width - text.length - left);
647
- return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
648
- }
649
-
650
- function padBoxLine(content, visibleLength, width) {
651
- return `${ANSI_GREEN}| ${ANSI_RESET}${content}${" ".repeat(Math.max(0, width - visibleLength))}${ANSI_GREEN} |${ANSI_RESET}`;
652
- }
653
-
654
- function renderConfigAddressBanner(port) {
655
- const urls = getAccessibleUrls(port);
656
- const titleArt = [
657
- " __ __ ______ __ __ ____ _____ ",
658
- " | \\/ | | ____| | \\/ | / __ \\ / ____| ",
659
- " | \\ / | | |__ | \\ / | | | | | | (___ ",
660
- " | |\\/| | | __| | |\\/| | | | | | \\___ \\ ",
661
- " | | | | | |____ | | | | | |__| | ____) | ",
662
- " |_| |_| |______| |_| |_| \\____/ |_____/ "
663
- ];
664
- const heading = "Plugin Configuration";
665
- const urlLines = urls.map((url, index) => ` [${index + 1}] ${url}`);
666
- const plainLines = [
667
- "",
668
- ...titleArt,
669
- "",
670
- heading,
671
- "",
672
- "Plugin configuration page is ready.",
673
- "Open one of the following URLs in your browser:",
674
- "",
675
- ...urlLines,
676
- "",
677
- "Tip: keep this window open while you finish the setup.",
678
- "",
679
- ];
680
- const contentWidth = plainLines.reduce((max, line) => Math.max(max, line.length), 0);
681
- const centeredTitleArt = titleArt.map((line) => centerText(line, contentWidth));
682
- const centeredHeading = centerText(heading, contentWidth);
683
- const visibleLineWidths = [
684
- 0,
685
- ...centeredTitleArt.map(() => contentWidth),
686
- 0,
687
- contentWidth,
688
- contentWidth,
689
- "Plugin configuration page is ready.".length,
690
- "Open one of the following URLs in your browser:".length,
691
- 0,
692
- ...urlLines.map((line) => line.length),
693
- 0,
694
- "Tip: keep this window open while you finish the setup.".length,
695
- 0,
696
- ];
697
- const separator = "-".repeat(contentWidth);
698
- const coloredLines = [
699
- "",
700
- ...centeredTitleArt.map((line) => `${ANSI_BOLD}${ANSI_CYAN}${line}${ANSI_RESET}`),
701
- "",
702
- `${ANSI_BOLD}${centeredHeading}${ANSI_RESET}`,
703
- `${ANSI_GREEN}${separator}${ANSI_RESET}`,
704
- "Plugin configuration page is ready.",
705
- "Open one of the following URLs in your browser:",
706
- "",
707
- ...urlLines.map((line) => `${ANSI_BOLD}${ANSI_GREEN}${line}${ANSI_RESET}`),
708
- "",
709
- "Tip: keep this window open while you finish the setup.",
710
- "",
711
- ];
712
- const horizontalBorder = `+${"=".repeat(contentWidth + 2)}+`;
713
-
714
- return `\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}\n${coloredLines
715
- .map((line, index) => padBoxLine(line, visibleLineWidths[index], contentWidth))
716
- .join("\n")}\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}`;
717
- }
718
-
719
- function renderHookPolicyRestartBanner(profile, addedKeys) {
720
- const heading = "PLUGIN HOOK POLICY UPDATED";
721
- const subheading = "Manual gateway restart required";
722
- const command = `${profile.cliName} gateway restart`;
723
- const keyLines = addedKeys.map((key) => ` - ${key}`);
724
-
725
- const plainLines = [
726
- "",
727
- heading,
728
- subheading,
729
- "",
730
- "Patched file:",
731
- ` ${profile.configPath}`,
732
- "",
733
- "Added hook policies:",
734
- ...keyLines,
735
- "",
736
- "To apply the change, restart the gateway:",
737
- "",
738
- ` $ ${command}`,
739
- "",
740
- "Without a restart, hooks like `agent_end` will not be registered,",
741
- "so memory capture / recall will silently stop working.",
742
- "",
743
- ];
744
-
745
- const contentWidth = plainLines.reduce((max, line) => Math.max(max, line.length), 0);
746
- const centeredHeading = centerText(heading, contentWidth);
747
- const centeredSubheading = centerText(subheading, contentWidth);
748
- const separator = "-".repeat(contentWidth);
749
-
750
- const coloredLines = plainLines.map((line) => {
751
- if (line === heading) return `${ANSI_BOLD}${ANSI_YELLOW}${centeredHeading}${ANSI_RESET}`;
752
- if (line === subheading) return `${ANSI_YELLOW}${centeredSubheading}${ANSI_RESET}`;
753
- if (line === ` $ ${command}`) return `${ANSI_BOLD}${ANSI_GREEN}${line}${ANSI_RESET}`;
754
- if (line === ` ${profile.configPath}`) return `${ANSI_CYAN}${line}${ANSI_RESET}`;
755
- if (line.startsWith(" - ")) return `${ANSI_CYAN}${line}${ANSI_RESET}`;
756
- return line;
757
- });
758
-
759
- const visibleLineWidths = plainLines.map((line, index) => {
760
- if (index === 1 || index === 2) return contentWidth;
761
- return line.length;
762
- });
763
-
764
- // Insert a separator line between the subheading block and the body so
765
- // the title visually anchors to the top of the box.
766
- const headingBoundaryIndex = 3;
767
- coloredLines.splice(headingBoundaryIndex, 0, `${ANSI_YELLOW}${separator}${ANSI_RESET}`);
768
- visibleLineWidths.splice(headingBoundaryIndex, 0, contentWidth);
769
-
770
- const padYellowBoxLine = (content, visibleLength) =>
771
- `${ANSI_YELLOW}| ${ANSI_RESET}${content}${" ".repeat(Math.max(0, contentWidth - visibleLength))}${ANSI_YELLOW} |${ANSI_RESET}`;
772
-
773
- const horizontalBorder = `${ANSI_YELLOW}+${"=".repeat(contentWidth + 2)}+${ANSI_RESET}`;
774
-
775
- return `\n${horizontalBorder}\n${coloredLines
776
- .map((line, index) => padYellowBoxLine(line, visibleLineWidths[index]))
777
- .join("\n")}\n${horizontalBorder}\n`;
778
- }
779
-
780
- function renderHtml(service) {
781
- return replaceTokens(loadAssetTemplate("index.html"), {
782
- "__PLUGIN_ID__": PLUGIN_ID,
783
- "__APP_JS_URL__": `/app.js?token=${service.token}`,
784
- });
785
- }
786
-
787
- function renderAppJs(service) {
788
- return replaceTokens(loadAssetTemplate("app.js"), {
789
- "__CONFIG_UI_TOKEN__": jsonString(service.token),
790
- "__FIELD_GROUPS__": jsonString(FIELD_GROUPS),
791
- "__FIELD_DEFINITIONS__": jsonString(FIELD_DEFINITIONS),
792
- });
793
- }
794
-
795
- // Legacy restart functions removed for sandbox compliance
796
-
797
- function listenOnPort(server, host, port) {
798
- return new Promise((resolve, reject) => {
799
- const onError = (error) => {
800
- cleanup();
801
- reject(error);
802
- };
803
- const onListening = () => {
804
- cleanup();
805
- resolve(port);
806
- };
807
- const cleanup = () => {
808
- server.off("error", onError);
809
- server.off("listening", onListening);
810
- };
811
- server.once("error", onError);
812
- server.once("listening", onListening);
813
- server.listen(port, host);
814
- });
815
- }
816
-
817
- async function bindWithFallback(server) {
818
- for (let offset = 0; offset < UI_PORT_ATTEMPTS; offset += 1) {
819
- const port = UI_BASE_PORT + offset;
820
- try {
821
- await listenOnPort(server, UI_HOST, port);
822
- return port;
823
- } catch (error) {
824
- if (error?.code !== "EADDRINUSE") throw error;
825
- }
826
- }
827
- throw new Error(`Could not bind config UI after trying ${UI_PORT_ATTEMPTS} ports from ${UI_BASE_PORT}.`);
828
- }
829
-
830
- function readRequestBody(req) {
831
- return new Promise((resolve, reject) => {
832
- let body = "";
833
- req.setEncoding("utf8");
834
- req.on("data", (chunk) => {
835
- body += chunk;
836
- if (body.length > 1024 * 1024) {
837
- reject(new Error("Request body too large."));
838
- req.destroy();
839
- }
840
- });
841
- req.on("end", () => resolve(body));
842
- req.on("error", reject);
843
- });
844
- }
845
-
846
- function sendJson(res, statusCode, payload) {
847
- res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
848
- res.end(JSON.stringify(payload));
849
- }
850
-
851
- function sendText(res, statusCode, message) {
852
- res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
853
- res.end(message);
854
- }
855
-
856
- function isAuthorized(req, service) {
857
- return req.headers["x-memos-config-token"] === service.token;
858
- }
859
-
860
- async function createService(log) {
861
- const profile = detectRuntimeProfile();
862
- const token = randomBytes(24).toString("hex");
863
- const bootId = randomBytes(10).toString("hex");
864
-
611
+ return readFileSync(join(ASSET_DIR, name), "utf8");
612
+ }
613
+
614
+ function getAssetRevision() {
615
+ const files = ["index.html", "app.js", "app.css"];
616
+ const parts = [];
617
+ for (const name of files) {
618
+ try {
619
+ const stat = statSync(join(ASSET_DIR, name));
620
+ parts.push(`${name}:${stat.mtimeMs}:${stat.size}`);
621
+ } catch {
622
+ parts.push(`${name}:missing`);
623
+ }
624
+ }
625
+ return createRevision(parts);
626
+ }
627
+
628
+ function replaceTokens(template, tokenMap) {
629
+ let output = template;
630
+ for (const [token, value] of Object.entries(tokenMap)) {
631
+ output = output.split(token).join(value);
632
+ }
633
+ return output;
634
+ }
635
+
636
+ function jsonString(value) {
637
+ return JSON.stringify(value).replace(/</g, "\\u003c");
638
+ }
639
+
640
+ function getAccessibleUrls(port) {
641
+ return [`http://127.0.0.1:${port}`];
642
+ }
643
+
644
+ function centerText(text, width) {
645
+ const left = Math.max(0, Math.floor((width - text.length) / 2));
646
+ const right = Math.max(0, width - text.length - left);
647
+ return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
648
+ }
649
+
650
+ function padBoxLine(content, visibleLength, width) {
651
+ return `${ANSI_GREEN}| ${ANSI_RESET}${content}${" ".repeat(Math.max(0, width - visibleLength))}${ANSI_GREEN} |${ANSI_RESET}`;
652
+ }
653
+
654
+ function renderConfigAddressBanner(port) {
655
+ const urls = getAccessibleUrls(port);
656
+ const titleArt = [
657
+ " __ __ ______ __ __ ____ _____ ",
658
+ " | \\/ | | ____| | \\/ | / __ \\ / ____| ",
659
+ " | \\ / | | |__ | \\ / | | | | | | (___ ",
660
+ " | |\\/| | | __| | |\\/| | | | | | \\___ \\ ",
661
+ " | | | | | |____ | | | | | |__| | ____) | ",
662
+ " |_| |_| |______| |_| |_| \\____/ |_____/ "
663
+ ];
664
+ const heading = "Plugin Configuration";
665
+ const urlLines = urls.map((url, index) => ` [${index + 1}] ${url}`);
666
+ const plainLines = [
667
+ "",
668
+ ...titleArt,
669
+ "",
670
+ heading,
671
+ "",
672
+ "Plugin configuration page is ready.",
673
+ "Open one of the following URLs in your browser:",
674
+ "",
675
+ ...urlLines,
676
+ "",
677
+ "Tip: keep this window open while you finish the setup.",
678
+ "",
679
+ ];
680
+ const contentWidth = plainLines.reduce((max, line) => Math.max(max, line.length), 0);
681
+ const centeredTitleArt = titleArt.map((line) => centerText(line, contentWidth));
682
+ const centeredHeading = centerText(heading, contentWidth);
683
+ const visibleLineWidths = [
684
+ 0,
685
+ ...centeredTitleArt.map(() => contentWidth),
686
+ 0,
687
+ contentWidth,
688
+ contentWidth,
689
+ "Plugin configuration page is ready.".length,
690
+ "Open one of the following URLs in your browser:".length,
691
+ 0,
692
+ ...urlLines.map((line) => line.length),
693
+ 0,
694
+ "Tip: keep this window open while you finish the setup.".length,
695
+ 0,
696
+ ];
697
+ const separator = "-".repeat(contentWidth);
698
+ const coloredLines = [
699
+ "",
700
+ ...centeredTitleArt.map((line) => `${ANSI_BOLD}${ANSI_CYAN}${line}${ANSI_RESET}`),
701
+ "",
702
+ `${ANSI_BOLD}${centeredHeading}${ANSI_RESET}`,
703
+ `${ANSI_GREEN}${separator}${ANSI_RESET}`,
704
+ "Plugin configuration page is ready.",
705
+ "Open one of the following URLs in your browser:",
706
+ "",
707
+ ...urlLines.map((line) => `${ANSI_BOLD}${ANSI_GREEN}${line}${ANSI_RESET}`),
708
+ "",
709
+ "Tip: keep this window open while you finish the setup.",
710
+ "",
711
+ ];
712
+ const horizontalBorder = `+${"=".repeat(contentWidth + 2)}+`;
713
+
714
+ return `\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}\n${coloredLines
715
+ .map((line, index) => padBoxLine(line, visibleLineWidths[index], contentWidth))
716
+ .join("\n")}\n${ANSI_GREEN}${horizontalBorder}${ANSI_RESET}`;
717
+ }
718
+
719
+ function renderHookPolicyRestartBanner(profile, addedKeys) {
720
+ const heading = "PLUGIN HOOK POLICY UPDATED";
721
+ const subheading = "Manual gateway restart required";
722
+ const command = `${profile.cliName} gateway restart`;
723
+ const keyLines = addedKeys.map((key) => ` - ${key}`);
724
+
725
+ const plainLines = [
726
+ "",
727
+ heading,
728
+ subheading,
729
+ "",
730
+ "Patched file:",
731
+ ` ${profile.configPath}`,
732
+ "",
733
+ "Added hook policies:",
734
+ ...keyLines,
735
+ "",
736
+ "To apply the change, restart the gateway:",
737
+ "",
738
+ ` $ ${command}`,
739
+ "",
740
+ "Without a restart, hooks like `agent_end` will not be registered,",
741
+ "so memory capture / recall will silently stop working.",
742
+ "",
743
+ ];
744
+
745
+ const contentWidth = plainLines.reduce((max, line) => Math.max(max, line.length), 0);
746
+ const centeredHeading = centerText(heading, contentWidth);
747
+ const centeredSubheading = centerText(subheading, contentWidth);
748
+ const separator = "-".repeat(contentWidth);
749
+
750
+ const coloredLines = plainLines.map((line) => {
751
+ if (line === heading) return `${ANSI_BOLD}${ANSI_YELLOW}${centeredHeading}${ANSI_RESET}`;
752
+ if (line === subheading) return `${ANSI_YELLOW}${centeredSubheading}${ANSI_RESET}`;
753
+ if (line === ` $ ${command}`) return `${ANSI_BOLD}${ANSI_GREEN}${line}${ANSI_RESET}`;
754
+ if (line === ` ${profile.configPath}`) return `${ANSI_CYAN}${line}${ANSI_RESET}`;
755
+ if (line.startsWith(" - ")) return `${ANSI_CYAN}${line}${ANSI_RESET}`;
756
+ return line;
757
+ });
758
+
759
+ const visibleLineWidths = plainLines.map((line, index) => {
760
+ if (index === 1 || index === 2) return contentWidth;
761
+ return line.length;
762
+ });
763
+
764
+ // Insert a separator line between the subheading block and the body so
765
+ // the title visually anchors to the top of the box.
766
+ const headingBoundaryIndex = 3;
767
+ coloredLines.splice(headingBoundaryIndex, 0, `${ANSI_YELLOW}${separator}${ANSI_RESET}`);
768
+ visibleLineWidths.splice(headingBoundaryIndex, 0, contentWidth);
769
+
770
+ const padYellowBoxLine = (content, visibleLength) =>
771
+ `${ANSI_YELLOW}| ${ANSI_RESET}${content}${" ".repeat(Math.max(0, contentWidth - visibleLength))}${ANSI_YELLOW} |${ANSI_RESET}`;
772
+
773
+ const horizontalBorder = `${ANSI_YELLOW}+${"=".repeat(contentWidth + 2)}+${ANSI_RESET}`;
774
+
775
+ return `\n${horizontalBorder}\n${coloredLines
776
+ .map((line, index) => padYellowBoxLine(line, visibleLineWidths[index]))
777
+ .join("\n")}\n${horizontalBorder}\n`;
778
+ }
779
+
780
+ function renderHtml(service) {
781
+ return replaceTokens(loadAssetTemplate("index.html"), {
782
+ "__PLUGIN_ID__": PLUGIN_ID,
783
+ "__APP_JS_URL__": `/app.js?token=${service.token}`,
784
+ });
785
+ }
786
+
787
+ function renderAppJs(service) {
788
+ return replaceTokens(loadAssetTemplate("app.js"), {
789
+ "__CONFIG_UI_TOKEN__": jsonString(service.token),
790
+ "__FIELD_GROUPS__": jsonString(FIELD_GROUPS),
791
+ "__FIELD_DEFINITIONS__": jsonString(FIELD_DEFINITIONS),
792
+ });
793
+ }
794
+
795
+ // Legacy restart functions removed for sandbox compliance
796
+
797
+ function listenOnPort(server, host, port) {
798
+ return new Promise((resolve, reject) => {
799
+ const onError = (error) => {
800
+ cleanup();
801
+ reject(error);
802
+ };
803
+ const onListening = () => {
804
+ cleanup();
805
+ resolve(port);
806
+ };
807
+ const cleanup = () => {
808
+ server.off("error", onError);
809
+ server.off("listening", onListening);
810
+ };
811
+ server.once("error", onError);
812
+ server.once("listening", onListening);
813
+ server.listen(port, host);
814
+ });
815
+ }
816
+
817
+ async function bindWithFallback(server) {
818
+ for (let offset = 0; offset < UI_PORT_ATTEMPTS; offset += 1) {
819
+ const port = UI_BASE_PORT + offset;
820
+ try {
821
+ await listenOnPort(server, UI_HOST, port);
822
+ return port;
823
+ } catch (error) {
824
+ if (error?.code !== "EADDRINUSE") throw error;
825
+ }
826
+ }
827
+ throw new Error(`Could not bind config UI after trying ${UI_PORT_ATTEMPTS} ports from ${UI_BASE_PORT}.`);
828
+ }
829
+
830
+ function readRequestBody(req) {
831
+ return new Promise((resolve, reject) => {
832
+ let body = "";
833
+ req.setEncoding("utf8");
834
+ req.on("data", (chunk) => {
835
+ body += chunk;
836
+ if (body.length > 1024 * 1024) {
837
+ reject(new Error("Request body too large."));
838
+ req.destroy();
839
+ }
840
+ });
841
+ req.on("end", () => resolve(body));
842
+ req.on("error", reject);
843
+ });
844
+ }
845
+
846
+ function sendJson(res, statusCode, payload) {
847
+ res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
848
+ res.end(JSON.stringify(payload));
849
+ }
850
+
851
+ function sendText(res, statusCode, message) {
852
+ res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
853
+ res.end(message);
854
+ }
855
+
856
+ function isAuthorized(req, service) {
857
+ return req.headers["x-memos-config-token"] === service.token;
858
+ }
859
+
860
+ async function createService(log) {
861
+ const profile = detectRuntimeProfile();
862
+ const token = randomBytes(24).toString("hex");
863
+ const bootId = randomBytes(10).toString("hex");
864
+
865
865
  const service = {
866
866
  profile,
867
867
  token,
@@ -873,50 +873,50 @@ async function createService(log) {
873
873
  heartbeatCache: null,
874
874
  updateStatusCache: null,
875
875
  };
876
-
877
- const server = createServer(async (req, res) => {
878
- try {
879
- const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
880
-
881
- if (requestUrl.pathname === "/favicon.ico") {
882
- res.writeHead(204);
883
- res.end();
884
- return;
885
- }
886
-
887
- if (requestUrl.pathname === "/icon.svg") {
888
- res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "no-store" });
889
- res.end(loadAssetTemplate("icon.svg"));
890
- return;
891
- }
892
-
893
- if (requestUrl.pathname === "/app.css") {
894
- res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-store" });
895
- res.end(loadAssetTemplate("app.css"));
896
- return;
897
- }
898
-
899
- if (requestUrl.pathname === "/app.js") {
900
- if (requestUrl.searchParams.get("token") !== service.token) {
901
- sendText(res, 403, "Forbidden");
902
- return;
903
- }
904
- res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store" });
905
- res.end(renderAppJs(service));
906
- return;
907
- }
908
-
909
- if (requestUrl.pathname === "/api/heartbeat" && req.method === "GET") {
910
- sendJson(res, 200, getCachedHeartbeatPayload(service));
911
- return;
912
- }
913
-
914
- if (requestUrl.pathname.startsWith("/api/")) {
915
- if (!isAuthorized(req, service)) {
916
- sendText(res, 403, "Forbidden");
917
- return;
918
- }
919
-
876
+
877
+ const server = createServer(async (req, res) => {
878
+ try {
879
+ const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
880
+
881
+ if (requestUrl.pathname === "/favicon.ico") {
882
+ res.writeHead(204);
883
+ res.end();
884
+ return;
885
+ }
886
+
887
+ if (requestUrl.pathname === "/icon.svg") {
888
+ res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "no-store" });
889
+ res.end(loadAssetTemplate("icon.svg"));
890
+ return;
891
+ }
892
+
893
+ if (requestUrl.pathname === "/app.css") {
894
+ res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-store" });
895
+ res.end(loadAssetTemplate("app.css"));
896
+ return;
897
+ }
898
+
899
+ if (requestUrl.pathname === "/app.js") {
900
+ if (requestUrl.searchParams.get("token") !== service.token) {
901
+ sendText(res, 403, "Forbidden");
902
+ return;
903
+ }
904
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store" });
905
+ res.end(renderAppJs(service));
906
+ return;
907
+ }
908
+
909
+ if (requestUrl.pathname === "/api/heartbeat" && req.method === "GET") {
910
+ sendJson(res, 200, getCachedHeartbeatPayload(service));
911
+ return;
912
+ }
913
+
914
+ if (requestUrl.pathname.startsWith("/api/")) {
915
+ if (!isAuthorized(req, service)) {
916
+ sendText(res, 403, "Forbidden");
917
+ return;
918
+ }
919
+
920
920
  if (requestUrl.pathname === "/api/state" && req.method === "GET") {
921
921
  sendJson(res, 200, getCachedStatePayload(service));
922
922
  return;
@@ -930,109 +930,109 @@ async function createService(log) {
930
930
  }
931
931
 
932
932
  if (requestUrl.pathname === "/api/save" && req.method === "POST") {
933
- let parsed = {};
934
- try {
935
- parsed = JSON.parse((await readRequestBody(req)) || "{}");
936
- } catch {
937
- sendText(res, 400, "Invalid JSON payload.");
938
- return;
939
- }
940
- if (!isPlainObject(parsed)) {
941
- sendText(res, 400, "Invalid JSON payload.");
942
- return;
943
- }
944
- if (typeof parsed.enabled !== "boolean") {
945
- sendText(res, 400, "Payload.enabled must be a boolean.");
946
- return;
947
- }
948
- if (!isPlainObject(parsed.config)) {
949
- sendText(res, 400, "Payload.config must be an object.");
950
- return;
951
- }
952
-
953
- const nextState = writeGatewayConfig(profile, parsed);
954
- service.stateCache = null;
955
- service.heartbeatCache = null;
956
- sendJson(res, 200, { ok: true, state: buildStatePayload({ ...service, profile: nextState.profile }) });
957
- return;
958
- }
959
-
960
- // Legacy `/api/restart` has been entirely removed
961
- sendText(res, 404, "Not found");
962
- return;
963
- }
964
-
965
- if (requestUrl.pathname !== "/") {
966
- sendText(res, 404, "Not found");
967
- return;
968
- }
969
-
970
- res.writeHead(200, {
971
- "Content-Type": "text/html; charset=utf-8",
972
- "Cache-Control": "no-store",
973
- "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;",
974
- });
975
- res.end(renderHtml(service));
976
- } catch (error) {
977
- sendText(res, 500, `Internal error: ${String(error?.message || error)}`);
978
- }
979
- });
980
-
981
- service.server = server;
982
- service.port = await bindWithFallback(server);
983
- service.url = `http://${UI_HOST}:${service.port}`;
984
-
985
- setTimeout(() => {
986
- console.log(renderConfigAddressBanner(service.port));
987
- }, 1200);
988
- return service;
989
- }
990
-
991
- export function ensureConfigUiService(log = console) {
992
- if (!shouldStartConfigUi()) {
993
- return Promise.resolve(null);
994
- }
995
-
996
- const globalState = getGlobalState();
997
- if (globalState.promise) return globalState.promise;
998
-
999
- globalState.promise = createService(log)
1000
- .then((service) => {
1001
- globalState.service = service;
1002
- return service;
1003
- })
1004
- .catch((error) => {
1005
- globalState.service = null;
1006
- globalState.promise = null;
1007
- throw error;
1008
- });
1009
-
1010
- return globalState.promise;
1011
- }
1012
-
1013
- export async function closeConfigUiService(options = {}) {
1014
- const globalState = getGlobalState();
1015
-
1016
- if (!globalState.service) {
1017
- globalState.promise = null;
1018
- return;
1019
- }
1020
-
1021
- const { service } = globalState;
1022
- globalState.service = null;
1023
- globalState.promise = null;
1024
-
1025
- if (service?.server) {
1026
- await new Promise((resolve) => {
1027
- try {
1028
- service.server.close(() => resolve());
1029
- } catch {
1030
- resolve();
1031
- }
1032
- });
1033
- }
1034
- }
1035
-
1036
- export async function runConfigUiChildProcess(log = console) {
1037
- // no-op
1038
- }
933
+ let parsed = {};
934
+ try {
935
+ parsed = JSON.parse((await readRequestBody(req)) || "{}");
936
+ } catch {
937
+ sendText(res, 400, "Invalid JSON payload.");
938
+ return;
939
+ }
940
+ if (!isPlainObject(parsed)) {
941
+ sendText(res, 400, "Invalid JSON payload.");
942
+ return;
943
+ }
944
+ if (typeof parsed.enabled !== "boolean") {
945
+ sendText(res, 400, "Payload.enabled must be a boolean.");
946
+ return;
947
+ }
948
+ if (!isPlainObject(parsed.config)) {
949
+ sendText(res, 400, "Payload.config must be an object.");
950
+ return;
951
+ }
952
+
953
+ const nextState = writeGatewayConfig(profile, parsed);
954
+ service.stateCache = null;
955
+ service.heartbeatCache = null;
956
+ sendJson(res, 200, { ok: true, state: buildStatePayload({ ...service, profile: nextState.profile }) });
957
+ return;
958
+ }
959
+
960
+ // Legacy `/api/restart` has been entirely removed
961
+ sendText(res, 404, "Not found");
962
+ return;
963
+ }
964
+
965
+ if (requestUrl.pathname !== "/") {
966
+ sendText(res, 404, "Not found");
967
+ return;
968
+ }
969
+
970
+ res.writeHead(200, {
971
+ "Content-Type": "text/html; charset=utf-8",
972
+ "Cache-Control": "no-store",
973
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;",
974
+ });
975
+ res.end(renderHtml(service));
976
+ } catch (error) {
977
+ sendText(res, 500, `Internal error: ${String(error?.message || error)}`);
978
+ }
979
+ });
980
+
981
+ service.server = server;
982
+ service.port = await bindWithFallback(server);
983
+ service.url = `http://${UI_HOST}:${service.port}`;
984
+
985
+ setTimeout(() => {
986
+ console.log(renderConfigAddressBanner(service.port));
987
+ }, 1200);
988
+ return service;
989
+ }
990
+
991
+ export function ensureConfigUiService(log = console) {
992
+ if (!shouldStartConfigUi()) {
993
+ return Promise.resolve(null);
994
+ }
995
+
996
+ const globalState = getGlobalState();
997
+ if (globalState.promise) return globalState.promise;
998
+
999
+ globalState.promise = createService(log)
1000
+ .then((service) => {
1001
+ globalState.service = service;
1002
+ return service;
1003
+ })
1004
+ .catch((error) => {
1005
+ globalState.service = null;
1006
+ globalState.promise = null;
1007
+ throw error;
1008
+ });
1009
+
1010
+ return globalState.promise;
1011
+ }
1012
+
1013
+ export async function closeConfigUiService(options = {}) {
1014
+ const globalState = getGlobalState();
1015
+
1016
+ if (!globalState.service) {
1017
+ globalState.promise = null;
1018
+ return;
1019
+ }
1020
+
1021
+ const { service } = globalState;
1022
+ globalState.service = null;
1023
+ globalState.promise = null;
1024
+
1025
+ if (service?.server) {
1026
+ await new Promise((resolve) => {
1027
+ try {
1028
+ service.server.close(() => resolve());
1029
+ } catch {
1030
+ resolve();
1031
+ }
1032
+ });
1033
+ }
1034
+ }
1035
+
1036
+ export async function runConfigUiChildProcess(log = console) {
1037
+ // no-op
1038
+ }