@2en/clawly-plugins 1.31.0-beta.0 → 1.32.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.
@@ -15,9 +15,9 @@
15
15
  {
16
16
  id: "auto",
17
17
  name: "auto",
18
- input: ["text", "image"],
19
- contextWindow: 262144,
20
- maxTokens: 65535,
18
+ input: ["text"],
19
+ contextWindow: 204800,
20
+ maxTokens: 131072,
21
21
  },
22
22
  {
23
23
  id: "moonshotai/kimi-k2.5",
package/gateway/index.ts CHANGED
@@ -7,6 +7,8 @@ import {registerClawhub2gateway} from './clawhub2gateway'
7
7
  import {registerConfigModel} from './config-model'
8
8
  import {registerConfigRepair} from './config-repair'
9
9
  import {registerConfigTimezone} from './config-timezone'
10
+ import {registerInfo} from './info'
11
+ import {registerIssueAccessToken} from './issueAccessToken'
10
12
  import {registerCronDelivery} from './cron-delivery'
11
13
  import {registerCronTelemetry} from './cron-telemetry'
12
14
  import {registerMessageLog} from './message-log'
@@ -19,6 +21,7 @@ import {registerPlugins} from './plugins'
19
21
  import {initPostHog, shutdownPostHog} from './posthog'
20
22
  import {registerPresence} from './presence'
21
23
  import {registerSessionSanitize} from './session-sanitize'
24
+ import {registerUploadHttpRoute} from '../http/file/upload'
22
25
  import {registerVersion} from './version'
23
26
 
24
27
  export function registerGateway(api: PluginApi) {
@@ -47,6 +50,7 @@ export function registerGateway(api: PluginApi) {
47
50
  })
48
51
  }
49
52
 
53
+ registerInfo(api)
50
54
  registerPresence(api)
51
55
  registerNotification(api)
52
56
  registerAgentSend(api)
@@ -64,6 +68,8 @@ export function registerGateway(api: PluginApi) {
64
68
  registerSessionSanitize(api)
65
69
  registerPairing(api)
66
70
  registerVersion(api)
71
+ registerIssueAccessToken(api)
72
+ registerUploadHttpRoute(api)
67
73
  registerAudit(api)
68
74
  registerCalendarNative(api)
69
75
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Plugin info — fast, synchronous metadata from package.json.
3
+ *
4
+ * Only static info that can be resolved without I/O or network calls.
5
+ *
6
+ * Method: clawly.info({}) → { version: string }
7
+ */
8
+
9
+ import type {PluginApi} from '../types'
10
+
11
+ // @ts-expect-error — JSON import
12
+ import pkg from '../package.json'
13
+
14
+ export function registerInfo(api: PluginApi) {
15
+ api.registerGatewayMethod('clawly.info', async ({respond}) => {
16
+ respond(true, {version: pkg.version})
17
+ })
18
+ }
@@ -0,0 +1,17 @@
1
+ import {createAccessToken, resolveGatewaySecret} from '../lib/httpAuth'
2
+ import type {PluginApi} from '../types'
3
+
4
+ export function registerIssueAccessToken(api: PluginApi) {
5
+ api.registerGatewayMethod('clawly.issueAccessToken', async ({respond}) => {
6
+ const secret = resolveGatewaySecret(api)
7
+ if (!secret) {
8
+ respond(false, undefined, {
9
+ code: 'no_secret',
10
+ message: 'gateway auth token is not configured',
11
+ })
12
+ return
13
+ }
14
+ const {token, expiresAt} = createAccessToken(secret)
15
+ respond(true, {accessToken: token, expiresAt})
16
+ })
17
+ }
@@ -20,8 +20,8 @@ import os from 'node:os'
20
20
  import path from 'node:path'
21
21
  import mime from 'mime'
22
22
 
23
- import type {PluginApi} from './index'
24
- import {createAccessToken, guardHttpAuth, resolveGatewaySecret, sendJson} from './lib/httpAuth'
23
+ import type {PluginApi} from '../../types'
24
+ import {guardHttpAuth, handleCors, sendJson} from '../../lib/httpAuth'
25
25
 
26
26
  const OUTBOUND_DIR = path.join(os.homedir(), '.openclaw', 'clawly', 'outbound')
27
27
 
@@ -116,19 +116,6 @@ export function registerOutboundMethods(api: PluginApi) {
116
116
  )
117
117
  respond(true, {base64: buffer.toString('base64')})
118
118
  })
