@2en/clawly-plugins 1.29.0-beta.1 → 1.29.0-beta.4
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/gateway/audit.ts +2 -2
- package/gateway/config-repair.ts +22 -2
- package/gateway/cron-delivery.test.ts +385 -0
- package/gateway/cron-delivery.ts +8 -4
- package/internal/hooks/auto-update.ts +3 -3
- package/lib/stripCliLogs.test.ts +5 -17
- package/lib/stripCliLogs.ts +7 -22
- package/model-gateway-setup.ts +75 -8
- package/package.json +1 -1
package/gateway/audit.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {$} from 'zx'
|
|
10
|
-
import {
|
|
10
|
+
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
11
11
|
import type {PluginApi} from '../types'
|
|
12
12
|
|
|
13
13
|
const TIMEOUT_MS = 30_000
|
|
@@ -27,7 +27,7 @@ export function registerAudit(api: PluginApi) {
|
|
|
27
27
|
})
|
|
28
28
|
return
|
|
29
29
|
}
|
|
30
|
-
const json =
|
|
30
|
+
const json = stripCliLogs(result.stdout)
|
|
31
31
|
respond(true, JSON.parse(json))
|
|
32
32
|
} catch (err) {
|
|
33
33
|
respond(false, undefined, {message: err instanceof Error ? err.message : 'Unknown error'})
|
package/gateway/config-repair.ts
CHANGED
|
@@ -83,11 +83,31 @@ export function registerConfigRepair(api: PluginApi) {
|
|
|
83
83
|
|
|
84
84
|
const defaultIds = new Set([defaultModel])
|
|
85
85
|
const extraModels = EXTRA_GATEWAY_MODELS.filter((m) => !defaultIds.has(m.id)).map(
|
|
86
|
-
({id, name, input}) => ({
|
|
86
|
+
({id, name, input, contextWindow, maxTokens, api}) => ({
|
|
87
|
+
id,
|
|
88
|
+
name,
|
|
89
|
+
input,
|
|
90
|
+
contextWindow,
|
|
91
|
+
maxTokens,
|
|
92
|
+
...(api ? {api} : {}),
|
|
93
|
+
}),
|
|
87
94
|
)
|
|
95
|
+
const extraMatch = EXTRA_GATEWAY_MODELS.find((m) => m.id === defaultModel)
|
|
88
96
|
const models = !defaultModel
|
|
89
97
|
? (provider?.models ?? [])
|
|
90
|
-
: [
|
|
98
|
+
: [
|
|
99
|
+
{
|
|
100
|
+
id: defaultModel,
|
|
101
|
+
name: defaultModel,
|
|
102
|
+
input: extraMatch ? [...extraMatch.input] : (['text', 'image'] as string[]),
|
|
103
|
+
...(extraMatch && {
|
|
104
|
+
contextWindow: extraMatch.contextWindow,
|
|
105
|
+
maxTokens: extraMatch.maxTokens,
|
|
106
|
+
}),
|
|
107
|
+
...(extraMatch?.api ? {api: extraMatch.api} : {}),
|
|
108
|
+
},
|
|
109
|
+
...extraModels,
|
|
110
|
+
]
|
|
91
111
|
|
|
92
112
|
if (!config.models) config.models = {}
|
|
93
113
|
if (!(config.models as any).providers) (config.models as any).providers = {}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import type {PluginApi} from '../types'
|
|
3
|
+
|
|
4
|
+
// ── Mocks ────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
let mockResolvedKey = 'agent:clawly:main'
|
|
7
|
+
let mockInjectResult = {ok: true, messageId: 'msg-001'}
|
|
8
|
+
let mockResolveError: Error | null = null
|
|
9
|
+
let mockInjectError: Error | null = null
|
|
10
|
+
let resolveSessionKeyCalls: string[] = []
|
|
11
|
+
let injectCalls: {sessionKey: string; message: string}[] = []
|
|
12
|
+
|
|
13
|
+
mock.module('./inject', () => ({
|
|
14
|
+
resolveSessionKey: async (agentId: string) => {
|
|
15
|
+
resolveSessionKeyCalls.push(agentId)
|
|
16
|
+
if (mockResolveError) throw mockResolveError
|
|
17
|
+
return mockResolvedKey
|
|
18
|
+
},
|
|
19
|
+
injectAssistantMessage: async (params: {sessionKey: string; message: string}) => {
|
|
20
|
+
injectCalls.push(params)
|
|
21
|
+
if (mockInjectError) throw mockInjectError
|
|
22
|
+
return mockInjectResult
|
|
23
|
+
},
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
// Do NOT mock cron-telemetry — it uses module-internal state (Maps) that
|
|
27
|
+
// cron-telemetry.test.ts depends on, and Bun's mock.module contaminates
|
|
28
|
+
// across test files in the same process. Assert on logs + inject calls instead.
|
|
29
|
+
|
|
30
|
+
// autoUpdateJobId is a `let` export — ESM namespace objects are frozen,
|
|
31
|
+
// so we can't mutate it between tests. We set it to 'test-auto-update-id'
|
|
32
|
+
// and test the job-ID matching path using a sessionKey that embeds this ID.
|
|
33
|
+
const MOCK_AUTO_UPDATE_ID = 'test-auto-update-id'
|
|
34
|
+
|
|
35
|
+
mock.module('../internal/hooks/auto-update', () => ({
|
|
36
|
+
autoUpdateJobId: MOCK_AUTO_UPDATE_ID,
|
|
37
|
+
JOB_NAME: 'Clawly Plugins Auto-Update',
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
// Must import after mocks
|
|
41
|
+
const {registerCronDelivery} = await import('./cron-delivery')
|
|
42
|
+
|
|
43
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
type Handler = (event: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<void>
|
|
46
|
+
|
|
47
|
+
function createMockApi(): {
|
|
48
|
+
api: PluginApi
|
|
49
|
+
logs: {level: string; msg: string}[]
|
|
50
|
+
handlers: Map<string, Handler>
|
|
51
|
+
} {
|
|
52
|
+
const logs: {level: string; msg: string}[] = []
|
|
53
|
+
const handlers = new Map<string, Handler>()
|
|
54
|
+
const api = {
|
|
55
|
+
id: 'test',
|
|
56
|
+
name: 'test',
|
|
57
|
+
logger: {
|
|
58
|
+
info: (msg: string) => logs.push({level: 'info', msg}),
|
|
59
|
+
warn: (msg: string) => logs.push({level: 'warn', msg}),
|
|
60
|
+
error: (msg: string) => logs.push({level: 'error', msg}),
|
|
61
|
+
},
|
|
62
|
+
on: (hookName: string, handler: (...args: any[]) => any) => {
|
|
63
|
+
handlers.set(hookName, handler as any)
|
|
64
|
+
},
|
|
65
|
+
} as unknown as PluginApi
|
|
66
|
+
return {api, logs, handlers}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeMessages(assistantContent: string | object[]) {
|
|
70
|
+
return [
|
|
71
|
+
{role: 'user', content: 'trigger message'},
|
|
72
|
+
{role: 'assistant', content: assistantContent},
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Tests ────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
mockResolvedKey = 'agent:clawly:main'
|
|
80
|
+
mockInjectResult = {ok: true, messageId: 'msg-001'}
|
|
81
|
+
mockResolveError = null
|
|
82
|
+
mockInjectError = null
|
|
83
|
+
resolveSessionKeyCalls = []
|
|
84
|
+
injectCalls = []
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('cron-delivery', () => {
|
|
88
|
+
describe('session filtering', () => {
|
|
89
|
+
test('skips non-cron sessions (main)', async () => {
|
|
90
|
+
const {api, handlers} = createMockApi()
|
|
91
|
+
registerCronDelivery(api)
|
|
92
|
+
|
|
93
|
+
await handlers.get('agent_end')!(
|
|
94
|
+
{messages: makeMessages('Hello!')},
|
|
95
|
+
{sessionKey: 'agent:clawly:main', agentId: 'clawly'},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(injectCalls).toHaveLength(0)
|
|
99
|
+
expect(resolveSessionKeyCalls).toHaveLength(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('skips non-cron sessions (telegram)', async () => {
|
|
103
|
+
const {api, handlers} = createMockApi()
|
|
104
|
+
registerCronDelivery(api)
|
|
105
|
+
|
|
106
|
+
await handlers.get('agent_end')!(
|
|
107
|
+
{messages: makeMessages('Hello!')},
|
|
108
|
+
{sessionKey: 'agent:clawly:telegram:12345', agentId: 'clawly'},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(injectCalls).toHaveLength(0)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('skips when sessionKey is missing', async () => {
|
|
115
|
+
const {api, handlers} = createMockApi()
|
|
116
|
+
registerCronDelivery(api)
|
|
117
|
+
|
|
118
|
+
await handlers.get('agent_end')!({messages: makeMessages('Hello!')}, {})
|
|
119
|
+
|
|
120
|
+
expect(injectCalls).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('assistant text extraction', () => {
|
|
125
|
+
test('skips when no assistant message in event', async () => {
|
|
126
|
+
const {api, logs, handlers} = createMockApi()
|
|
127
|
+
registerCronDelivery(api)
|
|
128
|
+
|
|
129
|
+
await handlers.get('agent_end')!(
|
|
130
|
+
{messages: [{role: 'user', content: 'Hello'}]},
|
|
131
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
expect(injectCalls).toHaveLength(0)
|
|
135
|
+
expect(logs).toContainEqual({
|
|
136
|
+
level: 'info',
|
|
137
|
+
msg: 'cron-delivery: skipped (no assistant message)',
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('skips when messages is undefined', async () => {
|
|
142
|
+
const {api, logs, handlers} = createMockApi()
|
|
143
|
+
registerCronDelivery(api)
|
|
144
|
+
|
|
145
|
+
await handlers.get('agent_end')!(
|
|
146
|
+
{},
|
|
147
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect(injectCalls).toHaveLength(0)
|
|
151
|
+
expect(logs).toContainEqual({
|
|
152
|
+
level: 'info',
|
|
153
|
+
msg: 'cron-delivery: skipped (no assistant message)',
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('extracts string content', async () => {
|
|
158
|
+
const {api, handlers} = createMockApi()
|
|
159
|
+
registerCronDelivery(api)
|
|
160
|
+
|
|
161
|
+
await handlers.get('agent_end')!(
|
|
162
|
+
{messages: makeMessages('Weather is sunny today!')},
|
|
163
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(injectCalls).toHaveLength(1)
|
|
167
|
+
expect(injectCalls[0].message).toBe('Weather is sunny today!')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('extracts array content (multi-part text)', async () => {
|
|
171
|
+
const {api, handlers} = createMockApi()
|
|
172
|
+
registerCronDelivery(api)
|
|
173
|
+
|
|
174
|
+
await handlers.get('agent_end')!(
|
|
175
|
+
{
|
|
176
|
+
messages: makeMessages([
|
|
177
|
+
{type: 'text', text: 'Part 1 '},
|
|
178
|
+
{type: 'text', text: 'Part 2'},
|
|
179
|
+
]),
|
|
180
|
+
},
|
|
181
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
expect(injectCalls).toHaveLength(1)
|
|
185
|
+
expect(injectCalls[0].message).toBe('Part 1 Part 2')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('preserves raw formatting (newlines)', async () => {
|
|
189
|
+
const {api, handlers} = createMockApi()
|
|
190
|
+
registerCronDelivery(api)
|
|
191
|
+
|
|
192
|
+
await handlers.get('agent_end')!(
|
|
193
|
+
{messages: makeMessages('Line one\n\nLine two')},
|
|
194
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(injectCalls).toHaveLength(1)
|
|
198
|
+
// cron-delivery uses getRawLastAssistantText which preserves newlines
|
|
199
|
+
expect(injectCalls[0].message).toBe('Line one\n\nLine two')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('auto-update filtering', () => {
|
|
204
|
+
test('skips by job ID match in sessionKey', async () => {
|
|
205
|
+
const {api, logs, handlers} = createMockApi()
|
|
206
|
+
registerCronDelivery(api)
|
|
207
|
+
|
|
208
|
+
// sessionKey embeds MOCK_AUTO_UPDATE_ID — matches the primary job-ID check
|
|
209
|
+
await handlers.get('agent_end')!(
|
|
210
|
+
{messages: makeMessages('Update complete')},
|
|
211
|
+
{sessionKey: `agent:clawly:cron:${MOCK_AUTO_UPDATE_ID}`, agentId: 'clawly'},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(injectCalls).toHaveLength(0)
|
|
215
|
+
expect(logs).toContainEqual({level: 'info', msg: 'cron-delivery: skipped (auto-update job)'})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('skips by JOB_NAME text match (fallback)', async () => {
|
|
219
|
+
const {api, logs, handlers} = createMockApi()
|
|
220
|
+
registerCronDelivery(api)
|
|
221
|
+
|
|
222
|
+
// sessionKey does NOT match the job ID — falls through to text-based detection
|
|
223
|
+
await handlers.get('agent_end')!(
|
|
224
|
+
{messages: makeMessages('Clawly Plugins Auto-Update completed successfully')},
|
|
225
|
+
{sessionKey: 'agent:clawly:cron:some-other-id', agentId: 'clawly'},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(injectCalls).toHaveLength(0)
|
|
229
|
+
expect(logs).toContainEqual({level: 'info', msg: 'cron-delivery: skipped (auto-update job)'})
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('noise filtering', () => {
|
|
234
|
+
test('skips NO_REPLY', async () => {
|
|
235
|
+
const {api, logs, handlers} = createMockApi()
|
|
236
|
+
registerCronDelivery(api)
|
|
237
|
+
|
|
238
|
+
await handlers.get('agent_end')!(
|
|
239
|
+
{messages: makeMessages('NO_REPLY')},
|
|
240
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
expect(injectCalls).toHaveLength(0)
|
|
244
|
+
expect(logs).toContainEqual({
|
|
245
|
+
level: 'info',
|
|
246
|
+
msg: expect.stringContaining('skipped (filtered: silent reply)'),
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('skips HEARTBEAT_OK (bare)', async () => {
|
|
251
|
+
const {api, logs, handlers} = createMockApi()
|
|
252
|
+
registerCronDelivery(api)
|
|
253
|
+
|
|
254
|
+
await handlers.get('agent_end')!(
|
|
255
|
+
{messages: makeMessages('HEARTBEAT_OK')},
|
|
256
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
expect(injectCalls).toHaveLength(0)
|
|
260
|
+
expect(logs).toContainEqual({
|
|
261
|
+
level: 'info',
|
|
262
|
+
msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('skips empty text', async () => {
|
|
267
|
+
const {api, logs, handlers} = createMockApi()
|
|
268
|
+
registerCronDelivery(api)
|
|
269
|
+
|
|
270
|
+
await handlers.get('agent_end')!(
|
|
271
|
+
{messages: makeMessages(' ')},
|
|
272
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(injectCalls).toHaveLength(0)
|
|
276
|
+
expect(logs).toContainEqual({
|
|
277
|
+
level: 'info',
|
|
278
|
+
msg: expect.stringContaining('skipped (filtered: empty assistant)'),
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('does not skip meaningful content ending with HEARTBEAT_OK', async () => {
|
|
283
|
+
const {api, handlers} = createMockApi()
|
|
284
|
+
registerCronDelivery(api)
|
|
285
|
+
|
|
286
|
+
await handlers.get('agent_end')!(
|
|
287
|
+
{messages: makeMessages('Weather is good today. HEARTBEAT_OK')},
|
|
288
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
expect(injectCalls).toHaveLength(1)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('agentId validation', () => {
|
|
296
|
+
test('skips when agentId is missing', async () => {
|
|
297
|
+
const {api, logs, handlers} = createMockApi()
|
|
298
|
+
registerCronDelivery(api)
|
|
299
|
+
|
|
300
|
+
await handlers.get('agent_end')!(
|
|
301
|
+
{messages: makeMessages('Weather report')},
|
|
302
|
+
{sessionKey: 'agent:clawly:cron:weather-check'},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(injectCalls).toHaveLength(0)
|
|
306
|
+
expect(resolveSessionKeyCalls).toHaveLength(0)
|
|
307
|
+
expect(logs).toContainEqual({
|
|
308
|
+
level: 'error',
|
|
309
|
+
msg: 'cron-delivery: skipped (no agentId on ctx)',
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('happy path', () => {
|
|
315
|
+
test('resolves session key and injects message', async () => {
|
|
316
|
+
const {api, logs, handlers} = createMockApi()
|
|
317
|
+
registerCronDelivery(api)
|
|
318
|
+
|
|
319
|
+
await handlers.get('agent_end')!(
|
|
320
|
+
{messages: makeMessages('Weather is sunny today!')},
|
|
321
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
expect(resolveSessionKeyCalls).toEqual(['clawly'])
|
|
325
|
+
expect(injectCalls).toEqual([
|
|
326
|
+
{sessionKey: 'agent:clawly:main', message: 'Weather is sunny today!'},
|
|
327
|
+
])
|
|
328
|
+
expect(logs).toContainEqual({
|
|
329
|
+
level: 'info',
|
|
330
|
+
msg: 'cron-delivery: injected into agent:clawly:main (messageId=msg-001)',
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('uses resolved session key (not hardcoded)', async () => {
|
|
335
|
+
mockResolvedKey = 'agent:luna:main'
|
|
336
|
+
const {api, handlers} = createMockApi()
|
|
337
|
+
registerCronDelivery(api)
|
|
338
|
+
|
|
339
|
+
await handlers.get('agent_end')!(
|
|
340
|
+
{messages: makeMessages('Report ready')},
|
|
341
|
+
{sessionKey: 'agent:clawly:cron:daily-report', agentId: 'luna'},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
expect(resolveSessionKeyCalls).toEqual(['luna'])
|
|
345
|
+
expect(injectCalls[0].sessionKey).toBe('agent:luna:main')
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe('error handling', () => {
|
|
350
|
+
test('handles resolveSessionKey failure gracefully', async () => {
|
|
351
|
+
mockResolveError = new Error('sessions.resolve failed: JSON parse error')
|
|
352
|
+
const {api, logs, handlers} = createMockApi()
|
|
353
|
+
registerCronDelivery(api)
|
|
354
|
+
|
|
355
|
+
await handlers.get('agent_end')!(
|
|
356
|
+
{messages: makeMessages('Weather report')},
|
|
357
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
expect(injectCalls).toHaveLength(0)
|
|
361
|
+
expect(logs).toContainEqual({
|
|
362
|
+
level: 'error',
|
|
363
|
+
msg: 'cron-delivery: sessions.resolve failed: JSON parse error',
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('handles injectAssistantMessage failure gracefully', async () => {
|
|
368
|
+
mockInjectError = new Error('chat.inject failed: timeout')
|
|
369
|
+
const {api, logs, handlers} = createMockApi()
|
|
370
|
+
registerCronDelivery(api)
|
|
371
|
+
|
|
372
|
+
await handlers.get('agent_end')!(
|
|
373
|
+
{messages: makeMessages('Weather report')},
|
|
374
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
// injectAssistantMessage was called (recorded) but threw
|
|
378
|
+
expect(injectCalls).toHaveLength(1)
|
|
379
|
+
expect(logs).toContainEqual({
|
|
380
|
+
level: 'error',
|
|
381
|
+
msg: 'cron-delivery: chat.inject failed: timeout',
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
})
|
package/gateway/cron-delivery.ts
CHANGED
|
@@ -52,12 +52,13 @@ export function registerCronDelivery(api: PluginApi) {
|
|
|
52
52
|
api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
53
53
|
const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
|
|
54
54
|
const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
|
|
55
|
-
api.logger.info(
|
|
56
|
-
`cron-delivery[debug]: agent_end fired sessionKey=${sessionKey ?? 'undefined'} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
|
|
57
|
-
)
|
|
58
55
|
// Only fire for cron sessions
|
|
59
56
|
if (!sessionKey?.startsWith('agent:clawly:cron:')) return
|
|
60
57
|
|
|
58
|
+
api.logger.info(
|
|
59
|
+
`cron-delivery[debug]: agent_end fired sessionKey=${sessionKey} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
|
|
60
|
+
)
|
|
61
|
+
|
|
61
62
|
try {
|
|
62
63
|
// Extract raw assistant text (preserving formatting)
|
|
63
64
|
const text = getRawLastAssistantText(event.messages)
|
|
@@ -78,7 +79,10 @@ export function registerCronDelivery(api: PluginApi) {
|
|
|
78
79
|
return
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
// Filter noise — reuse the same logic as offline-push
|
|
82
|
+
// Filter noise — reuse the same logic as offline-push.
|
|
83
|
+
// Note: shouldSkipPushForMessage was designed for newline-collapsed text
|
|
84
|
+
// (via getLastAssistantText), but works correctly here with raw text
|
|
85
|
+
// because all its regexes use \s (matches \n) and $ without /m flag.
|
|
82
86
|
const reason = shouldSkipPushForMessage(text)
|
|
83
87
|
if (reason) {
|
|
84
88
|
api.logger.info(`cron-delivery: skipped (filtered: ${reason})`)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import {$} from 'zx'
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {stripCliLogs} from '../../lib/stripCliLogs'
|
|
8
8
|
import type {PluginApi} from '../../types'
|
|
9
9
|
|
|
10
10
|
$.verbose = false
|
|
@@ -90,7 +90,7 @@ function needsUpdate(job: CronJobEntry, config: JobConfig): boolean {
|
|
|
90
90
|
async function findJobs(): Promise<[unknown, CronJobEntry[]]> {
|
|
91
91
|
try {
|
|
92
92
|
const {stdout} = await $`openclaw cron list --json`
|
|
93
|
-
const parsed = JSON.parse(
|
|
93
|
+
const parsed = JSON.parse(stripCliLogs(stdout))
|
|
94
94
|
const jobs: unknown[] = parsed?.jobs ?? parsed ?? []
|
|
95
95
|
if (!Array.isArray(jobs)) return [null, []]
|
|
96
96
|
return [null, jobs.filter((j: any) => j.name === JOB_NAME) as CronJobEntry[]]
|
|
@@ -136,7 +136,7 @@ export function registerAutoUpdate(api: PluginApi) {
|
|
|
136
136
|
const {stdout} =
|
|
137
137
|
await $`openclaw cron add ${['--name', JOB_NAME, '--json', ...configToArgs(JOB_CONFIG)]}`
|
|
138
138
|
try {
|
|
139
|
-
const result = JSON.parse(
|
|
139
|
+
const result = JSON.parse(stripCliLogs(stdout))
|
|
140
140
|
autoUpdateJobId = result?.id ?? null
|
|
141
141
|
} catch {
|
|
142
142
|
// ID extraction failed — job was still created
|
package/lib/stripCliLogs.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {describe, expect, test} from 'bun:test'
|
|
2
|
-
import {stripCliLogs
|
|
2
|
+
import {stripCliLogs} from './stripCliLogs'
|
|
3
3
|
|
|
4
4
|
describe('stripCliLogs', () => {
|
|
5
5
|
test('strips leading log lines', () => {
|
|
@@ -82,9 +82,7 @@ describe('stripCliLogs', () => {
|
|
|
82
82
|
const input = ['11:40:09 [plugins] loading', '[', ' {"id":"test"}', ']'].join('\n')
|
|
83
83
|
expect(stripCliLogs(input)).toBe('[\n {"id":"test"}\n]')
|
|
84
84
|
})
|
|
85
|
-
})
|
|
86
85
|
|
|
87
|
-
describe('stripCliLogsFull', () => {
|
|
88
86
|
test('strips both leading and trailing log lines around pretty-printed JSON', () => {
|
|
89
87
|
const input = [
|
|
90
88
|
'19:48:48 [plugins] feishu_drive: Registered feishu_drive tool',
|
|
@@ -94,29 +92,19 @@ describe('stripCliLogsFull', () => {
|
|
|
94
92
|
'}',
|
|
95
93
|
'19:48:49 [plugins] echo: registered /clawly_echo command',
|
|
96
94
|
].join('\n')
|
|
97
|
-
expect(
|
|
98
|
-
expect(JSON.parse(
|
|
95
|
+
expect(stripCliLogs(input)).toBe('{\n "jobs": []\n}')
|
|
96
|
+
expect(JSON.parse(stripCliLogs(input))).toEqual({jobs: []})
|
|
99
97
|
})
|
|
100
98
|
|
|
101
99
|
test('strips trailing log lines after compact single-line JSON', () => {
|
|
102
100
|
const input = ['{"ok":true}', '19:48:49 [plugins] echo: registered /clawly_echo command'].join(
|
|
103
101
|
'\n',
|
|
104
102
|
)
|
|
105
|
-
expect(
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('behaves like stripCliLogs when no trailing log lines', () => {
|
|
109
|
-
const input = ['11:40:09 [plugins] loading', '{', ' "version": "1.0.0"', '}'].join('\n')
|
|
110
|
-
expect(stripCliLogsFull(input)).toBe(stripCliLogs(input))
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
test('returns empty string when all lines are logs', () => {
|
|
114
|
-
const input = '11:00:00 [a] foo\n12:00:00 [b] bar'
|
|
115
|
-
expect(stripCliLogsFull(input)).toBe('')
|
|
103
|
+
expect(stripCliLogs(input)).toBe('{"ok":true}')
|
|
116
104
|
})
|
|
117
105
|
|
|
118
106
|
test('handles JSON array with leading and trailing logs', () => {
|
|
119
107
|
const input = ['[plugins] loading', '[', ' {"id":"test"}', ']', '[plugins] done'].join('\n')
|
|
120
|
-
expect(
|
|
108
|
+
expect(stripCliLogs(input)).toBe('[\n {"id":"test"}\n]')
|
|
121
109
|
})
|
|
122
110
|
})
|
package/lib/stripCliLogs.ts
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strip leading log lines from `openclaw ... --json` output.
|
|
2
|
+
* Strip leading and trailing log lines from `openclaw ... --json` output.
|
|
3
3
|
*
|
|
4
4
|
* OpenClaw CLI may emit log lines (with or without ANSI colors / timestamps)
|
|
5
|
-
* before the actual JSON payload:
|
|
5
|
+
* before and after the actual JSON payload:
|
|
6
6
|
*
|
|
7
7
|
* 11:40:09 [plugins] echo: registered /clawly_echo command
|
|
8
8
|
* \x1b[35m[plugins] tool: registered clawly_send_app_push agent tool
|
|
9
9
|
* [{"id":"clawly-plugins", ...}]
|
|
10
|
+
* 19:48:49 [plugins] echo: registered /clawly_echo command
|
|
10
11
|
*
|
|
11
12
|
* It may also emit a "Config warnings" box using box-drawing characters
|
|
12
13
|
* (│, ◇, ├, ─, ╮, ╯) between log lines and the JSON body.
|
|
13
14
|
*
|
|
14
|
-
* This function strips ANSI escape sequences, drops every leading
|
|
15
|
-
* is not JSON (log prefixes, warning boxes, blank lines), and returns
|
|
16
|
-
* remaining text (the JSON body).
|
|
15
|
+
* This function strips ANSI escape sequences, drops every leading and trailing
|
|
16
|
+
* line that is not JSON (log prefixes, warning boxes, blank lines), and returns
|
|
17
|
+
* the remaining text (the JSON body).
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
const ANSI_RE = /\x1b\[[0-9;]*m/g
|
|
20
21
|
const JSON_START_RE = /^\{|^\[$|^\[[\s\][{"]/
|
|
21
|
-
|
|
22
|
-
export function stripCliLogs(output: string): string {
|
|
23
|
-
const cleaned = output.replace(ANSI_RE, '')
|
|
24
|
-
const lines = cleaned.split('\n')
|
|
25
|
-
const firstJson = lines.findIndex((line) => JSON_START_RE.test(line))
|
|
26
|
-
if (firstJson === -1) return ''
|
|
27
|
-
return lines.slice(firstJson).join('\n').trim()
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Strip both leading AND trailing non-JSON lines from CLI output.
|
|
32
|
-
*
|
|
33
|
-
* `stripCliLogs` only strips leading lines. This variant also removes
|
|
34
|
-
* trailing log lines that appear after the JSON body closes — e.g.
|
|
35
|
-
* `19:48:49 [plugins] echo: registered /clawly_echo command`.
|
|
36
|
-
*/
|
|
37
22
|
const JSON_END_RE = /^[}\]]$/
|
|
38
23
|
|
|
39
|
-
export function
|
|
24
|
+
export function stripCliLogs(output: string): string {
|
|
40
25
|
const cleaned = output.replace(ANSI_RE, '')
|
|
41
26
|
const lines = cleaned.split('\n')
|
|
42
27
|
const firstJson = lines.findIndex((line) => JSON_START_RE.test(line))
|
package/model-gateway-setup.ts
CHANGED
|
@@ -27,51 +27,73 @@ export const PUBLIC_GATEWAY_MODELS = [
|
|
|
27
27
|
canonicalId: 'moonshotai/kimi-k2.5',
|
|
28
28
|
alias: 'Kimi K2.5',
|
|
29
29
|
input: ['text', 'image'],
|
|
30
|
+
contextWindow: 262_144,
|
|
31
|
+
maxTokens: 65_535,
|
|
30
32
|
},
|
|
31
33
|
{
|
|
32
34
|
canonicalId: 'google/gemini-2.5-pro',
|
|
33
35
|
alias: 'Gemini 2.5 Pro',
|
|
34
36
|
input: ['text', 'image'],
|
|
37
|
+
contextWindow: 1_048_576,
|
|
38
|
+
maxTokens: 65_536,
|
|
35
39
|
},
|
|
36
40
|
{
|
|
37
41
|
canonicalId: 'google/gemini-3-pro-preview',
|
|
38
42
|
alias: 'Gemini 3 Pro Preview',
|
|
39
43
|
input: ['text', 'image'],
|
|
44
|
+
contextWindow: 1_048_576,
|
|
45
|
+
maxTokens: 65_536,
|
|
40
46
|
},
|
|
41
47
|
{
|
|
42
48
|
canonicalId: 'anthropic/claude-sonnet-4.6',
|
|
43
49
|
alias: 'Claude Sonnet 4.6',
|
|
44
50
|
input: ['text', 'image'],
|
|
51
|
+
contextWindow: 1_000_000,
|
|
52
|
+
maxTokens: 128_000,
|
|
53
|
+
api: 'anthropic-messages',
|
|
45
54
|
},
|
|
46
55
|
{
|
|
47
56
|
canonicalId: 'anthropic/claude-opus-4.6',
|
|
48
57
|
alias: 'Claude Opus 4.6',
|
|
49
58
|
input: ['text', 'image'],
|
|
59
|
+
contextWindow: 1_000_000,
|
|
60
|
+
maxTokens: 128_000,
|
|
61
|
+
api: 'anthropic-messages',
|
|
50
62
|
},
|
|
51
63
|
{
|
|
52
64
|
canonicalId: 'openai/gpt-5.4',
|
|
53
65
|
alias: 'GPT-5.4',
|
|
54
66
|
input: ['text', 'image'],
|
|
67
|
+
contextWindow: 1_050_000,
|
|
68
|
+
maxTokens: 128_000,
|
|
55
69
|
},
|
|
56
70
|
{
|
|
57
71
|
canonicalId: 'minimax/minimax-m2.5',
|
|
58
72
|
alias: 'MiniMax M2.5',
|
|
59
73
|
input: ['text'],
|
|
74
|
+
contextWindow: 196_608,
|
|
75
|
+
maxTokens: 196_608,
|
|
60
76
|
},
|
|
61
77
|
{
|
|
62
78
|
canonicalId: 'minimax/minimax-m2.1',
|
|
63
79
|
alias: 'MiniMax M2.1',
|
|
64
80
|
input: ['text'],
|
|
81
|
+
contextWindow: 196_608,
|
|
82
|
+
maxTokens: 196_608,
|
|
65
83
|
},
|
|
66
84
|
{
|
|
67
85
|
canonicalId: 'qwen/qwen3.5-plus-02-15',
|
|
68
86
|
alias: 'Qwen 3.5 Plus',
|
|
69
87
|
input: ['text', 'image'],
|
|
88
|
+
contextWindow: 1_000_000,
|
|
89
|
+
maxTokens: 65_536,
|
|
70
90
|
},
|
|
71
91
|
{
|
|
72
92
|
canonicalId: 'z-ai/glm-5',
|
|
73
93
|
alias: 'GLM-5',
|
|
74
94
|
input: ['text'],
|
|
95
|
+
contextWindow: 202_752,
|
|
96
|
+
maxTokens: 131_072,
|
|
75
97
|
},
|
|
76
98
|
] as const
|
|
77
99
|
|
|
@@ -81,11 +103,17 @@ export const EXTRA_GATEWAY_MODELS: Array<{
|
|
|
81
103
|
name: string
|
|
82
104
|
alias: string
|
|
83
105
|
input: string[]
|
|
106
|
+
contextWindow: number
|
|
107
|
+
maxTokens: number
|
|
108
|
+
api?: string
|
|
84
109
|
}> = PUBLIC_GATEWAY_MODELS.map((model) => ({
|
|
85
110
|
id: model.canonicalId,
|
|
86
111
|
name: model.canonicalId,
|
|
87
112
|
alias: model.alias,
|
|
88
113
|
input: [...model.input],
|
|
114
|
+
contextWindow: model.contextWindow,
|
|
115
|
+
maxTokens: model.maxTokens,
|
|
116
|
+
...('api' in model && model.api ? {api: model.api} : {}),
|
|
89
117
|
}))
|
|
90
118
|
|
|
91
119
|
export function readOpenclawConfig(configPath: string): Record<string, unknown> {
|
|
@@ -100,6 +128,24 @@ export function writeOpenclawConfig(configPath: string, config: Record<string, u
|
|
|
100
128
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
101
129
|
}
|
|
102
130
|
|
|
131
|
+
function buildProviderModel(id: string): {
|
|
132
|
+
id: string
|
|
133
|
+
name: string
|
|
134
|
+
input: string[]
|
|
135
|
+
contextWindow?: number
|
|
136
|
+
maxTokens?: number
|
|
137
|
+
api?: string
|
|
138
|
+
} {
|
|
139
|
+
const extra = EXTRA_GATEWAY_MODELS.find((model) => model.id === id)
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
name: id,
|
|
143
|
+
input: extra ? [...extra.input] : ['text', 'image'],
|
|
144
|
+
...(extra ? {contextWindow: extra.contextWindow, maxTokens: extra.maxTokens} : {}),
|
|
145
|
+
...(extra?.api ? {api: extra.api} : {}),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
103
149
|
export function patchModelGateway(
|
|
104
150
|
config: Record<string, unknown>,
|
|
105
151
|
api: PluginApi,
|
|
@@ -123,7 +169,30 @@ export function patchModelGateway(
|
|
|
123
169
|
// Append any missing extra models
|
|
124
170
|
for (const m of EXTRA_GATEWAY_MODELS) {
|
|
125
171
|
if (!existingIds.has(m.id)) {
|
|
126
|
-
existingModels.push({
|
|
172
|
+
existingModels.push({
|
|
173
|
+
id: m.id,
|
|
174
|
+
name: m.name,
|
|
175
|
+
input: m.input,
|
|
176
|
+
contextWindow: m.contextWindow,
|
|
177
|
+
maxTokens: m.maxTokens,
|
|
178
|
+
...(m.api ? {api: m.api} : {}),
|
|
179
|
+
} as any)
|
|
180
|
+
dirty = true
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Backfill contextWindow/maxTokens and api on existing models that lack them
|
|
185
|
+
const extraLookup = new Map(EXTRA_GATEWAY_MODELS.map((m) => [m.id, m]))
|
|
186
|
+
for (const m of existingModels) {
|
|
187
|
+
const extra = extraLookup.get(m.id)
|
|
188
|
+
if (!extra) continue
|
|
189
|
+
if ((m as any).contextWindow == null || (m as any).maxTokens == null) {
|
|
190
|
+
;(m as any).contextWindow = extra.contextWindow
|
|
191
|
+
;(m as any).maxTokens = extra.maxTokens
|
|
192
|
+
dirty = true
|
|
193
|
+
}
|
|
194
|
+
if (extra.api && (m as any).api !== extra.api) {
|
|
195
|
+
;(m as any).api = extra.api
|
|
127
196
|
dirty = true
|
|
128
197
|
}
|
|
129
198
|
}
|
|
@@ -167,7 +236,7 @@ export function patchModelGateway(
|
|
|
167
236
|
defaults.models = existingAliases
|
|
168
237
|
agents.defaults = defaults
|
|
169
238
|
config.agents = agents
|
|
170
|
-
api.logger.info(
|
|
239
|
+
api.logger.info('Model gateway updated.')
|
|
171
240
|
} else {
|
|
172
241
|
api.logger.info('Model gateway provider already configured.')
|
|
173
242
|
}
|
|
@@ -199,16 +268,14 @@ export function patchModelGateway(
|
|
|
199
268
|
return false
|
|
200
269
|
}
|
|
201
270
|
|
|
202
|
-
const defaultModels = [
|
|
271
|
+
const defaultModels = [buildProviderModel(defaultModel)]
|
|
203
272
|
|
|
204
273
|
const defaultIds = new Set(defaultModels.map((m) => m.id))
|
|
205
274
|
const models = [
|
|
206
275
|
...defaultModels,
|
|
207
|
-
...EXTRA_GATEWAY_MODELS.filter((m) => !defaultIds.has(m.id)).map(({id
|
|
208
|
-
id,
|
|
209
|
-
|
|
210
|
-
input,
|
|
211
|
-
})),
|
|
276
|
+
...EXTRA_GATEWAY_MODELS.filter((m) => !defaultIds.has(m.id)).map(({id}) =>
|
|
277
|
+
buildProviderModel(id),
|
|
278
|
+
),
|
|
212
279
|
]
|
|
213
280
|
|
|
214
281
|
if (!config.models) config.models = {}
|