diff --git a/clawdbot.plugin.json b/clawdbot.plugin.json index 2df9c68..5367322 100644 --- a/clawdbot.plugin.json +++ b/clawdbot.plugin.json @@ -2,7 +2,7 @@ "id": "memos-cloud-openclaw-plugin", "name": "MemOS Cloud OpenClaw Plugin", "description": "MemOS Cloud recall + add memory via lifecycle hooks", - "version": "0.1.17", + "version": "0.1.18", "kind": "lifecycle", "main": "./index.js", "activation": { diff --git a/lib/check-update.js b/lib/check-update.js index 906fdf3..75cb71e 100644 --- a/lib/check-update.js +++ b/lib/check-update.js @@ -19,7 +19,7 @@ const ANSI = { }; -function getPackageVersion() { +export function getPackageVersion() { try { const pkgPath = path.join(__dirname, "..", "package.json"); const pkgData = fs.readFileSync(pkgPath, "utf-8"); @@ -30,7 +30,7 @@ function getPackageVersion() { } } -function getLatestVersion(log) { +export function getLatestVersion() { return new Promise((resolve, reject) => { const req = https.get( `https://registry.npmjs.org/${PLUGIN_NAME}/latest`, @@ -68,7 +68,7 @@ function getLatestVersion(log) { }); } -function compareVersions(v1, v2) { +export function compareVersions(v1, v2) { // Split pre-release tags (e.g. 0.1.8-beta.1 -> "0.1.8" and "beta.1") const split1 = v1.split("-"); const split2 = v2.split("-"); @@ -101,6 +101,37 @@ function compareVersions(v1, v2) { return 0; } +function detectCliName() { + // Check the full path of the entry script (e.g., .../moltbot/bin/index.js) or the executable + const scriptPath = process.argv[1] ? process.argv[1].toLowerCase() : ""; + const execPath = process.execPath ? process.execPath.toLowerCase() : ""; + + if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) return "moltbot"; + if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) return "clawdbot"; + return "openclaw"; +} + +export async function checkForPluginUpdate() { + const currentVersion = getPackageVersion(); + if (!currentVersion) { + throw new Error("Could not read current version from package.json"); + } + + const latestVersion = await getLatestVersion(); + const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; + const cliName = detectCliName(); + + return { + pluginName: PLUGIN_NAME, + currentVersion, + latestVersion, + updateAvailable, + cliName, + updateCommand: `${cliName} plugins update memos-cloud-openclaw-plugin`, + checkedAt: new Date().toISOString(), + }; +} + export function startUpdateChecker(log) { // Only start the interval if we are in the gateway const isGateway = process.argv.includes("gateway"); @@ -118,39 +149,23 @@ export function startUpdateChecker(log) { log.warn?.(`${ANSI.RED}[memos-cloud] Failed to write timestamp file: ${e.message}${ANSI.RESET}`); } - const currentVersion = getPackageVersion(); - if (!currentVersion) { - log.warn?.(`${ANSI.RED}[memos-cloud] Could not read current version from package.json${ANSI.RESET}`); - return; - } - try { - const latestVersion = await getLatestVersion(log); + const updateStatus = await checkForPluginUpdate(); // Normal version check - if (compareVersions(latestVersion, currentVersion) <= 0) { + if (!updateStatus.updateAvailable) { return; } - const cliName = (() => { - // Check the full path of the entry script (e.g., .../moltbot/bin/index.js) or the executable - const scriptPath = process.argv[1] ? process.argv[1].toLowerCase() : ""; - const execPath = process.execPath ? process.execPath.toLowerCase() : ""; - - if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) return "moltbot"; - if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) return "clawdbot"; - return "openclaw"; - })(); - const border = "=".repeat(64); log.info?.(""); log.info?.(`${ANSI.GREEN}${border}${ANSI.RESET}`); log.info?.(`${ANSI.YELLOW}🚀 [memos-cloud] NEW VERSION AVAILABLE!${ANSI.RESET}`); - log.info?.(`${ANSI.CYAN}📦 Current version : ${currentVersion}${ANSI.RESET}`); - log.info?.(`${ANSI.GREEN}✨ Latest version : ${latestVersion}${ANSI.RESET}`); + log.info?.(`${ANSI.CYAN}📦 Current version : ${updateStatus.currentVersion}${ANSI.RESET}`); + log.info?.(`${ANSI.GREEN}✨ Latest version : ${updateStatus.latestVersion}${ANSI.RESET}`); log.info?.(`${ANSI.CYAN}────────────────────────────────────────────────────────────────${ANSI.RESET}`); log.info?.(`${ANSI.GREEN}Please run the following command to update manually:${ANSI.RESET}`); - log.info?.(`${ANSI.YELLOW}${cliName} plugins update memos-cloud-openclaw-plugin${ANSI.RESET}`); + log.info?.(`${ANSI.YELLOW}${updateStatus.updateCommand}${ANSI.RESET}`); log.info?.(`${ANSI.GREEN}${border}${ANSI.RESET}`); log.info?.(""); diff --git a/lib/config-ui-server.js b/lib/config-ui-server.js index 1564c0d..dba4214 100644 --- a/lib/config-ui-server.js +++ b/lib/config-ui-server.js @@ -1,12 +1,13 @@ -import { createHash, randomBytes } from "node:crypto"; -import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { Script } from "node:vm"; -import { getConfigResolution } from "./memos-cloud-api.js"; +import { Script } from "node:vm"; +import { checkForPluginUpdate, getPackageVersion } from "./check-update.js"; +import { getConfigResolution } from "./memos-cloud-api.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -21,7 +22,8 @@ const ANSI_CYAN = "\x1b[36m"; const ANSI_GREEN = "\x1b[32m"; const ANSI_YELLOW = "\x1b[33m"; const ANSI_RESET = "\x1b[0m"; -const DEFAULT_GATEWAY_READY_PORT = 18789; +const DEFAULT_GATEWAY_READY_PORT = 18789; +const UPDATE_STATUS_CACHE_MS = 30 * 60 * 1000; // Hook policies that the gateway requires non-bundled plugins to opt into. // Without these, the gateway blocks `agent_end` (and other conversation-access @@ -517,9 +519,10 @@ function buildStatePayload(service) { const state = readGatewayConfig(service.profile); const resolution = getConfigResolution(state.config); return { - runtime: state.profile.id, - runtimeDisplayName: state.profile.displayName, - configPath: state.configPath, + runtime: state.profile.id, + runtimeDisplayName: state.profile.displayName, + pluginVersion: getPackageVersion(), + configPath: state.configPath, entryExists: state.entryExists, fileExists: state.fileExists, enabled: state.enabled, @@ -548,7 +551,7 @@ function getCachedStatePayload(service, maxAgeMs = 1200) { return payload; } -function getCachedHeartbeatPayload(service, maxAgeMs = 1200) { +function getCachedHeartbeatPayload(service, maxAgeMs = 1200) { const now = Date.now(); if (service.heartbeatCache && now - service.heartbeatCache.createdAt < maxAgeMs) { return { @@ -570,9 +573,41 @@ function getCachedHeartbeatPayload(service, maxAgeMs = 1200) { ...payload, timestamp: now, }; -} - -function loadAssetTemplate(name) { +} + +async function getCachedUpdateStatusPayload(service, maxAgeMs = UPDATE_STATUS_CACHE_MS, options = {}) { + const now = Date.now(); + if (!options.force && service.updateStatusCache && now - service.updateStatusCache.createdAt < maxAgeMs) { + return { + ...service.updateStatusCache.payload, + cached: true, + }; + } + + try { + const status = await checkForPluginUpdate(); + const payload = { + ok: true, + ...status, + cached: false, + }; + service.updateStatusCache = { + createdAt: now, + payload, + }; + return payload; + } catch (error) { + return { + ok: false, + currentVersion: getPackageVersion(), + error: String(error?.message || error), + checkedAt: new Date().toISOString(), + cached: false, + }; + } +} + +function loadAssetTemplate(name) { return readFileSync(join(ASSET_DIR, name), "utf8"); } @@ -827,7 +862,17 @@ async function createService(log) { const token = randomBytes(24).toString("hex"); const bootId = randomBytes(10).toString("hex"); - const service = { profile, token, bootId, port: 0, url: "", server: null, stateCache: null, heartbeatCache: null }; + const service = { + profile, + token, + bootId, + port: 0, + url: "", + server: null, + stateCache: null, + heartbeatCache: null, + updateStatusCache: null, + }; const server = createServer(async (req, res) => { try { @@ -872,12 +917,19 @@ async function createService(log) { return; } - if (requestUrl.pathname === "/api/state" && req.method === "GET") { - sendJson(res, 200, getCachedStatePayload(service)); - return; - } - - if (requestUrl.pathname === "/api/save" && req.method === "POST") { + if (requestUrl.pathname === "/api/state" && req.method === "GET") { + sendJson(res, 200, getCachedStatePayload(service)); + return; + } + + if (requestUrl.pathname === "/api/update-status" && req.method === "GET") { + sendJson(res, 200, await getCachedUpdateStatusPayload(service, UPDATE_STATUS_CACHE_MS, { + force: requestUrl.searchParams.get("force") === "1", + })); + return; + } + + if (requestUrl.pathname === "/api/save" && req.method === "POST") { let parsed = {}; try { parsed = JSON.parse((await readRequestBody(req)) || "{}"); diff --git a/lib/config-ui/app.css b/lib/config-ui/app.css index 58ca635..f636d37 100644 --- a/lib/config-ui/app.css +++ b/lib/config-ui/app.css @@ -337,6 +337,99 @@ h1 { color: var(--warn); } +.update-notice { + display: none; + margin-top: 16px; + padding: 14px 16px; + border: 1px solid rgba(181, 71, 8, 0.2); + border-radius: 18px; + background: rgba(181, 71, 8, 0.08); + color: var(--warn); +} + +.update-notice.show { + display: block; +} + +.update-notice.error { + border-color: rgba(180, 35, 24, 0.18); + background: rgba(180, 35, 24, 0.08); + color: var(--danger); +} + +.update-notice.ok, +.update-notice.info { + border-color: rgba(15, 118, 110, 0.18); + background: rgba(15, 118, 110, 0.08); + color: var(--accent2); +} + +.update-check-button { + appearance: none; + min-height: 38px; + padding: 8px 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.74); + color: var(--strong); + font-weight: 700; + cursor: pointer; + transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease, transform 160ms ease; +} + +.update-check-button:hover { + border-color: rgba(15, 118, 110, 0.2); + color: var(--accent2); + transform: translateY(-1px); +} + +.update-check-button:focus { + outline: none; + border-color: rgba(15, 118, 110, 0.34); + box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); +} + +.update-check-button:disabled { + cursor: wait; + opacity: 0.7; + transform: none; +} + +.update-copy { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.update-title { + margin-bottom: 4px; + color: var(--strong); + font-weight: 800; +} + +.update-body { + font-size: 13px; + line-height: 1.55; +} + +.update-command { + display: block; + margin-top: 12px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + color: var(--strong); + font-family: "Cascadia Code", "Consolas", monospace; + font-size: 12px; + line-height: 1.45; + overflow-x: auto; +} + +.update-copy-button { + flex: 0 0 auto; + white-space: nowrap; +} + .hero-actions { display: grid; gap: 12px; @@ -609,6 +702,11 @@ h1 { cursor: pointer; } +.inline-btn:disabled { + opacity: 0.58; + cursor: not-allowed; +} + .error { color: var(--danger); font-size: 12px; @@ -868,6 +966,10 @@ body.nav-collapsed .floating-nav { padding: 18px; } + .update-copy { + display: grid; + } + .card, .panel { padding: 18px; diff --git a/lib/config-ui/app.js b/lib/config-ui/app.js index 576bc64..d5f68fa 100644 --- a/lib/config-ui/app.js +++ b/lib/config-ui/app.js @@ -6,6 +6,9 @@ const APP = { const knownKeys = new Set(APP.fieldDefinitions.map((field) => field.key)); let remoteState = null; +let updateStatus = null; +let updateStatusChecked = false; +let updateStatusLoading = false; let draft = null; let baselineSnapshot = ""; @@ -41,6 +44,8 @@ const elements = { languageOptionZh: document.getElementById("languageOptionZh"), statusRow: document.getElementById("statusRow"), metaRow: document.getElementById("metaRow"), + updateCheckButton: document.getElementById("updateCheckButton") || document.createElement("button"), + updateNotice: document.getElementById("updateNotice"), banner: document.getElementById("banner"), floatingTools: document.getElementById("floatingTools"), floatingNavLabel: document.getElementById("floatingNavLabel"), @@ -128,6 +133,7 @@ const UI_TEXT = { bannerAuthWaiting: "Config session expired. Waiting for the gateway to finish restarting before retrying...", bannerAuthFailed: "Config session expired. Refresh this page to reconnect.", bannerCopied: "The config file path was copied to your clipboard.", + bannerUpdateCommandCopied: "The update command was copied to your clipboard.", bannerClipboardFailed: "Clipboard access failed. Copy the address from your browser bar instead.", bannerSaved: "The plugin config was saved.", bannerRestartLaunched: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.", @@ -135,12 +141,21 @@ const UI_TEXT = { bannerWaitingBack: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.", bannerRestartRefreshHint: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.", pillPlugin: "Plugin", + pillVersion: "Version", pillRuntime: "Runtime", pillEntry: "Entry", pillPageUrl: "Page URL", - pillRevision: "Revision", + pillRevision: "Config revision", pillConfigFile: "Config file", pillInclude: "Include", + checkUpdates: "Check for updates", + checkingUpdates: "Checking...", + updateTitle: "Plugin update available", + updateBody: "Current version {current}; latest version {latest}. Update and restart with:", + updateCopyCommand: "Copy Update And Restart Command", + upToDateTitle: "Already up to date", + upToDateBody: "Current version {current} is the latest version.", + updateCheckFailed: "Version check failed: {error}", pillEntryPresent: "present", pillEntryMissing: "missing", pillConfigFound: "found", @@ -209,6 +224,7 @@ const UI_TEXT = { bannerAuthWaiting: "配置页会话已过期,正在等待 Gateway 完成重启后再恢复...", bannerAuthFailed: "配置页会话已过期,请刷新页面后重新连接。", bannerCopied: "配置文件路径已复制到剪贴板。", + bannerUpdateCommandCopied: "更新命令已复制到剪贴板。", bannerClipboardFailed: "复制失败,请直接从浏览器地址栏复制。", bannerSaved: "插件配置已保存。", bannerRestartLaunched: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。", @@ -216,12 +232,21 @@ const UI_TEXT = { bannerWaitingBack: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。", bannerRestartRefreshHint: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。", pillPlugin: "插件", + pillVersion: "版本", pillRuntime: "运行时", pillEntry: "配置项", pillPageUrl: "页面地址", - pillRevision: "版本标识", + pillRevision: "配置标识", pillConfigFile: "配置文件", pillInclude: "包含", + checkUpdates: "检查更新", + checkingUpdates: "检查中...", + updateTitle: "插件有新版本", + updateBody: "当前版本 {current};最新版本 {latest}。可使用以下命令更新并重启:", + updateCopyCommand: "复制更新并重启命令", + upToDateTitle: "已是最新版本", + upToDateBody: "当前版本 {current} 已经是最新版本。", + updateCheckFailed: "版本检查失败:{error}", pillEntryPresent: "已存在", pillEntryMissing: "不存在", pillConfigFound: "已找到", @@ -307,6 +332,14 @@ function uiText(key) { return UI_TEXT[lang][key] ?? UI_TEXT.en[key] ?? key; } +function formatUiText(key, values) { + let text = uiText(key); + for (const [name, value] of Object.entries(values || {})) { + text = text.split(`{${name}}`).join(String(value ?? "")); + } + return text; +} + function localizedGroup(group) { const lang = getCurrentLanguage(); const translated = GROUP_TRANSLATIONS[lang]?.[group.id]; @@ -340,6 +373,10 @@ function applyLanguageUi() { elements.copyPathButton.setAttribute("aria-label", uiText("copyPath")); elements.copyPathButton.setAttribute("title", uiText("copyPath")); elements.saveButton.textContent = uiText("save"); + elements.updateCheckButton.id = "updateCheckButton"; + elements.updateCheckButton.type = "button"; + elements.updateCheckButton.className = "pill update-check-button"; + updateCheckButtonUi(); elements.reloadButton.textContent = uiText("reload"); elements.overlayTitle.textContent = uiText("overlayTitle"); @@ -350,6 +387,22 @@ function applyLanguageUi() { elements.languageOptionEn.textContent = "EN"; elements.languageOptionZh.textContent = "中文"; updateLanguageDropdownUi(); + renderUpdateNotice(); +} + +function updateCheckButtonUi() { + if (!elements.updateCheckButton) return; + const version = remoteState?.pluginVersion || "unknown"; + const action = updateStatusLoading ? uiText("checkingUpdates") : uiText("checkUpdates"); + elements.updateCheckButton.innerHTML = + "" + + escapeHtml(uiText("pillVersion")) + + "" + + escapeHtml(version + " · " + action) + + ""; + elements.updateCheckButton.setAttribute("aria-label", uiText("pillVersion") + " " + version + ", " + action); + elements.updateCheckButton.setAttribute("title", action); + elements.updateCheckButton.disabled = updateStatusLoading; } function updateFloatingNavToggleUi() { @@ -722,11 +775,16 @@ function renderStatus() { elements.statusRow.innerHTML = ""; elements.metaRow.innerHTML = ""; elements.pathBox.textContent = remoteState ? remoteState.configPath : ""; + if (elements.updateCheckButton && !elements.updateCheckButton.parentElement) { + elements.updateCheckButton.remove(); + } if (!remoteState) return; elements.statusRow.appendChild( createPill(remoteState.enabled ? "ok" : "warn", uiText("pillPlugin"), remoteState.enabled ? uiText("enabled") : uiText("disabled")), ); + updateCheckButtonUi(); + elements.statusRow.appendChild(elements.updateCheckButton); elements.statusRow.appendChild(createPill("", uiText("pillRuntime"), remoteState.runtimeDisplayName)); elements.statusRow.appendChild( createPill(remoteState.entryExists ? "" : "warn", uiText("pillEntry"), remoteState.entryExists ? uiText("pillEntryPresent") : uiText("pillEntryMissing")), @@ -743,6 +801,70 @@ function renderStatus() { } } +function renderUpdateNotice() { + if (!elements.updateNotice) return; + + if (updateStatusLoading) { + elements.updateNotice.hidden = false; + elements.updateNotice.className = "update-notice info show"; + elements.updateNotice.innerHTML = + '
' +
+ escapeHtml(command) +
+ "";
+
+ elements.updateNotice.querySelector(".update-copy-button")?.addEventListener("click", () => {
+ void copyUpdateCommand(command);
+ });
+ return;
+ }
+
+ if (updateStatus.ok === false) {
+ elements.updateNotice.innerHTML = escapeHtml(formatUiText("updateCheckFailed", { error: updateStatus.error || "Unknown error" }));
+ return;
+ }
+
+ elements.updateNotice.innerHTML =
+ '