119
-
120
- api.registerGatewayMethod('clawly.issueAccessToken', async ({respond}) => {
121
- const secret = resolveGatewaySecret(api)
122
- if (!secret) {
123
- respond(false, undefined, {
124
- code: 'no_secret',
125
- message: 'gateway auth token is not configured',
126
- })
127
- return
128
- }
129
- const {token, expiresAt} = createAccessToken(secret)
130
- respond(true, {accessToken: token, expiresAt})
131
- })
132
119
  }
133
120
 
134
121
  // ── HTTP route: GET /clawly/file/outbound?path=<original-path> ─────────────
@@ -180,17 +167,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
180
167
  path: '/clawly/file/outbound',
181
168
  auth: 'plugin',
182
169
  handler: async (_req: IncomingMessage, res: ServerResponse) => {
183
- res.setHeader('Access-Control-Allow-Origin', '*')
184
-
185
- // Handle CORS preflight
186
- if (_req.method === 'OPTIONS') {
187
- res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
188
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
189
- res.setHeader('Access-Control-Max-Age', '86400')
190
- res.statusCode = 204
191
- res.end()
192
- return
193
- }
170
+ if (handleCors(_req, res, 'GET, HEAD, OPTIONS')) return
194
171
 
195
172
  const url = new URL(_req.url ?? '/', 'http://localhost')
196
173
  if (!guardHttpAuth(api, _req, res, url)) return
@@ -0,0 +1,98 @@
1
+ import crypto from 'node:crypto'
2
+ import fsp from 'node:fs/promises'
3
+ import type {IncomingMessage, ServerResponse} from 'node:http'
4
+ import path from 'node:path'
5
+
6
+ import mime from 'mime'
7
+ import {guardHttpAuth, handleCors, sendJson} from '../../lib/httpAuth'
8
+ import type {PluginApi} from '../../types'
9
+
10
+ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024 // 20 MB
11
+ const ALLOWED_CONTENT_TYPE_PREFIXES = ['image/', 'audio/']
12
+
13
+ function uploadsDir(api: PluginApi): string {
14
+ return path.join(api.runtime.state.resolveStateDir(), 'media', 'clawly')
15
+ }
16
+
17
+ function collectBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
18
+ return new Promise((resolve, reject) => {
19
+ const chunks: Buffer[] = []
20
+ let size = 0
21
+ let done = false
22
+ req.on('data', (chunk: Buffer) => {
23
+ if (done) return
24
+ size += chunk.length
25
+ if (size > maxBytes) {
26
+ done = true
27
+ req.destroy()
28
+ reject(new Error(`body too large (exceeded ${maxBytes} bytes)`))
29
+ return
30
+ }
31
+ chunks.push(chunk)
32
+ })
33
+ req.on('end', () => {
34
+ if (!done) resolve(Buffer.concat(chunks))
35
+ })
36
+ req.on('error', reject)
37
+ })
38
+ }
39
+
40
+ export function registerUploadHttpRoute(api: PluginApi) {
41
+ api.registerHttpRoute({
42
+ path: '/clawly/file/upload',
43
+ auth: 'plugin',
44
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
45
+ if (handleCors(req, res, 'POST, OPTIONS')) return
46
+
47
+ const url = new URL(req.url ?? '/', 'http://localhost')
48
+ if (!guardHttpAuth(api, req, res, url)) return
49
+
50
+ if (req.method !== 'POST') {
51
+ sendJson(res, 405, {error: 'method not allowed'})
52
+ return
53
+ }
54
+
55
+ const contentType = req.headers['content-type']?.split(';')[0]?.trim()
56
+ if (!contentType || !ALLOWED_CONTENT_TYPE_PREFIXES.some((p) => contentType.startsWith(p))) {
57
+ sendJson(res, 415, {
58
+ error: `only ${ALLOWED_CONTENT_TYPE_PREFIXES.map((p) => p + '*').join(', ')} content types are accepted`,
59
+ })
60
+ return
61
+ }
62
+
63
+ const filename = url.searchParams.get('filename')?.trim() ?? ''
64
+ const extFromFilename = filename ? path.extname(filename).toLowerCase() : ''
65
+ const extFromMime = mime.getExtension(contentType)
66
+ const ext = extFromFilename || (extFromMime ? `.${extFromMime}` : '')
67
+ if (!ext) {
68
+ sendJson(res, 400, {error: 'cannot determine file extension from filename or Content-Type'})
69
+ return
70
+ }
71
+
72
+ let buffer: Buffer
73
+ try {
74
+ buffer = await collectBody(req, MAX_UPLOAD_BYTES)
75
+ } catch (err) {
76
+ sendJson(res, 413, {error: err instanceof Error ? err.message : 'body too large'})
77
+ return
78
+ }
79
+
80
+ if (buffer.length === 0) {
81
+ sendJson(res, 400, {error: 'empty body'})
82
+ return
83
+ }
84
+
85
+ const dir = uploadsDir(api)
86
+ await fsp.mkdir(dir, {recursive: true})
87
+
88
+ const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16)
89
+ const destFilename = `${hash}${ext}`
90
+ const destPath = path.join(dir, destFilename)
91
+
92
+ await fsp.writeFile(destPath, buffer)
93
+ api.logger.info(`upload: saved ${destPath} (${buffer.length} bytes)`)
94
+
95
+ sendJson(res, 200, {path: destPath})
96
+ },
97
+ })
98
+ }
package/index.ts CHANGED
@@ -50,7 +50,11 @@ import {registerCronHook} from './cron-hook'
50
50
  import {registerEmail} from './email'
