@2en/clawly-plugins 1.3.1 → 1.4.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.
@@ -0,0 +1,405 @@
1
+ /**
2
+ * ClawHub CLI RPC bridge — expose ClawHub CLI commands as Gateway RPC methods.
3
+ *
4
+ * Gateway methods:
5
+ * - clawhub2gateway.search
6
+ * - clawhub2gateway.install
7
+ * - clawhub2gateway.update
8
+ * - clawhub2gateway.list
9
+ * - clawhub2gateway.explore
10
+ * - clawhub2gateway.inspect
11
+ * - clawhub2gateway.star
12
+ * - clawhub2gateway.unstar
13
+ *
14
+ * Notes:
15
+ * - This plugin shells out to the `clawhub` CLI. Install it on the gateway host:
16
+ * `npm i -g clawhub` (or ensure it is in PATH).
17
+ */
18
+
19
+ import fs from 'node:fs/promises'
20
+ import path from 'node:path'
21
+
22
+ import type {PluginApi} from './index'
23
+
24
+ type JsonSchema = Record<string, unknown>
25
+
26
+ function isRecord(v: unknown): v is Record<string, unknown> {
27
+ return Boolean(v && typeof v === 'object' && !Array.isArray(v))
28
+ }
29
+
30
+ function readBool(params: Record<string, unknown>, key: string): boolean | undefined {
31
+ return typeof params[key] === 'boolean' ? (params[key] as boolean) : undefined
32
+ }
33
+
34
+ function readString(params: Record<string, unknown>, key: string): string | undefined {
35
+ const v = params[key]
36
+ if (typeof v !== 'string') return undefined
37
+ const t = v.trim()
38
+ return t ? t : undefined
39
+ }
40
+
41
+ function readNumber(params: Record<string, unknown>, key: string): number | undefined {
42
+ const v = params[key]
43
+ if (typeof v === 'number' && Number.isFinite(v)) return v
44
+ if (typeof v === 'string' && v.trim()) {
45
+ const n = Number(v.trim())
46
+ if (Number.isFinite(n)) return n
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ function coercePluginConfig(api: PluginApi): Record<string, unknown> {
52
+ return isRecord(api.pluginConfig) ? api.pluginConfig : {}
53
+ }
54
+
55
+ function configString(
56
+ cfg: Record<string, unknown>,
57
+ key: string,
58
+ fallback?: string,
59
+ ): string | undefined {
60
+ const v = cfg[key]
61
+ if (typeof v === 'string' && v.trim()) return v.trim()
62
+ return fallback
63
+ }
64
+
65
+ function configBool(cfg: Record<string, unknown>, key: string, fallback: boolean): boolean {
66
+ const v = cfg[key]
67
+ if (typeof v === 'boolean') return v
68
+ return fallback
69
+ }
70
+
71
+ function configNumber(cfg: Record<string, unknown>, key: string, fallback: number): number {
72
+ const v = cfg[key]
73
+ if (typeof v === 'number' && Number.isFinite(v)) return v
74
+ return fallback
75
+ }
76
+
77
+ async function ensureDir(dirPath: string): Promise<void> {
78
+ await fs.mkdir(dirPath, {recursive: true})
79
+ }
80
+
81
+ function buildGlobalCliArgs(
82
+ params: Record<string, unknown>,
83
+ defaults: {dir: string; noInput: boolean},
84
+ ) {
85
+ const out: string[] = []
86
+
87
+ const workdir = readString(params, 'workdir')
88
+ const dir = readString(params, 'dir') ?? defaults.dir
89
+ const site = readString(params, 'site')
90
+ const registry = readString(params, 'registry')
91
+ const noInput = readBool(params, 'noInput') ?? defaults.noInput
92
+
93
+ if (workdir) out.push('--workdir', workdir)
94
+ if (dir) out.push('--dir', dir)
95
+ if (site) out.push('--site', site)
96
+ if (registry) out.push('--registry', registry)
97
+ if (noInput) out.push('--no-input')
98
+
99
+ return out
100
+ }
101
+
102
+ async function runClawhubCommand(opts: {
103
+ api: PluginApi
104
+ argv: string[]
105
+ cwd?: string
106
+ timeoutMs: number
107
+ env?: Record<string, string | undefined>
108
+ }) {
109
+ const runner = opts.api.runtime.system?.runCommandWithTimeout
110
+ if (!runner) {
111
+ throw new Error('Plugin runtime missing system.runCommandWithTimeout')
112
+ }
113
+ const result = await runner(opts.argv, {timeoutMs: opts.timeoutMs, cwd: opts.cwd, env: opts.env})
114
+ return result
115
+ }
116
+
117
+ function joinOutput(stdout: string, stderr: string): string {
118
+ const s1 = (stdout ?? '').trimEnd()
119
+ const s2 = (stderr ?? '').trimEnd()
120
+ if (s1 && s2) return `${s1}\n\n${s2}`
121
+ return s1 || s2 || ''
122
+ }
123
+
124
+ function baseToolParamsSchema(extra?: JsonSchema): JsonSchema {
125
+ return {
126
+ type: 'object',
127
+ additionalProperties: false,
128
+ properties: {
129
+ workdir: {type: 'string', description: 'Working directory (ClawHub --workdir)'},
130
+ dir: {type: 'string', description: 'Skills directory relative to workdir (ClawHub --dir)'},
131
+ site: {type: 'string', description: 'ClawHub site URL (ClawHub --site)'},
132
+ registry: {type: 'string', description: 'ClawHub registry API URL (ClawHub --registry)'},
133
+ noInput: {type: 'boolean', description: 'Disable prompts (ClawHub --no-input)'},
134
+ timeoutMs: {type: 'number', description: 'Command timeout in milliseconds'},
135
+ ...(extra ?? {}),
136
+ },
137
+ }
138
+ }
139
+
140
+ export function registerClawhub2gateway(api: PluginApi) {
141
+ const cfg = coercePluginConfig(api)
142
+ const bin = configString(cfg, 'bin', 'clawhub') ?? 'clawhub'
143
+ const defaultDir = configString(cfg, 'defaultDir', 'skills') ?? 'skills'
144
+ const defaultNoInput = configBool(cfg, 'defaultNoInput', true)
145
+ const defaultTimeoutMs = configNumber(cfg, 'defaultTimeoutMs', 60_000)
146
+ const configPath = configString(cfg, 'configPath')
147
+
148
+ const stateDir =
149
+ api.runtime.state?.resolveStateDir?.(process.env) ??
150
+ process.env.OPENCLAW_STATE_DIR ??
151
+ process.env.CLAWDBOT_STATE_DIR ??
152
+ ''
153
+ const defaultConfigPath = stateDir ? path.join(stateDir, 'clawhub', 'config.json') : ''
154
+
155
+ const resolveEnv = async (): Promise<Record<string, string | undefined>> => {
156
+ const env: Record<string, string | undefined> = {}
157
+ const resolved = configPath ?? defaultConfigPath
158
+ if (resolved) {
159
+ await ensureDir(path.dirname(resolved))
160
+ env.CLAWHUB_CONFIG_PATH = resolved
161
+ }
162
+ return env
163
+ }
164
+
165
+ const registerRpc = (rpc: {
166
+ method: string
167
+ description: string
168
+ parameters: JsonSchema
169
+ buildCli: (
170
+ params: Record<string, unknown>,
171
+ ) => Promise<{argv: string[]; cwd?: string; timeoutMs?: number}>
172
+ }) => {
173
+ api.registerGatewayMethod(rpc.method, async ({params, respond}) => {
174
+ try {
175
+ const timeoutMs = Math.max(
176
+ 1_000,
177
+ Math.floor(readNumber(params, 'timeoutMs') ?? defaultTimeoutMs),
178
+ )
179
+ const built = await rpc.buildCli(params)
180
+ const env = await resolveEnv()
181
+ const res = await runClawhubCommand({
182
+ api,
183
+ argv: built.argv,
184
+ cwd: built.cwd,
185
+ timeoutMs: built.timeoutMs ?? timeoutMs,
186
+ env,
187
+ })
188
+ const output = joinOutput(res.stdout, res.stderr)
189
+ if (res.code && res.code !== 0) {
190
+ respond(false, undefined, {
191
+ code: 'command_failed',
192
+ message: output || `clawhub exited with code ${res.code}`,
193
+ })
194
+ return
195
+ }
196
+ respond(true, {
197
+ ok: true,
198
+ output,
199
+ stdout: res.stdout,
200
+ stderr: res.stderr,
201
+ code: res.code,
202
+ signal: res.signal,
203
+ killed: res.killed,
204
+ argv: built.argv,
205
+ cwd: built.cwd,
206
+ })
207
+ } catch (err) {
208
+ respond(false, undefined, {
209
+ code: 'error',
210
+ message: err instanceof Error ? err.message : String(err),
211
+ })
212
+ }
213
+ })
214
+ }
215
+
216
+ // -----------------------------
217
+ // Search
218
+ // -----------------------------
219
+
220
+ registerRpc({
221
+ method: 'clawhub2gateway.search',
222
+ description: 'ClawHub CLI: search skills.',
223
+ parameters: baseToolParamsSchema({
224
+ query: {type: 'string', description: 'Search query'},
225
+ limit: {type: 'number', description: 'Max results (clawhub search --limit)'},
226
+ }),
227
+ async buildCli(params) {
228
+ const query = readString(params, 'query')
229
+ if (!query) throw new Error('query required')
230
+ const limit = readNumber(params, 'limit')
231
+ const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
232
+ const argv = [bin, 'search', query, ...globalArgs]
233
+ if (limit !== undefined) argv.push('--limit', String(Math.max(1, Math.floor(limit))))
234
+ return {argv}
235
+ },
236
+ })
237
+
238
+ registerRpc({
239
+ method: 'clawhub2gateway.explore',
240
+ description: 'ClawHub CLI: explore skills.!',
241
+ parameters: baseToolParamsSchema({
242
+ limit: {type: 'number', description: 'Max results (clawhub explore --limit, if supported)'},
243
+ sort: {
244
+ type: 'string',
245
+ enum: ['newest', 'downloads', 'rating', 'installs', 'installsAllTime', 'trending'],
246
+ description:
247
+ 'Sort order (clawhub explore --sort). One of newest, downloads, rating, installs, installsAllTime, trending.',
248
+ },
249
+ json: {type: 'boolean', description: 'Output JSON'},
250
+ }),
251
+ async buildCli(params) {
252
+ const limit = readNumber(params, 'limit')
253
+ const sort = readString(params, 'sort')
254
+ const json = readBool(params, 'json') ?? false
255
+ const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
256
+ const argv = [bin, 'explore', ...globalArgs]
257
+ if (limit !== undefined) argv.push('--limit', String(Math.max(1, Math.floor(limit))))
258
+ if (sort) argv.push('--sort', sort)
259
+ if (json) argv.push('--json')
260
+ return {argv}
261
+ },
262
+ })
263
+
264
+ // -----------------------------
265
+ // Install / Update / List
266
+ // -----------------------------
267
+
268
+ registerRpc({
269
+ method: 'clawhub2gateway.install',
270
+ description: 'ClawHub CLI: install a skill into the workspace skills dir.',
271
+ parameters: baseToolParamsSchema({
272
+ slug: {type: 'string', description: 'Skill slug to install'},
273
+ version: {type: 'string', description: 'Specific version to install'},
274
+ force: {
275
+ type: 'boolean',
276
+ description: 'Overwrite if folder exists (clawhub install --force)',
277
+ },
278
+ }),
279
+ async buildCli(params) {
280
+ const slug = readString(params, 'slug')
281
+ if (!slug) throw new Error('slug required')
282
+ const version = readString(params, 'version')
283
+ const force = readBool(params, 'force') ?? false
284
+ const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
285
+ const argv = [bin, 'install', slug, ...globalArgs]
286
+ if (version) argv.push('--version', version)
287
+ if (force) argv.push('--force')
288
+ return {argv}
289
+ },
290
+ })
291
+
292
+ registerRpc({
293
+ method: 'clawhub2gateway.update',
294
+ description: 'ClawHub CLI: update one skill or all installed skills.',
295
+ parameters: baseToolParamsSchema({
296
+ slug: {type: 'string', description: 'Skill slug to update (omit when all=true)'},
297
+ all: {type: 'boolean', description: 'Update all skills (clawhub update --all)'},
298
+ version: {type: 'string', description: 'Update to a specific version (single slug only)'},
299
+ force: {
300
+ type: 'boolean',
301
+ description: 'Overwrite when local files do not match any published version',
302
+ },
303
+ }),
304
+ async buildCli(params) {
305
+ const all = readBool(params, 'all') ?? false
306
+ const slug = readString(params, 'slug')
307
+ const version = readString(params, 'version')
308
+ const force = readBool(params, 'force') ?? false
309
+ const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
310
+
311
+ const argv = [bin, 'update', ...globalArgs]
312
+ if (all) {
313
+ argv.push('--all')
314
+ } else {
315
+ if (!slug) throw new Error('slug required when all=false')
316
+ argv.push(slug)
317
+ if (version) argv.push('--version', version)
318
+ }
319
+ if (force) argv.push('--force')
320
+ return {argv}
321
+ },
322
+ })
323
+
324
+ registerRpc({
325
+ method: 'clawhub2gateway.list',
326
+ description: 'ClawHub CLI: list installed skills from .clawhub/lock.json.',
327
+ parameters: baseToolParamsSchema(),
328
+ async buildCli(params) {
329
+ const argv = [
330
+ bin,
331
+ 'list',
332
+ ...buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput}),
333
+ ]
334
+ return {argv}
335
+ },
336
+ })
337
+
338
+ // -----------------------------
339
+ // Inspect / Star
340
+ // -----------------------------
341
+
342
+ registerRpc({
343
+ method: 'clawhub2gateway.inspect',
344
+ description: 'ClawHub CLI: inspect a skill without installing.',
345
+ parameters: baseToolParamsSchema({
346
+ slug: {type: 'string', description: 'Skill slug'},
347
+ }),
348
+ async buildCli(params) {
349
+ const slug = readString(params, 'slug')
350
+ if (!slug) throw new Error('slug required')
351
+ const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
352
+
353
+ const argv = [bin, 'inspect', slug, ...globalArgs]
354
+ return {argv}
355
+ },
356
+ })
357
+
358
+ registerRpc({
359
+ method: 'clawhub2gateway.star',
360
+ description: 'ClawHub CLI: star a skill.',
361
+ parameters: baseToolParamsSchema({
362
+ slug: {type: 'string', description: 'Skill slug'},
363
+ }),
364
+ async buildCli(params) {
365
+ const slug = readString(params, 'slug')
366
+ if (!slug) throw new Error('slug required')
367
+ const argv = [
368
+ bin,
369
+ 'star',
370
+ slug,
371
+ ...buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput}),
372
+ ]
373
+ return {argv}
374
+ },
375
+ })
376
+
377
+ registerRpc({
378
+ method: 'clawhub2gateway.unstar',
379
+ description: 'ClawHub CLI: unstar a skill.',
380
+ parameters: baseToolParamsSchema({
381
+ slug: {type: 'string', description: 'Skill slug'},
382
+ }),
383
+ async buildCli(params) {
384
+ const slug = readString(params, 'slug')
385
+ if (!slug) throw new Error('slug required')
386
+ const argv = [
387
+ bin,
388
+ 'unstar',
389
+ slug,
390
+ ...buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput}),
391
+ ]
392
+ return {argv}
393
+ },
394
+ })
395
+
396
+ api.logger.info(
397
+ [
398
+ `clawhub2gateway: registered.`,
399
+ `Using clawhub bin: ${bin}`,
400
+ defaultConfigPath
401
+ ? `CLI config: ${configPath ?? defaultConfigPath}`
402
+ : 'CLI config: (not set)',
403
+ ].join(' '),
404
+ )
405
+ }
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OpenClaw plugin: Clawly utility RPC methods (clawly.*).
2
+ * OpenClaw plugin: Clawly utility RPC methods (clawly.*), memory browser, and ClawHub CLI bridge.
3
3
  *
