@2en/clawly-plugins 1.32.0 → 1.34.0-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.
package/auto-pair.ts CHANGED
@@ -37,7 +37,15 @@ export function registerAutoPair(api: PluginApi) {
37
37
 
38
38
  api.on('gateway_start', async () => {
39
39
  try {
40
- sdk = await import('openclaw/plugin-sdk')
40
+ // v2026.3.23+ moved pairing functions to device-bootstrap subpath;
41
+ // older versions export them from the main plugin-sdk index.
42
+ try {
43
+ sdk = await import('openclaw/plugin-sdk/device-bootstrap')
44
+ } catch (err: unknown) {
45
+ const code = (err as NodeJS.ErrnoException)?.code
46
+ if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw err
47
+ sdk = await import('openclaw/plugin-sdk')
48
+ }
41
49
  } catch {
42
50
  api.logger.warn('auto-pair: openclaw/plugin-sdk not available, skipping')
43
51
  return
@@ -39,7 +39,7 @@
39
39
  input: ["text", "image"],
40
40
  contextWindow: 1000000,
41
41
  maxTokens: 128000,
42
- // api: 'anthropic-messages', // TODO: uncomment once model gateway Anthropic Messages API support is out of testing
42
+ api: "anthropic-messages",
43
43
  },
44
44
  {
45
45
  id: "anthropic/claude-opus-4.6",
@@ -47,7 +47,7 @@
47
47
  input: ["text", "image"],
48
48
  contextWindow: 1000000,
49
49
  maxTokens: 128000,
50
- // api: 'anthropic-messages', // TODO: uncomment once model gateway Anthropic Messages API support is out of testing
50
+ api: "anthropic-messages",
51
51
  },
52
52
  {
53
53
  id: "openai/gpt-5.4",
@@ -77,6 +77,13 @@
77
77
  contextWindow: 196608,
78
78
  maxTokens: 196608,
79
79
  },
80
+ {
81
+ id: "qwen/qwen3.5-flash-02-23",
82
+ name: "qwen/qwen3.5-flash-02-23",
83
+ input: ["text", "image"],
84
+ contextWindow: 1000000,
85
+ maxTokens: 65536,
86
+ },
80
87
  {
81
88
  id: "qwen/qwen3.5-plus-02-15",
82
89
  name: "qwen/qwen3.5-plus-02-15",
package/config-setup.ts CHANGED
@@ -237,7 +237,7 @@ export function patchBrowser(config: OpenClawConfig): boolean {
237
237
  // patchHeartbeat — migrated to clawly-config-defaults.json5
238
238
 
239
239
  /** Fields enforced by patchModelDefinitions on every gateway restart. */
240
- const MODEL_ENFORCED_KEYS = ['contextWindow', 'maxTokens', 'input'] as const
240
+ const MODEL_ENFORCED_KEYS = ['contextWindow', 'maxTokens', 'input', 'api'] as const
241
241
 
242
242
  /** Shallow equality — only supports primitives and arrays of primitives. */
243
243
  function valuesEqual(a: unknown, b: unknown): boolean {
@@ -593,6 +593,43 @@ export function patchPluginsAllow(config: OpenClawConfig): boolean {
593
593
  return true
594
594
  }
595
595
 
596
+ /**
597
+ * Ensure the bootstrap-extra-files internal hook is configured so the agent
598
+ * loads system-managed workspace files from .clawly/ alongside user files.
599
+ */
600
+ export function patchBootstrapExtraFiles(config: OpenClawConfig): boolean {
601
+ const hooks = (config.hooks ?? {}) as Record<string, unknown>
602
+ const internal = asObj(hooks.internal)
603
+ const entries = asObj(internal.entries)
604
+
605
+ // Must match systemWorkspacePaths from @clawly/workspace-files.
606
+ // Hardcoded because this plugin runs on sprites without access to that package.
607
+ const EXPECTED_PATHS = ['.clawly/AGENTS.md', '.clawly/TOOLS.md']
608
+
609
+ const existing = entries['bootstrap-extra-files'] as
610
+ | {enabled?: boolean; paths?: string[]}
611
+ | undefined
612
+ if (
613
+ internal.enabled === true &&
614
+ existing?.enabled === true &&
615
+ Array.isArray(existing.paths) &&
616
+ existing.paths.length === EXPECTED_PATHS.length &&
617
+ EXPECTED_PATHS.every((p) => existing.paths!.includes(p))
618
+ ) {
619
+ return false
620
+ }
621
+
622
+ entries['bootstrap-extra-files'] = {
623
+ enabled: true,
624
+ paths: EXPECTED_PATHS,
625
+ }
626
+ internal.enabled = true
627
+ internal.entries = entries
628
+ hooks.internal = internal
629
+ config.hooks = hooks as OpenClawConfig['hooks']
630
+ return true
631
+ }
632
+
596
633
  function reconcileRuntimeConfig(
597
634
  api: PluginApi,
598
635
  config: OpenClawConfig,
@@ -618,6 +655,7 @@ function reconcileRuntimeConfig(
618
655
  dirty = patchGateway(config) || dirty
619
656
  dirty = patchBrowser(config) || dirty
620
657
  dirty = patchSession(config) || dirty
658
+ dirty = patchBootstrapExtraFiles(config) || dirty
621
659
 
622
660
  const defaults = loadDefaults(stateDir)
623
661
  if (defaults) {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Cron history injection — before_agent_start hook that prepends past run
3
+ * summaries to cron job prompts, preventing the LLM from repeating the same
4
+ * output across isolated sessions.
5
+ *
6
+ * Feature-flagged via agents.defaults.cronHistoryInjection in openclaw.json
7
+ * (default: off). Toggled from the mobile app's system settings.
8
+ *
9
+ * Data source: the existing cron run log JSONL at
10
+ * {cronStoreDir}/runs/{jobId}.jsonl — populated by OpenClaw after each run
11
+ * with a `summary` field (last agent output, truncated to 2000 chars).
12
+ */
13
+
14
+ import fs from 'node:fs/promises'
15
+ import path from 'node:path'
16
+
17
+ import {autoUpdateJobId} from './internal/hooks/auto-update'
18
+ import type {PluginApi} from './types'
19
+ import type {OpenClawConfig} from './types/openclaw'
20
+
21
+ const HISTORY_DEPTH = 10
22
+ const SUMMARY_MAX_CHARS = 200
23
+
24
+ /** Extract the first sentence or first line, truncated to maxChars. */
25
+ function truncateSummary(summary: string, maxChars: number): string {
26
+ // Take first line
27
+ const firstLine = summary.split('\n')[0]?.trim() ?? summary.trim()
28
+ if (firstLine.length <= maxChars) return firstLine
29
+ return `${firstLine.slice(0, maxChars)}…`
30
+ }
31
+
32
+ /** Parse job ID from the cron prompt prefix: [cron:{jobId} {jobName}] */
33
+ function parseJobIdFromPrompt(prompt: string): string | null {
34
+ const match = prompt.match(/\[cron:([0-9a-f-]+)\s/)
35
+ return match?.[1] ?? null
36
+ }
37
+
38
+ /** Resolve the cron run log path for a job. */
39
+ function resolveRunLogPath(cronStore: string | undefined, jobId: string): string {
40
+ const storePath =
41
+ cronStore || path.join(process.env.HOME || '/home/claw', '.openclaw', 'cron', 'cron.json')
42
+ return path.join(path.dirname(storePath), 'runs', `${jobId}.jsonl`)
43
+ }
44
+
45
+ interface RunLogEntry {
46
+ ts: number
47
+ status?: string
48
+ summary?: string
49
+ }
50
+
51
+ /** Read last N successful run summaries from the JSONL run log. */
52
+ async function readRecentSummaries(
53
+ logPath: string,
54
+ limit: number,
55
+ ): Promise<Array<{ts: number; summary: string}>> {
56
+ let raw: string
57
+ try {
58
+ raw = await fs.readFile(logPath, 'utf-8')
59
+ } catch {
60
+ return []
61
+ }
62
+ if (!raw.trim()) return []
63
+
64
+ const lines = raw.split('\n')
65
+ const results: Array<{ts: number; summary: string}> = []
66
+
67
+ // Read from the end (most recent first)
68
+ for (let i = lines.length - 1; i >= 0 && results.length < limit; i--) {
69
+ const line = lines[i]?.trim()
70
+ if (!line) continue
71
+ try {
72
+ const entry = JSON.parse(line) as Partial<RunLogEntry>
73
+ if (entry.status !== 'ok') continue
74
+ const summary = entry.summary?.trim()
75
+ if (!summary) continue
76
+ if (typeof entry.ts !== 'number') continue
77
+ results.push({ts: entry.ts, summary})
78
+ } catch {
79
+ // skip malformed lines
80
+ }
81
+ }
82
+
83
+ return results.reverse()
84
+ }
85
+
86
+ function formatDate(ts: number): string {
87
+ const d = new Date(ts)
88
+ const y = d.getFullYear()
89
+ const m = String(d.getMonth() + 1).padStart(2, '0')
90
+ const day = String(d.getDate()).padStart(2, '0')
91
+ return `${y}-${m}-${day}`
92
+ }
93
+
94
+ export function registerCronHistory(api: PluginApi) {
95
+ api.on(
96
+ 'before_agent_start',
97
+ async (
98
+ event: {prompt: string; messages?: unknown[]},
99
+ ctx?: {agentId?: string; sessionKey?: string; sessionId?: string},
100
+ ) => {
101
+ // Feature flag check — read fresh config each time (loadConfig has ~200ms cache)
102
+ try {
103
+ const config = api.runtime.config.loadConfig() as OpenClawConfig
104
+ const defaults = (config.agents as Record<string, unknown> | undefined)?.defaults as
105
+ | Record<string, unknown>
106
+ | undefined
107
+ if (defaults?.cronHistoryInjection !== true) return
108
+ } catch {
109
+ return
110
+ }
111
+
112
+ // Only fire for cron sessions
113
+ const sessionKey = ctx?.sessionKey
114
+ if (!sessionKey?.includes(':cron:')) return
115
+
116
+ // Extract job ID from the prompt prefix
117
+ const jobId = parseJobIdFromPrompt(event.prompt)
118
+ if (!jobId) return
119
+
120
+ // Skip auto-update job
121
+ if (autoUpdateJobId && jobId === autoUpdateJobId) return
122
+
123
+ try {
124
+ const logPath = resolveRunLogPath(
125
+ (api.config.cron as Record<string, unknown> | undefined)?.store as string | undefined,
126
+ jobId,
127
+ )
128
+ const summaries = await readRecentSummaries(logPath, HISTORY_DEPTH)
129
+ if (summaries.length === 0) return
130
+
131
+ const lines = summaries.map(
132
+ (s, i) =>
133
+ `${i + 1}. (${formatDate(s.ts)}) ${truncateSummary(s.summary, SUMMARY_MAX_CHARS)}`,
134
+ )
135
+ const prependContext = [
136
+ `[For context: summaries of your previous ${summaries.length} ${summaries.length === 1 ? 'output' : 'outputs'} for this task. Use this to avoid repetition where appropriate.]`,
137
+ ...lines,
138
+ ].join('\n')
139
+
140
+ api.logger.info(`cron-history: injected ${summaries.length} summaries for job ${jobId}`)
141
+
142
+ return {prependContext}
143
+ } catch (err) {
144
+ const msg = err instanceof Error ? err.message : String(err)
145
+ api.logger.warn(`cron-history: failed to read history for job ${jobId} — ${msg}`)
146
+ }
147
+ },
148
+ )
149
+
150
+ api.logger.info('cron-history: registered before_agent_start hook')
151
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Cron history injection toggle RPC: writes agents.defaults.cronHistoryInjection
3
+ * to openclaw.json without triggering a gateway restart.
4
+ *
5
+ * When enabled, the before_agent_start hook in cron-history.ts injects past run
6
+ * summaries into every cron job's prompt to prevent repeated outputs.
7
+ *
8
+ * Methods:
9
+ * - clawly.config.setCronHistory({ enabled }) → { changed, enabled }
10
+ */
11
+
12
+ import type {PluginApi} from '../types'
13
+ import type {OpenClawConfig} from '../types/openclaw'
14
+
15
+ export function registerConfigCronHistory(api: PluginApi) {
16
+ api.registerGatewayMethod('clawly.config.setCronHistory', async ({params, respond}) => {
17
+ const enabled = params.enabled === true
18
+
19
+ let config: OpenClawConfig
20
+ try {
21
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
22
+ } catch (err) {
23
+ const msg = err instanceof Error ? err.message : String(err)
24
+ respond(true, {changed: false, enabled, error: `Load failed: ${msg}`})
25
+ return
26
+ }
27
+
28
+ const currentDefaults = (config.agents as Record<string, unknown> | undefined)?.defaults as
29
+ | Record<string, unknown>
30
+ | undefined
31
+
32
+ if (currentDefaults?.cronHistoryInjection === enabled) {
33
+ respond(true, {changed: false, enabled})
34
+ return
35
+ }
36
+
37
+ // Shallow-copy nested objects to avoid polluting the loadConfig() cache
38
+ // if writeConfigFile fails below.
39
+ const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
40
+ const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
41
+ defaults.cronHistoryInjection = enabled
42
+ agents.defaults = defaults
43
+ config.agents = agents
44
+
45
+ try {
46
+ await api.runtime.config.writeConfigFile(config)
47
+ api.logger.info(`config-cron-history: set cronHistoryInjection to ${enabled}`)
48
+ respond(true, {changed: true, enabled})
49
+ } catch (err) {
50
+ const msg = err instanceof Error ? err.message : String(err)
51
+ api.logger.error(`config-cron-history: write failed — ${msg}`)
52
+ respond(true, {changed: false, enabled, error: `Write failed: ${msg}`})
53
+ }
54
+ })
55
+
56
+ api.logger.info('config-cron-history: registered clawly.config.setCronHistory')
57
+ }
package/gateway/index.ts CHANGED
@@ -4,6 +4,7 @@ import {registerCalendarNative} from './calendar-native'
4
4
  import {registerAnalytics} from './analytics'
5
5
  import {registerAudit} from './audit'
6
6
  import {registerClawhub2gateway} from './clawhub2gateway'
7
+ import {registerConfigCronHistory} from './config-cron-history'
7
8
  import {registerConfigModel} from './config-model'
8
9
  import {registerConfigRepair} from './config-repair'
9
10
  import {registerConfigTimezone} from './config-timezone'
@@ -63,6 +64,7 @@ export function registerGateway(api: PluginApi) {
63
64
  registerCronTelemetry(api)
64
65
  registerMessageLog(api)
65
66
  registerAnalytics(api)
67
+ registerConfigCronHistory(api)
66
68
  registerConfigModel(api)
67
69
  registerConfigRepair(api)
68
70
  registerConfigTimezone(api)
package/index.ts CHANGED
@@ -46,6 +46,7 @@ import {registerAutoUpdate} from './internal/hooks/auto-update'
46
46
  import {registerCalendar} from './calendar'
47
47
  import {registerCommands} from './command'
48
48
  import {setupConfig} from './config-setup'
49
+ import {registerCronHistory} from './cron-history'
49
50
  import {registerCronHook} from './cron-hook'
50
51
  import {registerEmail} from './email'
51
52
  import {registerGateway} from './gateway'
@@ -55,6 +56,7 @@ import {
55
56
  registerOutboundHttpRoute,
56
57
  registerOutboundMethods,
57
58
  } from './http/file/outbound'
59
+ import {registerMediaUnderstanding} from './media-understanding'
58
60
  import {registerSkillCommandRestore} from './skill-command-restore'
59
61
  import {registerTools} from './tools'
60
62
  import type {PluginApi} from './types'
@@ -79,10 +81,12 @@ export default {
79
81
  registerCommands(api)
80
82
  registerTools(api)
81
83
  registerCronHook(api)
84
+ registerCronHistory(api)
82
85
  setupConfig(api)
83
86
  registerGateway(api)
84
87
  registerAutoPair(api)
85
88
  registerAutoUpdate(api)
89
+ registerMediaUnderstanding(api)
86
90
 
87
91
  // Email & calendar (optional — requires API base URL + token)
88
92
  const gw = getGatewayConfig(api)
@@ -0,0 +1,97 @@
1
+ import {PROVIDER_NAME} from './model-gateway-setup'
2
+ import {resolveGatewayCredentials, type GatewayCredentials} from './resolve-gateway-credentials'
3
+ import type {PluginApi} from './types'
4
+
5
+ async function chatCompletion(
6
+ creds: GatewayCredentials,
7
+ content: Array<Record<string, unknown>>,
8
+ opts: {model: string; timeoutMs: number; maxTokens: number},
9
+ ): Promise<{text: string; model?: string}> {
10
+ const controller = new AbortController()
11
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs)
12
+
13
+ try {
14
+ const res = await fetch(`${creds.baseUrl}/chat/completions`, {
15
+ method: 'POST',
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ Authorization: `Bearer ${creds.token}`,
19
+ },
20
+ body: JSON.stringify({
21
+ model: opts.model,
22
+ messages: [{role: 'user', content}],
23
+ max_tokens: opts.maxTokens,
24
+ }),
25
+ signal: controller.signal,
26
+ })
27
+
28
+ if (!res.ok) {
29
+ const errText = await res.text().catch(() => '')
30
+ throw new Error(`Model gateway returned ${res.status}: ${errText}`)
31
+ }
32
+
33
+ const data = (await res.json()) as {
34
+ choices?: {message?: {content?: string}}[]
35
+ model?: string
36
+ }
37
+ const text = data.choices?.[0]?.message?.content ?? ''
38
+ return {text, model: data.model ?? opts.model}
39
+ } finally {
40
+ clearTimeout(timeout)
41
+ }
42
+ }
43
+
44
+ function imageToContentPart(buffer: Buffer, mime?: string): Record<string, unknown> {
45
+ const base64 = buffer.toString('base64')
46
+ return {type: 'image_url', image_url: {url: `data:${mime || 'image/jpeg'};base64,${base64}`}}
47
+ }
48
+
49
+ async function describeImageViaGateway(
50
+ creds: GatewayCredentials,
51
+ params: Record<string, unknown>,
52
+ ): Promise<{text: string; model?: string}> {
53
+ const content = [
54
+ {type: 'text', text: (params.prompt as string) || 'Describe the image.'},
55
+ imageToContentPart(params.buffer as Buffer, params.mime as string | undefined),
56
+ ]
57
+ return chatCompletion(creds, content, {
58
+ model: params.model as string,
59
+ timeoutMs: (params.timeoutMs as number) || 30_000,
60
+ maxTokens: (params.maxTokens as number) || 512,
61
+ })
62
+ }
63
+
64
+ async function describeImagesViaGateway(
65
+ creds: GatewayCredentials,
66
+ params: Record<string, unknown>,
67
+ ): Promise<{text: string; model?: string}> {
68
+ const images = params.images as Array<{buffer: Buffer; fileName?: string; mime?: string}>
69
+ const content: Array<Record<string, unknown>> = [
70
+ {type: 'text', text: (params.prompt as string) || 'Describe the images.'},
71
+ ...images.map((img) => imageToContentPart(img.buffer, img.mime)),
72
+ ]
73
+ return chatCompletion(creds, content, {
74
+ model: params.model as string,
75
+ timeoutMs: (params.timeoutMs as number) || 30_000,
76
+ maxTokens: (params.maxTokens as number) || 512,
77
+ })
78
+ }
79
+
80
+ export function registerMediaUnderstanding(api: PluginApi): void {
81
+ if (!api.registerMediaUnderstandingProvider) return
82
+
83
+ const creds = resolveGatewayCredentials(api)
84
+ if (!creds) {
85
+ api.logger.warn('media-understanding: model gateway credentials not available, skipping')
86
+ return
87
+ }
88
+
89
+ api.registerMediaUnderstandingProvider({
90
+ id: PROVIDER_NAME,
91
+ capabilities: ['image'],
92
+ describeImage: (...args: unknown[]) =>
93
+ describeImageViaGateway(creds, args[0] as Record<string, unknown>),
94
+ describeImages: (...args: unknown[]) =>
95
+ describeImagesViaGateway(creds, args[0] as Record<string, unknown>),
96
+ })
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.32.0",
3
+ "version": "1.34.0-beta.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -30,10 +30,12 @@
30
30
  "auto-pair.ts",
31
31
  "calendar.ts",
32
32
  "config-setup.ts",
33
+ "cron-history.ts",
33
34
  "cron-hook.ts",
34
35
  "email.ts",
35
36
  "gateway-fetch.ts",
36
37
  "http",
38
+ "media-understanding.ts",
37
39
  "model-gateway-setup.ts",
38
40
  "resolve-gateway-credentials.ts",
39
41
  "skill-command-restore.ts",
package/types.ts CHANGED
@@ -256,6 +256,14 @@ export type PluginApi = {
256
256
  stop?: (...args: unknown[]) => void | Promise<void>
257
257
  }) => void
258
258
  registerProvider: (provider: Record<string, unknown>) => void
259
+ registerMediaUnderstandingProvider?: (provider: {
260
+ id: string
261
+ capabilities?: Array<'image' | 'audio' | 'video'>
262
+ describeImage?: (...args: unknown[]) => Promise<unknown>
263
+ describeImages?: (...args: unknown[]) => Promise<unknown>
264
+ transcribeAudio?: (...args: unknown[]) => Promise<unknown>
265
+ describeVideo?: (...args: unknown[]) => Promise<unknown>
266
+ }) => void
259
267
  registerContextEngine: (id: string, factory: (...args: unknown[]) => unknown) => void
260
268
  resolvePath: (input: string) => string
261
269
  on: <K extends PluginHookName>(
@@ -1,55 +0,0 @@
1
- # memory-browser
2
-
3
- OpenClaw plugin that exposes the **memory directory** (all `.md` files) as **Gateway WebSocket RPC methods**.
4
-
5
- ## What you get
6
-
7
- Gateway methods (call via WS RPC):
8
-
9
- - **`memory-browser.list`** — List all `.md` files in the memory directory (recursive). Returns `{ files: string[] }` (relative paths).
10
- - **`memory-browser.get`** — Return the content of a single `.md` file. Params: `{ path: string }` (relative path, e.g. `"notes/foo.md"`). Returns `{ path: string, content: string }`.
11
-
12
- ## Memory directory
13
-
14
- The memory directory is resolved in this order:
15
-
16
- 1. Plugin config `memoryDir` (absolute path)
17
- 2. `<OPENCLAW_STATE_DIR>/memory` (or `<CLAWDBOT_STATE_DIR>/memory`)
18
- 3. `<process.cwd()>/memory`
19
-
20
- ## Install on a gateway host
21
-
22
- 1. Put this plugin on the gateway host and make it discoverable, e.g.:
23
- - Workspace: `<workspace>/.openclaw/extensions/memory-browser/` with `index.ts` + `openclaw.plugin.json`
24
- - Or set `plugins.load.paths` to the plugin folder
25
-
26
- 2. Enable the plugin in `openclaw.json`:
27
-
28
- ```json5
29
- {
30
- "plugins": {
31
- "enabled": true,
32
- "entries": {
33
- "memory-browser": { "enabled": true, "config": {} }
34
- }
35
- }
36
- }
37
- ```
38
-
39
- Optional config: `config.memoryDir` to override the memory directory path.
40
-
41
- ## Call examples (RPC params)
42
-
43
- List all `.md` files:
44
-
45
- ```json
46
- { "method": "memory-browser.list", "params": {} }
47
- ```
48
-
49
- Get one file:
50
-
51
- ```json
52
- { "method": "memory-browser.get", "params": { "path": "notes/meeting-2024.md" } }
53
- ```
54
-
55
- `path` must be a relative path to a `.md` file inside the memory directory; directory traversal (`..`) is rejected.
@@ -1,190 +0,0 @@
1
- # 插件版本管理
2
-
3
- ## 产品需求
4
-
5
- ### 背景
6
-
7
- OpenClaw 插件以 npm 包形式安装在 gateway 的 `extensions/` 目录下。用户需要一种方式来查询已安装插件的版本、检测是否有更新、并执行更新操作——无需手动 SSH 到服务器执行 CLI 命令。
8
-
9
- ### 目标
10
-
11
- - 通过 Gateway RPC 远程查询任意插件的已安装版本和 npm 最新版本
12
- - 支持多种更新策略(常规更新、全新安装、强制重装)
13
- - 更新后可选自动重启 gateway,使新版本立即生效
14
-
15
- ### 功能需求
16
-
17
- 1. **版本查询** — 给定 `pluginId` 和 `npmPkgName`,返回已安装版本、npm 最新版本、所有可用版本列表,以及是否有更新。
18
- 2. **插件更新** — 给定 `pluginId`、`npmPkgName` 和更新策略,执行安装或更新操作。支持指定目标版本和更新后自动重启。
19
- 3. **强制重装** — `force` 策略会备份插件配置、删除扩展目录、全新安装、再恢复配置,解决损坏安装或缓存问题。
20
-
21
- ### 约束
22
-
23
- - 仅通过 Gateway WebSocket RPC 调用,不暴露 HTTP 端点。
24
- - 版本比较仅支持标准 semver,预发布版本(如 `1.0.0-beta.1`)不会被标记为可用更新。
25
- - npm registry 查询结果缓存 5 分钟(LRU,最多 5 个包),避免频繁请求。
26
-
27
- ---
28
-
29
- ## 技术设计
30
-
31
- ### 概览
32
-
33
- 插件版本管理通过 `gateway/plugins.ts` 注册两个 Gateway RPC 方法,分别负责版本查询和更新执行。
34
-
35
- ### 数据流
36
-
37
- ```
38
- Mobile / Agent
39
-
40
- ├─ clawly.plugins.version({ pluginId, npmPkgName })
41
- │ ├─ 读取 extensions/<pluginId>/package.json → 已安装版本
42
- │ ├─ npm view <npmPkgName> --json → 最新版本 + 所有版本
43
- │ └─ isUpdateAvailable(current, latest) → 是否有更新
44
-
45
- └─ clawly.plugins.update({ pluginId, npmPkgName, strategy, targetVersion?, restart? })
46
- ├─ strategy=install → openclaw plugins install <pkg>
47
- ├─ strategy=update → openclaw plugins update <pluginId>
48
- └─ strategy=force → 备份配置 → 删除目录 → 安装 → 恢复配置
49
- └─ restart=true → openclaw gateway restart
50
- ```
51
-
52
- ### Gateway RPC 方法
53
-
54
- | 方法 | 参数 | 返回 |
55
- |---|---|---|
56
- | `clawly.plugins.version` | `{ pluginId, npmPkgName }` | `VersionResult` |
57
- | `clawly.plugins.update` | `{ pluginId, npmPkgName, strategy, targetVersion?, restart? }` | `UpdateResult` |
58
-
59
- ### 关键类型
60
-
61
- ```typescript
62
- // clawly.plugins.version 返回
63
- interface VersionResult {
64
- pluginVersion: string | null // 已安装版本(来自 package.json)
65
- npmPackageVersion: string | null // 同上(兼容字段)
66
- latestNpmVersion: string | null // npm registry 最新稳定版
67
- allNpmVersions: string[] // npm registry 所有已发布版本
68
- updateAvailable: boolean // 是否有可用更新
69
- error?: string // 查询错误(如 npm 不可达)
70
- }
71
-
72
- // clawly.plugins.update 返回
73
- interface UpdateResult {
74
- ok: boolean // 操作是否成功
75
- strategy: string // 实际使用的策略
76
- output?: string // CLI 输出
77
- restarted?: boolean // 是否已重启 gateway
78
- error?: string // 失败时的错误信息
79
- }
80
- ```
81
-
82
- ### 更新策略
83
-
84
- | 策略 | 行为 | 适用场景 |
85
- |---|---|---|
86
- | `install` | `openclaw plugins install <pkg>[@version]` | 首次安装或指定版本安装 |
87
- | `update` | `openclaw plugins update <pluginId>` | 常规更新到最新版 |
88
- | `force` | 备份配置 → 删除扩展目录 → 全新安装 → 恢复配置 | 安装损坏、缓存问题、降级 |
89
-
90
- ### `force` 策略详细流程
91
-
92
- 1. 读取 `openclaw.json` 中 `plugins.entries.<pluginId>` 的用户配置并保存
93
- 2. 从 `openclaw.json` 删除该插件条目(确保干净安装)
94
- 3. 删除 `extensions/<pluginId>/` 目录
95
- 4. 执行 `openclaw plugins install <pkg>[@version]`
96
- 5. 将保存的用户配置合并回 `openclaw.json`
97
-
98
- ### 版本检测逻辑
99
-
100
- 已安装版本从 `<stateDir>/extensions/<pluginId>/package.json` 读取。npm 最新版本通过 `npm view <pkg> --json` 获取。版本比较使用内置 `isUpdateAvailable(current, latest)`:
101
-
102
- - 仅比较标准 semver(`major.minor.patch`)
103
- - `latest` 为预发布版本时返回 `false`(不推荐更新到预发布版)
104
- - `latest > current` 时返回 `true`
105
-
106
- ### 缓存
107
-
108
- npm registry 查询结果通过 `LruCache` 缓存:
109
-
110
- - **容量**: 5 个包
111
- - **TTL**: 5 分钟
112
- - 更新操作成功后自动失效对应包的缓存
113
-
114
- ### 状态目录解析
115
-
116
- `stateDir` 按以下优先级解析:
117
-
118
- 1. `api.runtime.state.resolveStateDir(process.env)`(插件 API 提供)
119
- 2. `OPENCLAW_STATE_DIR` 环境变量
120
- 3. 空字符串(回退,版本查询将返回 `null`)
121
-
122
- ### 调用示例
123
-
124
- 查询版本:
125
-
126
- ```json
127
- {
128
- "method": "clawly.plugins.version",
129
- "params": {
130
- "pluginId": "clawly-plugins",
131
- "npmPkgName": "@AISomething/clawly-plugins"
132
- }
133
- }
134
- ```
135
-
136
- 常规更新:
137
-
138
- ```json
139
- {
140
- "method": "clawly.plugins.update",
141
- "params": {
142
- "pluginId": "clawly-plugins",
143
- "npmPkgName": "@AISomething/clawly-plugins",
144
- "strategy": "update"
145
- }
146
- }
147
- ```
148
-
149
- 强制重装并重启 gateway:
150
-
151
- ```json
152
- {
153
- "method": "clawly.plugins.update",
154
- "params": {
155
- "pluginId": "clawly-plugins",
156
- "npmPkgName": "@AISomething/clawly-plugins",
157
- "strategy": "force",
158
- "restart": true
159
- }
160
- }
161
- ```
162
-
163
- 安装指定版本:
164
-
165
- ```json
166
- {
167
- "method": "clawly.plugins.update",
168
- "params": {
169
- "pluginId": "clawly-plugins",
170
- "npmPkgName": "@AISomething/clawly-plugins",
171
- "strategy": "install",
172
- "targetVersion": "1.2.3"
173
- }
174
- }
175
- ```
176
-
177
- ### 错误处理
178
-
179
- - 参数缺失返回 `{ code: "invalid_params" }` 错误。
180
- - 无效 strategy 返回 `{ code: "invalid_params" }` 错误。
181
- - npm 查询失败时,版本查询仍返回成功,`error` 字段包含错误信息。
182
- - 更新操作失败时返回 `{ ok: false, error: "..." }`。
183
- - `force` 策略在恢复配置失败时仅打印警告日志,不影响整体操作结果。
184
-
185
- ### 不在范围内
186
-
187
- - 批量更新多个插件。
188
- - 插件安装/卸载(使用 `clawhub2gateway` 或 CLI)。
189
- - 自动定时检查更新。
190
- - 版本回滚历史记录。