51
51
  import {registerGateway} from './gateway'
52
52
  import {getGatewayConfig} from './gateway-fetch'
53
- import {registerOutboundHook, registerOutboundHttpRoute, registerOutboundMethods} from './outbound'
53
+ import {
54
+ registerOutboundHook,
55
+ registerOutboundHttpRoute,
56
+ registerOutboundMethods,
57
+ } from './http/file/outbound'
54
58
  import {registerSkillCommandRestore} from './skill-command-restore'
55
59
  import {registerTools} from './tools'
56
60
  import type {PluginApi} from './types'
package/lib/httpAuth.ts CHANGED
@@ -94,6 +94,27 @@ export function authenticateHttpRequest(req: IncomingMessage, url: URL, secret:
94
94
  return false
95
95
  }
96
96
 
97
+ /**
98
+ * Set CORS headers and handle OPTIONS preflight.
99
+ * Returns true if the request was a preflight (response already sent).
100
+ */
101
+ export function handleCors(
102
+ req: IncomingMessage,
103
+ res: ServerResponse,
104
+ methods: string = 'GET, HEAD, OPTIONS',
105
+ ): boolean {
106
+ res.setHeader('Access-Control-Allow-Origin', '*')
107
+ if (req.method === 'OPTIONS') {
108
+ res.setHeader('Access-Control-Allow-Methods', methods)
109
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
110
+ res.setHeader('Access-Control-Max-Age', '86400')
111
+ res.statusCode = 204
112
+ res.end()
113
+ return true
114
+ }
115
+ return false
116
+ }
117
+
97
118
  export function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
98
119
  res.writeHead(status, {'Content-Type': 'application/json'})
99
120
  res.end(JSON.stringify(body))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.31.0-beta.0",
3
+ "version": "1.32.0-beta.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "cron-hook.ts",
34
34
  "email.ts",
35
35
  "gateway-fetch.ts",
36
- "outbound.ts",
36
+ "http",
37
37
  "model-gateway-setup.ts",
38
38
  "resolve-gateway-credentials.ts",
39
39
  "skill-command-restore.ts",
@@ -192,6 +192,105 @@ describe('clawly_search', () => {
192
192
  expect(call.body?.search_mode).toBeUndefined()
193
193
  })
194
194
 