4
4
  * Gateway methods:
5
5
  * - clawly.file.getOutbound — read a persisted outbound file by original-path hash
@@ -8,6 +8,9 @@
8
8
  * - clawly.notification.send — send a push notification directly
9
9
  * - clawly.agent.send — send a message to the agent (+ optional push)
10
10
  * - clawly.agent.echo — echo-wrapped agent message (bypasses LLM)
11
+ * - memory-browser.list — list all .md files in the memory directory
12
+ * - memory-browser.get — return content of a single .md file
13
+ * - clawhub2gateway.* — ClawHub CLI RPC bridge (search/install/update/list/explore/inspect/star/unstar)
11
14
  *
12
15
  * Agent tools:
13
16
  * - clawly_is_user_online — check if user's device is connected
@@ -24,13 +27,27 @@
24
27
  import {registerAgentSend} from './agent-send'
25
28
  import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
26
29
  import {registerClawlyCronChannel} from './channel'
30
+ import {registerClawhub2gateway} from './clawhub2gateway'
27
31
  import {registerCronHook} from './cron-hook'
28
32
  import {registerEchoCommand} from './echo'
33
+ import {registerMemoryBrowser} from './memory'
29
34
  import {registerNotification} from './notification'
30
35
  import {registerOutboundHook, registerOutboundMethods} from './outbound'
