@2en/clawly-plugins 1.4.1 → 1.6.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/channel.ts +2 -2
- package/{echo.ts → command/clawly_echo.ts} +1 -1
- package/command/index.ts +6 -0
- package/{agent-send.ts → gateway/agent.ts} +1 -1
- package/gateway/clawhub2gateway.md +68 -0
- package/gateway/clawhub2gateway.ts +405 -0
- package/gateway/index.ts +16 -0
- package/gateway/memory-browser.md +55 -0
- package/gateway/memory.ts +187 -0
- package/{notification.ts → gateway/notification.ts} +1 -1
- package/gateway/plugins.ts +243 -0
- package/{presence.ts → gateway/presence.ts} +3 -7
- package/index.ts +22 -12
- package/lib/lruCache.test.ts +85 -0
- package/lib/lruCache.ts +50 -0
- package/lib/semver.test.ts +88 -0
- package/lib/semver.ts +46 -0
- package/lib/stripCliLogs.test.ts +71 -0
- package/lib/stripCliLogs.ts +25 -0
- package/openclaw.plugin.json +40 -1
- package/package.json +4 -6
- package/tools/clawly-is-user-online.ts +1 -1
- package/tools/clawly-send-app-push.ts +1 -1
- package/tools/index.ts +8 -0
- package/tools.ts +0 -2
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,7 @@ import fs from 'node:fs'
|
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
|
|
16
|
-
import type {PluginApi} from '
|
|
16
|
+
import type {PluginApi} from '../index'
|
|
17
17
|
|
|
18
18
|
const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
|
|
19
19
|
const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin version check and update management.
|
|
3
|
+
*
|
|
4
|
+
* Methods:
|
|
5
|
+
* - clawly.plugins.version({ pluginId, npmPkgName }) → VersionResult
|
|
6
|
+
* - clawly.plugins.update({ pluginId, npmPkgName, strategy, targetVersion?, restart? }) → UpdateResult
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import {$} from 'zx'
|
|
12
|
+
|
|
13
|
+
import type {PluginApi} from '../index'
|
|
14
|
+
import {LruCache} from '../lib/lruCache'
|
|
15
|
+
import {isUpdateAvailable} from '../lib/semver'
|
|
16
|
+
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
17
|
+
|
|
18
|
+
$.verbose = false
|
|
19
|
+
|
|
20
|
+
interface NpmViewCache {
|
|
21
|
+
version: string
|
|
22
|
+
versions: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const npmCache = new LruCache<NpmViewCache>({maxSize: 5, ttlMs: 5 * 60 * 1000})
|
|
26
|
+
|
|
27
|
+
function resolveStateDir(api: PluginApi): string {
|
|
28
|
+
return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readExtensionVersion(stateDir: string, pluginId: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
const pkgPath = path.join(stateDir, 'extensions', pluginId, 'package.json')
|
|
34
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
35
|
+
return typeof pkg.version === 'string' ? pkg.version : null
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
|
|
42
|
+
const cached = npmCache.get(npmPkgName)
|
|
43
|
+
if (cached) return cached
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const result = await $`npm view ${npmPkgName} --json`
|
|
47
|
+
const parsed = JSON.parse(stripCliLogs(result.stdout))
|
|
48
|
+
const entry: NpmViewCache = {
|
|
49
|
+
version: typeof parsed.version === 'string' ? parsed.version : '',
|
|
50
|
+
versions: Array.isArray(parsed.versions) ? parsed.versions : [],
|
|
51
|
+
}
|
|
52
|
+
npmCache.set(npmPkgName, entry)
|
|
53
|
+
return entry
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerPlugins(api: PluginApi) {
|
|
60
|
+
// ── clawly.plugins.version ──────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
api.registerGatewayMethod('clawly.plugins.version', async ({params, respond}) => {
|
|
63
|
+
const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
|
|
64
|
+
const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
|
|
65
|
+
|
|
66
|
+
if (!pluginId || !npmPkgName) {
|
|
67
|
+
respond(false, undefined, {
|
|
68
|
+
code: 'invalid_params',
|
|
69
|
+
message: 'pluginId and npmPkgName are required',
|
|
70
|
+
})
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stateDir = resolveStateDir(api)
|
|
75
|
+
api.logger.info(`plugins.version: checking ${pluginId} (${npmPkgName})`)
|
|
76
|
+
let latestNpmVersion: string | null = null
|
|
77
|
+
let allNpmVersions: string[] = []
|
|
78
|
+
let updateAvailable = false
|
|
79
|
+
const errors: string[] = []
|
|
80
|
+
|
|
81
|
+
// Step 1: Read installed version from extensions/<id>/package.json
|
|
82
|
+
const npmPackageVersion = stateDir ? readExtensionVersion(stateDir, pluginId) : null
|
|
83
|
+
api.logger.info(`plugins.version: installed version = ${npmPackageVersion ?? 'not found'}`)
|
|
84
|
+
|
|
85
|
+
// Step 2: Fetch npm registry info
|
|
86
|
+
try {
|
|
87
|
+
const npm = await fetchNpmView(npmPkgName)
|
|
88
|
+
if (npm) {
|
|
89
|
+
latestNpmVersion = npm.version
|
|
90
|
+
allNpmVersions = npm.versions
|
|
91
|
+
api.logger.info(
|
|
92
|
+
`plugins.version: npm registry latest=${latestNpmVersion}, ${allNpmVersions.length} versions available`,
|
|
93
|
+
)
|
|
94
|
+
} else {
|
|
95
|
+
api.logger.warn(`plugins.version: npm view returned null for ${npmPkgName}`)
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
errors.push(`npm view: ${err instanceof Error ? err.message : String(err)}`)
|
|
99
|
+
api.logger.error(`plugins.version: npm view failed — ${errors[errors.length - 1]}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Step 3: Compute update availability
|
|
103
|
+
if (npmPackageVersion && latestNpmVersion) {
|
|
104
|
+
updateAvailable = isUpdateAvailable(npmPackageVersion, latestNpmVersion)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
api.logger.info(
|
|
108
|
+
`plugins.version: result current=${npmPackageVersion} latest=${latestNpmVersion} updateAvailable=${updateAvailable}`,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
respond(true, {
|
|
112
|
+
pluginVersion: npmPackageVersion,
|
|
113
|
+
npmPackageVersion,
|
|
114
|
+
latestNpmVersion,
|
|
115
|
+
allNpmVersions,
|
|
116
|
+
updateAvailable,
|
|
117
|
+
...(errors.length ? {error: errors.join('; ')} : {}),
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ── clawly.plugins.update ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
api.registerGatewayMethod('clawly.plugins.update', async ({params, respond}) => {
|
|
124
|
+
const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
|
|
125
|
+
const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
|
|
126
|
+
const strategy = typeof params.strategy === 'string' ? params.strategy.trim() : ''
|
|
127
|
+
const targetVersion =
|
|
128
|
+
typeof params.targetVersion === 'string' ? params.targetVersion.trim() : undefined
|
|
129
|
+
const restart = params.restart === true
|
|
130
|
+
|
|
131
|
+
if (!pluginId || !npmPkgName || !strategy) {
|
|
132
|
+
respond(false, undefined, {
|
|
133
|
+
code: 'invalid_params',
|
|
134
|
+
message: 'pluginId, npmPkgName, and strategy are required',
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!['force', 'update', 'install'].includes(strategy)) {
|
|
140
|
+
respond(false, undefined, {
|
|
141
|
+
code: 'invalid_params',
|
|
142
|
+
message: `invalid strategy: ${strategy}`,
|
|
143
|
+
})
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const installTarget = targetVersion ? `${npmPkgName}@${targetVersion}` : npmPkgName
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let output = ''
|
|
151
|
+
|
|
152
|
+
if (strategy === 'force') {
|
|
153
|
+
const stateDir = resolveStateDir(api)
|
|
154
|
+
if (!stateDir) throw new Error('cannot resolve openclaw state dir')
|
|
155
|
+
|
|
156
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
157
|
+
|
|
158
|
+
// 1. Save current plugins.entries.<id> config (preserve user settings)
|
|
159
|
+
let savedEntry: Record<string, unknown> = {enabled: true}
|
|
160
|
+
try {
|
|
161
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
162
|
+
const entry = config?.plugins?.entries?.[pluginId]
|
|
163
|
+
if (entry && typeof entry === 'object') {
|
|
164
|
+
savedEntry = entry as Record<string, unknown>
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// config unreadable — use default
|
|
168
|
+
}
|
|
169
|
+
api.logger.info(`plugins: force — saved entry config for ${pluginId}`)
|
|
170
|
+
|
|
171
|
+
// 2. Delete plugins.entries.<id> from config so install starts clean
|
|
172
|
+
try {
|
|
173
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
174
|
+
if (config?.plugins?.entries?.[pluginId]) {
|
|
175
|
+
delete config.plugins.entries[pluginId]
|
|
176
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// ignore
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 3. Remove extension dir to force clean install
|
|
183
|
+
const extensionDir = path.join(stateDir, 'extensions', pluginId)
|
|
184
|
+
if (fs.existsSync(extensionDir)) {
|
|
185
|
+
api.logger.info(`plugins: force — removing ${extensionDir}`)
|
|
186
|
+
await $`rm -rf ${extensionDir}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 4. Install fresh
|
|
190
|
+
api.logger.info(`plugins: force — installing ${installTarget}`)
|
|
191
|
+
const result = await $`openclaw plugins install ${installTarget}`
|
|
192
|
+
output = result.stdout.trim()
|
|
193
|
+
|
|
194
|
+
// 5. Merge saved config back into plugins.entries.<id>
|
|
195
|
+
try {
|
|
196
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
197
|
+
if (!config.plugins) config.plugins = {}
|
|
198
|
+
if (!config.plugins.entries) config.plugins.entries = {}
|
|
199
|
+
config.plugins.entries[pluginId] = {
|
|
200
|
+
...config.plugins.entries[pluginId],
|
|
201
|
+
...savedEntry,
|
|
202
|
+
}
|
|
203
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
204
|
+
api.logger.info(`plugins: force — restored entry config for ${pluginId}`)
|
|
205
|
+
} catch (err) {
|
|
206
|
+
api.logger.warn(
|
|
207
|
+
`plugins: force — failed to restore entry config: ${err instanceof Error ? err.message : String(err)}`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
} else if (strategy === 'update') {
|
|
211
|
+
const result = await $`openclaw plugins update ${pluginId}`
|
|
212
|
+
output = result.stdout.trim()
|
|
213
|
+
} else {
|
|
214
|
+
// install
|
|
215
|
+
const result = await $`openclaw plugins install ${installTarget}`
|
|
216
|
+
output = result.stdout.trim()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Invalidate npm cache for this package
|
|
220
|
+
npmCache.delete(npmPkgName)
|
|
221
|
+
|
|
222
|
+
let restarted = false
|
|
223
|
+
if (restart) {
|
|
224
|
+
try {
|
|
225
|
+
await $`openclaw gateway restart`
|
|
226
|
+
restarted = true
|
|
227
|
+
} catch (err) {
|
|
228
|
+
api.logger.warn(
|
|
229
|
+
`plugins: gateway restart failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
respond(true, {ok: true, strategy, output, restarted})
|
|
235
|
+
} catch (err) {
|
|
236
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
237
|
+
api.logger.error(`plugins: update failed — ${msg}`)
|
|
238
|
+
respond(false, {ok: false, strategy, error: msg})
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
api.logger.info('plugins: registered clawly.plugins.version + clawly.plugins.update')
|
|
243
|
+
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import {$} from 'zx'
|
|
9
9
|
|
|
10
|
-
import type {PluginApi} from '
|
|
10
|
+
import type {PluginApi} from '../index'
|
|
11
|
+
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
11
12
|
|
|
12
13
|
$.verbose = false
|
|
13
14
|
|
|
@@ -25,12 +26,7 @@ interface PresenceEntry {
|
|
|
25
26
|
export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
|
|
26
27
|
try {
|
|
27
28
|
const result = await $`openclaw gateway call system-presence --json`
|
|
28
|
-
const
|
|
29
|
-
let jsonStr = output
|
|
30
|
-
if (!output.startsWith('[')) {
|
|
31
|
-
jsonStr = output.slice(output.indexOf('\n['))
|
|
32
|
-
}
|
|
33
|
-
console.log({jsonStr})
|
|
29
|
+
const jsonStr = stripCliLogs(result.stdout)
|
|
34
30
|
const entries: PresenceEntry[] = JSON.parse(jsonStr)
|
|
35
31
|
const entry = entries.find((e) => e.host === host)
|
|
36
32
|
if (!entry) return false
|
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
|
|
@@ -21,19 +24,29 @@
|
|
|
21
24
|
* - before_tool_call — enforces delivery fields on cron.create
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
|
-
import {registerAgentSend} from './agent-send'
|
|
25
27
|
import {registerCalendar} from './calendar'
|
|
26
|
-
import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
|
|
27
28
|
import {registerClawlyCronChannel} from './channel'
|
|
29
|
+
import {registerCommands} from './command'
|
|
28
30
|
import {registerCronHook} from './cron-hook'
|
|
29
|
-
import {registerEchoCommand} from './echo'
|
|
30
31
|
import {registerEmail} from './email'
|
|
32
|
+
import {registerGateway} from './gateway'
|
|
31
33
|
import {getGatewayConfig} from './gateway-fetch'
|
|
32
|
-
import {registerNotification} from './notification'
|
|
33
34
|
import {registerOutboundHook, registerOutboundMethods} from './outbound'
|
|
34
|
-
import {
|
|
35
|
+
import {registerTools} from './tools'
|
|
35
36
|
|
|
36
37
|
type PluginRuntime = {
|
|
38
|
+
system?: {
|
|
39
|
+
runCommandWithTimeout?: (
|
|
40
|
+
argv: string[],
|
|
41
|
+
opts: {timeoutMs: number; cwd?: string; env?: Record<string, string | undefined>},
|
|
42
|
+
) => Promise<{
|
|
43
|
+
stdout: string
|
|
44
|
+
stderr: string
|
|
45
|
+
code: number | null
|
|
46
|
+
signal: string | null
|
|
47
|
+
killed: boolean
|
|
48
|
+
}>
|
|
49
|
+
}
|
|
37
50
|
state?: {
|
|
38
51
|
resolveStateDir?: (env?: NodeJS.ProcessEnv) => string
|
|
39
52
|
}
|
|
@@ -86,14 +99,11 @@ export default {
|
|
|
86
99
|
register(api: PluginApi) {
|
|
87
100
|
registerOutboundHook(api)
|
|
88
101
|
registerOutboundMethods(api)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
registerNotification(api)
|
|
92
|
-
registerAgentSend(api)
|
|
93
|
-
registerIsUserOnlineTool(api)
|
|
94
|
-
registerSendAppPushTool(api)
|
|
102
|
+
registerCommands(api)
|
|
103
|
+
registerTools(api)
|
|
95
104
|
registerClawlyCronChannel(api)
|
|
96
105
|
registerCronHook(api)
|
|
106
|
+
registerGateway(api)
|
|
97
107
|
|
|
98
108
|
// Email & calendar (optional — requires gatewayBaseUrl + gatewayToken in config)
|
|
99
109
|
const gw = getGatewayConfig(api)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, jest, test} from 'bun:test'
|
|
2
|
+
import {LruCache} from './lruCache'
|
|
3
|
+
|
|
4
|
+
describe('LruCache', () => {
|
|
5
|
+
test('get returns undefined for missing key', () => {
|
|
6
|
+
const cache = new LruCache<string>({maxSize: 3, ttlMs: 60_000})
|
|
7
|
+
expect(cache.get('missing')).toBeUndefined()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('set and get', () => {
|
|
11
|
+
const cache = new LruCache<number>({maxSize: 3, ttlMs: 60_000})
|
|
12
|
+
cache.set('a', 1)
|
|
13
|
+
expect(cache.get('a')).toBe(1)
|
|
14
|
+
expect(cache.size).toBe(1)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('evicts oldest when over maxSize', () => {
|
|
18
|
+
const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
|
|
19
|
+
cache.set('a', 1)
|
|
20
|
+
cache.set('b', 2)
|
|
21
|
+
cache.set('c', 3)
|
|
22
|
+
expect(cache.get('a')).toBeUndefined()
|
|
23
|
+
expect(cache.get('b')).toBe(2)
|
|
24
|
+
expect(cache.get('c')).toBe(3)
|
|
25
|
+
expect(cache.size).toBe(2)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('get refreshes position (prevents eviction)', () => {
|
|
29
|
+
const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
|
|
30
|
+
cache.set('a', 1)
|
|
31
|
+
cache.set('b', 2)
|
|
32
|
+
// Access 'a' to refresh its position
|
|
33
|
+
cache.get('a')
|
|
34
|
+
// Insert 'c' — should evict 'b' (now oldest) instead of 'a'
|
|
35
|
+
cache.set('c', 3)
|
|
36
|
+
expect(cache.get('a')).toBe(1)
|
|
37
|
+
expect(cache.get('b')).toBeUndefined()
|
|
38
|
+
expect(cache.get('c')).toBe(3)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('expired entries return undefined', () => {
|
|
42
|
+
const now = Date.now()
|
|
43
|
+
jest.setSystemTime(new Date(now))
|
|
44
|
+
|
|
45
|
+
const cache = new LruCache<string>({maxSize: 5, ttlMs: 1000})
|
|
46
|
+
cache.set('x', 'val')
|
|
47
|
+
expect(cache.get('x')).toBe('val')
|
|
48
|
+
|
|
49
|
+
// Advance past TTL
|
|
50
|
+
jest.setSystemTime(new Date(now + 1001))
|
|
51
|
+
expect(cache.get('x')).toBeUndefined()
|
|
52
|
+
expect(cache.size).toBe(0)
|
|
53
|
+
|
|
54
|
+
jest.restoreAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('delete removes entry', () => {
|
|
58
|
+
const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
|
|
59
|
+
cache.set('a', 1)
|
|
60
|
+
expect(cache.delete('a')).toBe(true)
|
|
61
|
+
expect(cache.get('a')).toBeUndefined()
|
|
62
|
+
expect(cache.delete('a')).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('clear removes all entries', () => {
|
|
66
|
+
const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
|
|
67
|
+
cache.set('a', 1)
|
|
68
|
+
cache.set('b', 2)
|
|
69
|
+
cache.clear()
|
|
70
|
+
expect(cache.size).toBe(0)
|
|
71
|
+
expect(cache.get('a')).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('overwriting a key updates value and refreshes position', () => {
|
|
75
|
+
const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
|
|
76
|
+
cache.set('a', 1)
|
|
77
|
+
cache.set('b', 2)
|
|
78
|
+
cache.set('a', 10)
|
|
79
|
+
// 'a' is refreshed, 'b' is oldest
|
|
80
|
+
cache.set('c', 3)
|
|
81
|
+
expect(cache.get('a')).toBe(10)
|
|
82
|
+
expect(cache.get('b')).toBeUndefined()
|
|
83
|
+
expect(cache.get('c')).toBe(3)
|
|
84
|
+
})
|
|
85
|
+
})
|
package/lib/lruCache.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
interface CacheEntry<V> {
|
|
2
|
+
value: V
|
|
3
|
+
expiresAt: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class LruCache<V> {
|
|
7
|
+
private map = new Map<string, CacheEntry<V>>()
|
|
8
|
+
private maxSize: number
|
|
9
|
+
private ttlMs: number
|
|
10
|
+
|
|
11
|
+
constructor(opts: {maxSize: number; ttlMs: number}) {
|
|
12
|
+
this.maxSize = opts.maxSize
|
|
13
|
+
this.ttlMs = opts.ttlMs
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get(key: string): V | undefined {
|
|
17
|
+
const entry = this.map.get(key)
|
|
18
|
+
if (!entry) return undefined
|
|
19
|
+
if (Date.now() > entry.expiresAt) {
|
|
20
|
+
this.map.delete(key)
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
// Refresh position: delete and re-insert (Map preserves insertion order)
|
|
24
|
+
this.map.delete(key)
|
|
25
|
+
this.map.set(key, entry)
|
|
26
|
+
return entry.value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(key: string, value: V): void {
|
|
30
|
+
this.map.delete(key)
|
|
31
|
+
if (this.map.size >= this.maxSize) {
|
|
32
|
+
// Evict oldest (first entry)
|
|
33
|
+
const oldest = this.map.keys().next().value
|
|
34
|
+
if (oldest !== undefined) this.map.delete(oldest)
|
|
35
|
+
}
|
|
36
|
+
this.map.set(key, {value, expiresAt: Date.now() + this.ttlMs})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
delete(key: string): boolean {
|
|
40
|
+
return this.map.delete(key)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clear(): void {
|
|
44
|
+
this.map.clear()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get size(): number {
|
|
48
|
+
return this.map.size
|
|
49
|
+
}
|
|
50
|
+
}
|