195
+ test('extracts citations from message annotations (OpenRouter format)', async () => {
196
+ mockResponse.body = {
197
+ choices: [
198
+ {
199
+ message: {
200
+ content: 'Answer with annotation citations',
201
+ annotations: [
202
+ {type: 'url_citation', url_citation: {url: 'https://example.com/a'}},
203
+ {type: 'url_citation', url_citation: {url: 'https://example.com/b'}},
204
+ ],
205
+ },
206
+ },
207
+ ],
208
+ }
209
+ const {execute} = createMockApi(registerSearchTool)
210
+ const res = parseResult(await execute('tc-1', {query: 'test'}))
211
+
212
+ expect(res.answer).toBe('Answer with annotation citations')
213
+ expect(res.citations).toEqual(['https://example.com/a', 'https://example.com/b'])
214
+ })
215
+
216
+ test('prefers top-level citations over annotations', async () => {
217
+ mockResponse.body = {
218
+ choices: [
219
+ {
220
+ message: {
221
+ content: 'Answer',
222
+ annotations: [
223
+ {type: 'url_citation', url_citation: {url: 'https://example.com/annotation'}},
224
+ ],
225
+ },
226
+ },
227
+ ],
228
+ citations: ['https://example.com/top-level'],
229
+ }
230
+ const {execute} = createMockApi(registerSearchTool)
231
+ const res = parseResult(await execute('tc-1', {query: 'test'}))
232
+
233
+ expect(res.citations).toEqual(['https://example.com/top-level'])
234
+ })
235
+
236
+ test('deduplicates annotation citations', async () => {
237
+ mockResponse.body = {
238
+ choices: [
239
+ {
240
+ message: {
241
+ content: 'Answer',
242
+ annotations: [
243
+ {type: 'url_citation', url_citation: {url: 'https://example.com/same'}},
244
+ {type: 'url_citation', url_citation: {url: 'https://example.com/same'}},
245
+ {type: 'url_citation', url_citation: {url: 'https://example.com/other'}},
246
+ ],
247
+ },
248
+ },
249
+ ],
250
+ }
251
+ const {execute} = createMockApi(registerSearchTool)
252
+ const res = parseResult(await execute('tc-1', {query: 'test'}))
253
+
254
+ expect(res.citations).toEqual(['https://example.com/same', 'https://example.com/other'])
255
+ })
256
+
257
+ test('extracts citations from annotation.url fallback', async () => {
258
+ mockResponse.body = {
259
+ choices: [
260
+ {
261
+ message: {
262
+ content: 'Answer',
263
+ annotations: [{type: 'url_citation', url: 'https://example.com/fallback-url'}],
264
+ },
265
+ },
266
+ ],
267
+ }
268
+ const {execute} = createMockApi(registerSearchTool)
269
+ const res = parseResult(await execute('tc-1', {query: 'test'}))
270
+
271
+ expect(res.citations).toEqual(['https://example.com/fallback-url'])
272
+ })
273
+
274
+ test('falls back to annotations when top-level citations is empty array', async () => {
275
+ mockResponse.body = {
276
+ choices: [
277
+ {
278
+ message: {
279
+ content: 'Answer',
280
+ annotations: [
281
+ {type: 'url_citation', url_citation: {url: 'https://example.com/fallback'}},
282
+ ],
283
+ },
284
+ },
285
+ ],
286
+ citations: [],
287
+ }
288
+ const {execute} = createMockApi(registerSearchTool)
289
+ const res = parseResult(await execute('tc-1', {query: 'test'}))
290
+
291
+ expect(res.citations).toEqual(['https://example.com/fallback'])
292
+ })
293
+
195
294
  test('returns error on API failure', async () => {
196
295
  mockResponse = {ok: false, status: 503, body: {error: 'service unavailable'}}
197
296
  const {execute} = createMockApi(registerSearchTool)
@@ -7,6 +7,7 @@ import {
7
7
  createSearchToolRegistrar,
8
8
  parseGrokResponse,
9
9
  parseKimiResponse,
10
+ parsePerplexityResponse,
10
11
  } from './create-search-tool'
11
12
 
12
13
  export const registerSearchTool = createSearchToolRegistrar({
@@ -17,6 +18,7 @@ export const registerSearchTool = createSearchToolRegistrar({
17
18
  buildUrl: buildPerplexityUrl,
18
19
  timeoutMs: 30_000,
19
20
  provider: 'perplexity',
21
+ parseResponse: parsePerplexityResponse,
20
22
  })
21
23
 
22
24
  export const registerDeepSearchTool = createSearchToolRegistrar({
@@ -27,6 +29,7 @@ export const registerDeepSearchTool = createSearchToolRegistrar({
27
29
  buildUrl: buildPerplexityUrl,
28
30
  timeoutMs: 120_000,
29
31
  provider: 'perplexity',
32
+ parseResponse: parsePerplexityResponse,
30
33
  })
31
34
 
32
35
  // model is 'grok-4-fast' (not 'x-ai/grok-4-fast') because this goes through
@@ -17,9 +17,8 @@ export interface SearchToolConfig {
17
17
  buildUrl: (baseUrl: string) => string
18
18
  timeoutMs: number
19
19
  provider: SearchProvider
20
- extraBody?: Record<string, unknown>
21
20
  buildBody?: (model: string, query: string) => Record<string, unknown>
22
- parseResponse?: (data: unknown) => {answer: string; citations: string[]}
21
+ parseResponse: (data: unknown) => {answer: string; citations: string[]}
23
22
  }
24
23
 
25
24
  function createParameters(): Record<string, unknown> {
@@ -70,6 +69,47 @@ function extractCitations(text: string): string[] {
70
69
  return urls
71
70
  }
72
71
 
72
+ export function parsePerplexityResponse(data: unknown): {answer: string; citations: string[]} {
73
+ const d = data as {
74
+ choices?: {
75
+ message?: {
76
+ content?: string
77
+ annotations?: Array<{type?: string; url?: string; url_citation?: {url?: string}}>
78
+ }
79
+ }[]
80
+ citations?: string[]
81
+ }
82
+ const answer = d.choices?.[0]?.message?.content ?? ''
83
+
84
+ const seen = new Set<string>()
85
+ const topLevel = (d.citations ?? []).reduce<string[]>((acc, raw) => {
86
+ if (typeof raw !== 'string') return acc
87
+ const url = raw.trim()
88
+ if (url && !seen.has(url)) {
89
+ seen.add(url)
90
+ acc.push(url)
91
+ }
92
+ return acc
93
+ }, [])
94
+ if (topLevel.length > 0) {
95
+ return {answer, citations: topLevel}
96
+ }
97
+
98
+ // Fallback: extract from message annotations (OpenRouter format)
99
+ const citations: string[] = []
100
+ for (const annotation of d.choices?.[0]?.message?.annotations ?? []) {
101
+ if (annotation.type !== 'url_citation') continue
102
+ const raw = annotation.url_citation?.url ?? annotation.url
103
+ if (typeof raw !== 'string') continue
104
+ const url = raw.trim()
105
+ if (url && !seen.has(url)) {
106
+ seen.add(url)
107
+ citations.push(url)
108
+ }
109
+ }
110
+ return {answer, citations}
111
+ }
112
+
73
113
  export function parseKimiResponse(data: unknown): {answer: string; citations: string[]} {
74
114
  const d = data as {choices?: {message?: {content?: string}}[]}
75
115
  const answerText = d.choices?.[0]?.message?.content ?? ''
@@ -120,12 +160,7 @@ export function createSearchToolRegistrar(config: SearchToolConfig) {
120
160
  body: JSON.stringify(
121
161
  config.buildBody
122
162
  ? config.buildBody(config.model, query)
123
- : {
124
- model: config.model,
125
- stream: false,
126
- messages: [{role: 'user', content: query}],
127
- ...config.extraBody,
128
- },
163
+ : {model: config.model, stream: false, messages: [{role: 'user', content: query}]},
129
164
  ),
130
165
  signal: controller.signal,
131
166
  })
@@ -143,18 +178,7 @@ export function createSearchToolRegistrar(config: SearchToolConfig) {
143
178
 
144
179
  const data = await res.json()
145
180
 
146
- const parsed = config.parseResponse
147
- ? config.parseResponse(data)
148
- : (() => {
149
- const d = data as {
150
- choices?: {message?: {content?: string}}[]
151
- citations?: string[]
152
- }
153
- return {
154
- answer: d.choices?.[0]?.message?.content ?? '',
155
- citations: d.citations ?? [],
156
- }
157
- })()
181
+ const parsed = config.parseResponse(data)
158
182
 
159
183
  const result: SearchResult = {
160
184
  query,