31
36
  import {registerPresence} from './presence'
32
37
 
33
38
  type PluginRuntime = {
39
+ system?: {
40
+ runCommandWithTimeout?: (
41
+ argv: string[],
42
+ opts: {timeoutMs: number; cwd?: string; env?: Record<string, string | undefined>},
43
+ ) => Promise<{
44
+ stdout: string
45
+ stderr: string
46
+ code: number | null
47
+ signal: string | null
48
+ killed: boolean
49
+ }>
50
+ }
34
51
  state?: {
35
52
  resolveStateDir?: (env?: NodeJS.ProcessEnv) => string
36
53
  }
@@ -91,6 +108,8 @@ export default {
91
108
  registerSendAppPushTool(api)
92
109
  registerClawlyCronChannel(api)
93
110
  registerCronHook(api)
111
+ registerMemoryBrowser(api)
112
+ registerClawhub2gateway(api)
94
113
  api.logger.info(`Loaded ${api.id} plugin.`)
95
114
  },
96
115
  }
package/memory.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Memory directory browser — expose memory directory .md files as Gateway RPC methods.
3
+ *
4
+ * Gateway methods:
5
+ * - memory-browser.list — list all .md files in the OpenClaw memory directory
6
+ * - memory-browser.get — return the content of a single .md file by path
7
+ */
8
+
9
+ import fs from 'node:fs/promises'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+
13
+ import type {PluginApi} from './index'
14
+
15
+ function isRecord(v: unknown): v is Record<string, unknown> {
16
+ return Boolean(v && typeof v === 'object' && !Array.isArray(v))
17
+ }
18
+
19
+ function configString(
20
+ cfg: Record<string, unknown>,
21
+ key: string,
22
+ fallback?: string,
23
+ ): string | undefined {
24
+ const v = cfg[key]
25
+ if (typeof v === 'string' && v.trim()) return v.trim()
26
+ return fallback
27
+ }
28
+
29
+ function readString(params: Record<string, unknown>, key: string): string | undefined {
30
+ const v = params[key]
31
+ if (typeof v !== 'string') return undefined
32
+ const t = v.trim()
33
+ return t ? t : undefined
34
+ }
35
+
36
+ function coercePluginConfig(api: PluginApi): Record<string, unknown> {
37
+ return isRecord(api.pluginConfig) ? api.pluginConfig : {}
38
+ }
39
+
40
+ /** Resolve the memory directory path. Memory lives under workspace (agents.defaults.workspace). */
41
+ function resolveMemoryDir(api: PluginApi, profile?: string): string {
42
+ const cfg = coercePluginConfig(api)
43
+ const configPath = configString(cfg, 'memoryDir')
44
+ if (configPath) return configPath
45
+
46
+ const baseDir =
47
+ process.env.OPENCLAW_WORKSPACE ?? path.join(os.homedir(), '.openclaw', 'workspace')
48
+ // Profile-aware: "main" or empty → default workspace, otherwise workspace-<profile>
49
+ if (profile && profile !== 'main') {
50
+ const parentDir = path.dirname(baseDir)
51
+ const baseName = path.basename(baseDir)
52
+ return path.join(parentDir, `${baseName}-${profile}`, 'memory')
53
+ }
54
+ return path.join(baseDir, 'memory')
55
+ }
56
+
57
+ /** Check that a relative path is safe (no directory traversal). */
58
+ function isSafeRelativePath(relativePath: string): boolean {
59
+ if (!relativePath || relativePath.startsWith('/') || relativePath.includes('\\')) {
60
+ return false
61
+ }
62
+ const normalized = path.normalize(relativePath)
63
+ if (normalized.startsWith('..') || normalized.includes('..')) {
64
+ return false
65
+ }
66
+ return true
67
+ }
68
+
69
+ /** Recursively collect all .md file paths under dir, relative to baseDir. */
70
+ async function collectMdFiles(dir: string, baseDir: string, acc: string[] = []): Promise<string[]> {
71
+ let entries: fs.Dirent[]
72
+ try {
73
+ entries = await fs.readdir(dir, {withFileTypes: true})
74
+ } catch (err) {
75
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return acc
76
+ throw err
77
+ }
78
+
79
+ for (const e of entries) {
80
+ const full = path.join(dir, e.name)
81
+ const rel = path.relative(baseDir, full)
82
+
83
+ if (e.isDirectory()) {
84
+ await collectMdFiles(full, baseDir, acc)
85
+ } else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) {
86
+ acc.push(rel)
87
+ }
88
+ }
89
+ return acc
90
+ }
91
+
92
+ export function registerMemoryBrowser(api: PluginApi) {
93
+ const memoryDir = resolveMemoryDir(api)
94
+ api.logger.info(`memory-browser: memory directory: ${memoryDir}`)
95
+
96
+ // -----------------------------
97
+ // memory-browser.list
98
+ // -----------------------------
99
+ api.registerGatewayMethod('memory-browser.list', async ({params, respond}) => {
100
+ try {
101
+ const profile = readString(params, 'profile')
102
+ const dir = profile ? resolveMemoryDir(api, profile) : memoryDir
103
+ const files = await collectMdFiles(dir, dir)
104
+ api.logger.info(`memory-browser.list: ${files.length} files found`)
105
+ files.sort()
106
+ respond(true, {files})
107
+ } catch (err) {
108
+ api.logger.error(
109
+ `memory-browser.list failed: ${err instanceof Error ? err.message : String(err)}`,
110
+ )
111
+ respond(false, undefined, {
112
+ code: 'error',
113
+ message: err instanceof Error ? err.message : String(err),
114
+ })
115
+ }
116
+ })
117
+
118
+ // -----------------------------
119
+ // memory-browser.get
120
+ // -----------------------------
121
+ api.registerGatewayMethod('memory-browser.get', async ({params, respond}) => {
122
+ const relativePath = readString(params, 'path')
123
+ const profile = readString(params, 'profile')
124
+ const dir = profile ? resolveMemoryDir(api, profile) : memoryDir
125
+ if (!relativePath) {
126
+ respond(false, undefined, {
127
+ code: 'invalid_params',
128
+ message: 'path (relative path to .md file) is required',
129
+ })
130
+ return
131
+ }
132
+ if (!isSafeRelativePath(relativePath)) {
133
+ respond(false, undefined, {
134
+ code: 'invalid_params',
135
+ message: 'path must be a safe relative path (no directory traversal)',
136
+ })
137
+ return
138
+ }
139
+ if (!relativePath.toLowerCase().endsWith('.md')) {
140
+ respond(false, undefined, {
141
+ code: 'invalid_params',
142
+ message: 'path must point to a .md file',
143
+ })
144
+ return
145
+ }
146
+
147
+ const fullPath = path.join(dir, path.normalize(relativePath))
148
+ const realMemory = await fs.realpath(dir).catch(() => dir)
149
+ const realResolved = await fs.realpath(fullPath).catch(() => null)
150
+
151
+ if (!realResolved) {
152
+ respond(false, undefined, {
153
+ code: 'not_found',
154
+ message: 'file not found',
155
+ })
156
+ return
157
+ }
158
+ const rel = path.relative(realMemory, realResolved)
159
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
160
+ respond(false, undefined, {
161
+ code: 'not_found',
162
+ message: 'file not found or outside memory directory',
163
+ })
164
+ return
165
+ }
166
+
167
+ try {
168
+ const content = await fs.readFile(fullPath, 'utf-8')
169
+ respond(true, {path: relativePath, content})
170
+ } catch (err) {
171
+ const code = (err as NodeJS.ErrnoException).code
172
+ if (code === 'ENOENT') {
173
+ respond(false, undefined, {code: 'not_found', message: 'file not found'})
174
+ return
175
+ }
176
+ api.logger.error(
177
+ `memory-browser.get failed: ${err instanceof Error ? err.message : String(err)}`,
178
+ )
179
+ respond(false, undefined, {
180
+ code: 'error',
181
+ message: err instanceof Error ? err.message : String(err),
182
+ })
183
+ }
184
+ })
185
+
186
+ api.logger.info(`memory-browser: registered (dir: ${memoryDir})`)
187
+ }
@@ -1,12 +1,52 @@
1
1
  {
2
2
  "id": "clawly-plugins",
3
3
  "name": "Clawly Plugins",
4
- "description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, and agent messaging.",
4
+ "description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, agent messaging, memory browser, and ClawHub CLI bridge.",
5
5
  "version": "0.2.0",
6
+ "uiHints": {
7
+ "memoryDir": {
8
+ "label": "Memory directory",
9
+ "help": "Absolute path to the memory directory containing .md files. Defaults to <OPENCLAW_WORKSPACE>/memory.",
10
+ "placeholder": "/data/memory"
11
+ },
12
+ "bin": {
13
+ "label": "clawhub binary",
14
+ "help": "Command to run (must be in PATH on the gateway host).",
15
+ "placeholder": "clawhub"
16
+ },
17
+ "defaultDir": {
18
+ "label": "Default skills dir",
19
+ "help": "Maps to `clawhub --dir` when not provided per-call.",
20
+ "placeholder": "skills"
21
+ },
22
+ "defaultNoInput": {
23
+ "label": "Default no-input",
24
+ "help": "If true, adds `--no-input` unless the tool call overrides it.",
25
+ "advanced": true
26
+ },
27
+ "defaultTimeoutMs": {
28
+ "label": "Default timeout (ms)",
29
+ "help": "Default command timeout for most tool calls.",
30
+ "advanced": true
31
+ },
32
+ "configPath": {
33
+ "label": "CLAWHUB_CONFIG_PATH",
34
+ "help": "Where the ClawHub CLI stores auth/config. Defaults to `<OPENCLAW_STATE_DIR>/clawhub/config.json`.",
35
+ "advanced": true,
36
+ "placeholder": "~/.openclaw/clawhub/config.json"
37
+ }
38
+ },
6
39
  "configSchema": {
7
40
  "type": "object",
8
41
  "additionalProperties": false,
9
- "properties": {},
42
+ "properties": {
43
+ "memoryDir": { "type": "string", "minLength": 1 },
44
+ "bin": { "type": "string", "minLength": 1 },
45
+ "defaultDir": { "type": "string", "minLength": 1 },
46
+ "defaultNoInput": { "type": "boolean" },
47
+ "defaultTimeoutMs": { "type": "number", "minimum": 1000 },
48
+ "configPath": { "type": "string" }
49
+ },
10
50
  "required": []
11
51
  }
12
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -15,7 +15,9 @@
15
15
  "tools",
16
16
  "index.ts",
17
17
  "channel.ts",
18
+ "clawhub2gateway.ts",
18
19
  "cron-hook.ts",
20
+ "memory.ts",
19
21
  "outbound.ts",
20
22
  "echo.ts",
21
23
  "presence.ts",