@2en/clawly-plugins 1.25.0 → 1.26.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auto-pair.ts +5 -0
- package/config-setup.ts +5 -0
- package/gateway/analytics.ts +83 -0
- package/gateway/clawhub2gateway.ts +15 -0
- package/gateway/cron-delivery.ts +13 -2
- package/gateway/cron-telemetry.test.ts +407 -0
- package/gateway/cron-telemetry.ts +253 -0
- package/gateway/index.ts +33 -0
- package/gateway/offline-push.test.ts +209 -0
- package/gateway/offline-push.ts +107 -12
- package/gateway/otel.test.ts +88 -0
- package/gateway/otel.ts +57 -0
- package/gateway/plugins.ts +3 -0
- package/gateway/posthog.test.ts +73 -0
- package/gateway/posthog.ts +61 -0
- package/gateway/telemetry-config.test.ts +58 -0
- package/gateway/telemetry-config.ts +27 -0
- package/index.ts +7 -1
- package/model-gateway-setup.ts +6 -0
- package/openclaw.plugin.json +6 -1
- package/outbound.ts +67 -32
- package/package.json +7 -1
- package/tools/clawly-send-image.ts +228 -0
- package/tools/index.ts +2 -0
package/gateway/offline-push.ts
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type {PluginApi} from '../types'
|
|
13
|
+
import {markCronPushSent, markCronPushSkipped} from './cron-telemetry'
|
|
13
14
|
import {
|
|
14
15
|
decrementBadgeCount,
|
|
15
16
|
getPushToken,
|
|
16
17
|
incrementBadgeCount,
|
|
17
18
|
sendPushNotification,
|
|
18
19
|
} from './notification'
|
|
20
|
+
import {captureEvent} from './posthog'
|
|
19
21
|
import {isClientOnline} from './presence'
|
|
20
22
|
|
|
21
23
|
/** Strip [[type:value]], [[word]], and MEDIA:xxx placeholders from text (canonical: apps/mobile/lib/stripPlaceholders.ts). */
|
|
@@ -70,6 +72,54 @@ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string
|
|
|
70
72
|
return `${text.slice(0, maxLen)}…`
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
// ── Trigger detection: inspect what caused the agent turn ──
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Walk backwards from the last assistant message to find the preceding
|
|
79
|
+
* user message text. Symmetric to `getLastAssistantText` but for the
|
|
80
|
+
* triggering user turn.
|
|
81
|
+
*/
|
|
82
|
+
export function getTriggeringUserText(messages: unknown): string | null {
|
|
83
|
+
if (!Array.isArray(messages)) return null
|
|
84
|
+
|
|
85
|
+
// Find the last assistant message index, then look backwards for user
|
|
86
|
+
let foundAssistant = false
|
|
87
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
88
|
+
const msg = messages[i]
|
|
89
|
+
if (typeof msg !== 'object' || msg === null) continue
|
|
90
|
+
const role = (msg as any).role
|
|
91
|
+
|
|
92
|
+
if (!foundAssistant) {
|
|
93
|
+
if (role === 'assistant') foundAssistant = true
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Skip system messages between assistant and user
|
|
98
|
+
if (role !== 'user') continue
|
|
99
|
+
|
|
100
|
+
const content = (msg as any).content
|
|
101
|
+
if (typeof content === 'string') return content
|
|
102
|
+
if (Array.isArray(content)) {
|
|
103
|
+
return content
|
|
104
|
+
.filter((p: any) => typeof p === 'object' && p !== null && p.type === 'text')
|
|
105
|
+
.map((p: any) => p.text)
|
|
106
|
+
.join('')
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns true if the agent turn was triggered by a heartbeat prompt.
|
|
116
|
+
* Mirrors the mobile filter: `text.startsWith('Read HEARTBEAT.md')`.
|
|
117
|
+
*/
|
|
118
|
+
export function isHeartbeatTriggered(messages: unknown): boolean {
|
|
119
|
+
const text = getTriggeringUserText(messages)
|
|
120
|
+
return text != null && text.startsWith('Read HEARTBEAT.md')
|
|
121
|
+
}
|
|
122
|
+
|
|
73
123
|
// ── Push-filter: skip push for messages the mobile UI would hide ──
|
|
74
124
|
|
|
75
125
|
/**
|
|
@@ -77,8 +127,8 @@ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string
|
|
|
77
127
|
* UI would filter out via shouldFilterMessage(). If true, skip the push
|
|
78
128
|
* so the user isn't woken for nothing.
|
|
79
129
|
*
|
|
80
|
-
* Only covers assistant-
|
|
81
|
-
*
|
|
130
|
+
* Only covers assistant-text filter reasons. Trigger-level filtering
|
|
131
|
+
* (e.g. heartbeat prompt detection) is handled by `isHeartbeatTriggered`.
|
|
82
132
|
*/
|
|
83
133
|
export function shouldSkipPushForMessage(text: string): string | null {
|
|
84
134
|
const trimmed = text.trim()
|
|
@@ -106,10 +156,25 @@ export function shouldSkipPushForMessage(text: string): string | null {
|
|
|
106
156
|
|
|
107
157
|
export function registerOfflinePush(api: PluginApi) {
|
|
108
158
|
api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
159
|
+
// Read sessionKey early so we can report skip reasons for cron sessions.
|
|
160
|
+
const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
|
|
161
|
+
const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
|
|
162
|
+
const isCron = sessionKey?.startsWith('agent:clawly:cron:') ?? false
|
|
163
|
+
|
|
164
|
+
api.logger.info(
|
|
165
|
+
`offline-push[debug]: agent_end fired sessionKey=${sessionKey ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')} isCron=${isCron}`,
|
|
166
|
+
)
|
|
167
|
+
|
|
109
168
|
try {
|
|
110
169
|
// Skip if client is still connected — they got the response in real-time.
|
|
111
170
|
const online = await isClientOnline()
|
|
112
171
|
if (online) {
|
|
172
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'client online', true)
|
|
173
|
+
captureEvent('push.skipped', {
|
|
174
|
+
reason: 'client_online',
|
|
175
|
+
is_cron: isCron,
|
|
176
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
177
|
+
})
|
|
113
178
|
api.logger.info('offline-push: skipped (client online)')
|
|
114
179
|
return
|
|
115
180
|
}
|
|
@@ -121,22 +186,33 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
121
186
|
if (fullText != null) {
|
|
122
187
|
const reason = shouldSkipPushForMessage(fullText)
|
|
123
188
|
if (reason) {
|
|
189
|
+
if (isCron) markCronPushSkipped(sessionKey!, `filtered: ${reason}`, false)
|
|
190
|
+
captureEvent('push.skipped', {
|
|
191
|
+
reason: `filtered_${reason}`,
|
|
192
|
+
is_cron: isCron,
|
|
193
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
194
|
+
})
|
|
124
195
|
api.logger.info(`offline-push: skipped (filtered: ${reason})`)
|
|
125
196
|
return
|
|
126
197
|
}
|
|
127
198
|
}
|
|
128
199
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
200
|
+
// Heartbeat-triggered turns are silent by default — agent uses
|
|
201
|
+
// clawly_send_app_push tool for genuinely urgent findings.
|
|
202
|
+
if (isHeartbeatTriggered(event.messages)) {
|
|
203
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'heartbeat-triggered', false)
|
|
204
|
+
captureEvent('push.skipped', {
|
|
205
|
+
reason: 'heartbeat_triggered',
|
|
206
|
+
is_cron: isCron,
|
|
207
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
208
|
+
})
|
|
209
|
+
api.logger.info('offline-push: skipped (heartbeat-triggered turn)')
|
|
210
|
+
return
|
|
211
|
+
}
|
|
132
212
|
|
|
133
213
|
// Only send push for the main clawly mobile session and cron sessions —
|
|
134
214
|
// skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
|
|
135
|
-
if (
|
|
136
|
-
sessionKey !== undefined &&
|
|
137
|
-
sessionKey !== 'agent:clawly:main' &&
|
|
138
|
-
!sessionKey.startsWith('agent:clawly:cron:')
|
|
139
|
-
) {
|
|
215
|
+
if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
|
|
140
216
|
api.logger.info(`offline-push: skipped (non-main session: ${sessionKey})`)
|
|
141
217
|
return
|
|
142
218
|
}
|
|
@@ -147,6 +223,12 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
147
223
|
const body = preview || 'Your response is ready'
|
|
148
224
|
|
|
149
225
|
if (!getPushToken()) {
|
|
226
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'no push token', false)
|
|
227
|
+
captureEvent('push.skipped', {
|
|
228
|
+
reason: 'no_push_token',
|
|
229
|
+
is_cron: isCron,
|
|
230
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
231
|
+
})
|
|
150
232
|
api.logger.warn('offline-push: skipped (no push token)')
|
|
151
233
|
return
|
|
152
234
|
}
|
|
@@ -166,14 +248,27 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
166
248
|
)
|
|
167
249
|
|
|
168
250
|
if (sent) {
|
|
251
|
+
if (isCron) markCronPushSent(sessionKey!)
|
|
252
|
+
captureEvent('push.sent', {
|
|
253
|
+
is_cron: isCron,
|
|
254
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
255
|
+
})
|
|
169
256
|
api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
|
|
170
257
|
} else {
|
|
171
258
|
decrementBadgeCount()
|
|
259
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'send failed', false)
|
|
260
|
+
captureEvent('push.skipped', {
|
|
261
|
+
reason: 'send_failed',
|
|
262
|
+
is_cron: isCron,
|
|
263
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
264
|
+
})
|
|
172
265
|
}
|
|
173
266
|
} catch (err) {
|
|
174
|
-
|
|
267
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
268
|
+
if (isCron) markCronPushSkipped(sessionKey!, msg)
|
|
269
|
+
api.logger.error(`offline-push: ${msg}`)
|
|
175
270
|
}
|
|
176
271
|
})
|
|
177
272
|
|
|
178
|
-
api.logger.info('offline-push: registered agent_end hook')
|
|
273
|
+
api.logger.info('offline-push: registered agent_end hook (debug-instrumented)')
|
|
179
274
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
let exporterOptions: Record<string, unknown> | null = null
|
|
4
|
+
let loggerName: string | null = null
|
|
5
|
+
let shutdownCount = 0
|
|
6
|
+
const fakeLogger = {emit: () => {}}
|
|
7
|
+
|
|
8
|
+
mock.module('@opentelemetry/exporter-logs-otlp-http', () => ({
|
|
9
|
+
OTLPLogExporter: class OTLPLogExporter {
|
|
10
|
+
constructor(opts: Record<string, unknown>) {
|
|
11
|
+
exporterOptions = opts
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
mock.module('@opentelemetry/resources', () => ({
|
|
17
|
+
Resource: class Resource {
|
|
18
|
+
constructor(_attrs: Record<string, unknown>) {}
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
mock.module('@opentelemetry/sdk-logs', () => ({
|
|
23
|
+
BatchLogRecordProcessor: class BatchLogRecordProcessor {
|
|
24
|
+
constructor(_exporter: unknown) {}
|
|
25
|
+
},
|
|
26
|
+
LoggerProvider: class LoggerProvider {
|
|
27
|
+
addLogRecordProcessor(_processor: unknown) {}
|
|
28
|
+
getLogger(name: string) {
|
|
29
|
+
loggerName = name
|
|
30
|
+
return fakeLogger
|
|
31
|
+
}
|
|
32
|
+
async shutdown() {
|
|
33
|
+
shutdownCount++
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const {getOtelLogger, initOtel, shutdownOtel} = await import('./otel')
|
|
39
|
+
|
|
40
|
+
describe('initOtel', () => {
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
await shutdownOtel()
|
|
43
|
+
exporterOptions = null
|
|
44
|
+
loggerName = null
|
|
45
|
+
shutdownCount = 0
|
|
46
|
+
delete process.env.PLUGIN_OTEL_TOKEN
|
|
47
|
+
delete process.env.PLUGIN_OTEL_DATASET
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await shutdownOtel()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('prefers pluginConfig over legacy env vars', () => {
|
|
55
|
+
process.env.PLUGIN_OTEL_TOKEN = 'legacy-token'
|
|
56
|
+
process.env.PLUGIN_OTEL_DATASET = 'legacy-dataset'
|
|
57
|
+
|
|
58
|
+
const enabled = initOtel({
|
|
59
|
+
otelToken: 'cfg-token',
|
|
60
|
+
otelDataset: 'cfg-dataset',
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(enabled).toBe(true)
|
|
64
|
+
expect(exporterOptions).toEqual({
|
|
65
|
+
url: 'https://api.axiom.co',
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: 'Bearer cfg-token',
|
|
68
|
+
'X-Axiom-Dataset': 'cfg-dataset',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
expect(getOtelLogger()).toBeTruthy()
|
|
72
|
+
expect(loggerName).toBe('cron-telemetry')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('falls back to legacy env vars', () => {
|
|
76
|
+
process.env.PLUGIN_OTEL_TOKEN = 'legacy-token'
|
|
77
|
+
process.env.PLUGIN_OTEL_DATASET = 'legacy-dataset'
|
|
78
|
+
|
|
79
|
+
expect(initOtel()).toBe(true)
|
|
80
|
+
expect((exporterOptions?.headers as Record<string, string>)['X-Axiom-Dataset']).toBe(
|
|
81
|
+
'legacy-dataset',
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('returns false when no telemetry config is present', () => {
|
|
86
|
+
expect(initOtel()).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
package/gateway/otel.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry Logs provider for plugin telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Reads telemetry config provisioned by Fleet via pluginConfig:
|
|
5
|
+
*
|
|
6
|
+
* otelToken=<Axiom API token>
|
|
7
|
+
* otelDataset=<Axiom dataset name>
|
|
8
|
+
*
|
|
9
|
+
* Falls back to legacy PLUGIN_OTEL_* env vars for backward compatibility and
|
|
10
|
+
* manual debug sessions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {Logger} from '@opentelemetry/api-logs'
|
|
14
|
+
import {OTLPLogExporter} from '@opentelemetry/exporter-logs-otlp-http'
|
|
15
|
+
import {Resource} from '@opentelemetry/resources'
|
|
16
|
+
import {BatchLogRecordProcessor, LoggerProvider} from '@opentelemetry/sdk-logs'
|
|
17
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
18
|
+
|
|
19
|
+
let provider: LoggerProvider | null = null
|
|
20
|
+
let logger: Logger | null = null
|
|
21
|
+
|
|
22
|
+
export function initOtel(
|
|
23
|
+
pluginConfig?: Record<string, unknown>,
|
|
24
|
+
serviceName = 'clawly-plugins',
|
|
25
|
+
): boolean {
|
|
26
|
+
const cfg = readTelemetryPluginConfig(pluginConfig)
|
|
27
|
+
const token = cfg.otelToken ?? process.env.PLUGIN_OTEL_TOKEN
|
|
28
|
+
const dataset = cfg.otelDataset ?? process.env.PLUGIN_OTEL_DATASET
|
|
29
|
+
if (!token || !dataset) return false
|
|
30
|
+
if (provider) return true
|
|
31
|
+
|
|
32
|
+
const resource = new Resource({'service.name': serviceName})
|
|
33
|
+
const exporter = new OTLPLogExporter({
|
|
34
|
+
url: 'https://api.axiom.co',
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
'X-Axiom-Dataset': dataset,
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
provider = new LoggerProvider({resource})
|
|
42
|
+
provider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter))
|
|
43
|
+
logger = provider.getLogger('cron-telemetry')
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getOtelLogger(): Logger | null {
|
|
48
|
+
return logger
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function shutdownOtel(): Promise<void> {
|
|
52
|
+
if (provider) {
|
|
53
|
+
await provider.shutdown()
|
|
54
|
+
provider = null
|
|
55
|
+
logger = null
|
|
56
|
+
}
|
|
57
|
+
}
|
package/gateway/plugins.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {$} from 'zx'
|
|
|
12
12
|
|
|
13
13
|
import type {PluginApi} from '../types'
|
|
14
14
|
import {LruCache} from '../lib/lruCache'
|
|
15
|
+
import {captureEvent} from './posthog'
|
|
15
16
|
import {isUpdateAvailable} from '../lib/semver'
|
|
16
17
|
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
17
18
|
|
|
@@ -311,9 +312,11 @@ export function registerPlugins(api: PluginApi) {
|
|
|
311
312
|
}
|
|
312
313
|
}
|
|
313
314
|
|
|
315
|
+
captureEvent('plugin.updated', {plugin_id: pluginId, strategy, success: true, restarted})
|
|
314
316
|
respond(true, {ok: true, strategy, output, restarted})
|
|
315
317
|
} catch (err) {
|
|
316
318
|
const msg = err instanceof Error ? err.message : String(err)
|
|
319
|
+
captureEvent('plugin.updated', {plugin_id: pluginId, strategy, success: false, error: msg})
|
|
317
320
|
api.logger.error(`plugins: update failed — ${msg}`)
|
|
318
321
|
respond(false, {ok: false, strategy, error: msg})
|
|
319
322
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
let constructorArgs: {apiKey: string; host: string} | null = null
|
|
4
|
+
let captures: Array<{distinctId: string; event: string; properties?: Record<string, unknown>}> = []
|
|
5
|
+
let shutdownCount = 0
|
|
6
|
+
|
|
7
|
+
mock.module('posthog-node', () => ({
|
|
8
|
+
PostHog: class PostHog {
|
|
9
|
+
constructor(apiKey: string, opts: {host: string}) {
|
|
10
|
+
constructorArgs = {apiKey, host: opts.host}
|
|
11
|
+
}
|
|
12
|
+
capture(payload: {distinctId: string; event: string; properties?: Record<string, unknown>}) {
|
|
13
|
+
captures.push(payload)
|
|
14
|
+
}
|
|
15
|
+
async shutdown() {
|
|
16
|
+
shutdownCount++
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const {captureEvent, initPostHog, shutdownPostHog} = await import('./posthog')
|
|
22
|
+
|
|
23
|
+
describe('initPostHog', () => {
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
await shutdownPostHog()
|
|
26
|
+
constructorArgs = null
|
|
27
|
+
captures = []
|
|
28
|
+
shutdownCount = 0
|
|
29
|
+
delete process.env.PLUGIN_POSTHOG_API_KEY
|
|
30
|
+
delete process.env.PLUGIN_POSTHOG_HOST
|
|
31
|
+
delete process.env.INSTANCE_ID
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await shutdownPostHog()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('prefers pluginConfig over legacy env vars', () => {
|
|
39
|
+
process.env.PLUGIN_POSTHOG_API_KEY = 'legacy-key'
|
|
40
|
+
process.env.PLUGIN_POSTHOG_HOST = 'https://legacy.posthog.com'
|
|
41
|
+
process.env.INSTANCE_ID = 'legacy-inst'
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
initPostHog({
|
|
45
|
+
posthogApiKey: 'cfg-key',
|
|
46
|
+
posthogHost: 'https://cfg.posthog.com',
|
|
47
|
+
instanceId: 'inst-1',
|
|
48
|
+
}),
|
|
49
|
+
).toBe(true)
|
|
50
|
+
|
|
51
|
+
expect(constructorArgs).toEqual({
|
|
52
|
+
apiKey: 'cfg-key',
|
|
53
|
+
host: 'https://cfg.posthog.com',
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('falls back to legacy env vars', () => {
|
|
58
|
+
process.env.PLUGIN_POSTHOG_API_KEY = 'legacy-key'
|
|
59
|
+
process.env.INSTANCE_ID = 'legacy-inst'
|
|
60
|
+
|
|
61
|
+
expect(initPostHog()).toBe(true)
|
|
62
|
+
expect(constructorArgs).toEqual({
|
|
63
|
+
apiKey: 'legacy-key',
|
|
64
|
+
host: 'https://us.i.posthog.com',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('returns false when api key is missing', () => {
|
|
69
|
+
expect(initPostHog({instanceId: 'inst-1'})).toBe(false)
|
|
70
|
+
captureEvent('cron.deleted')
|
|
71
|
+
expect(captures).toHaveLength(0)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog analytics provider for plugin telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Reads telemetry config provisioned by Fleet via pluginConfig:
|
|
5
|
+
*
|
|
6
|
+
* posthogApiKey=<PostHog project API key>
|
|
7
|
+
* posthogHost=<PostHog ingest URL> (optional, defaults to https://us.i.posthog.com)
|
|
8
|
+
* instanceId=<distinct_id>
|
|
9
|
+
*
|
|
10
|
+
* Falls back to legacy PLUGIN_POSTHOG_* / INSTANCE_ID env vars for backward
|
|
11
|
+
* compatibility and manual debug sessions.
|
|
12
|
+
*
|
|
13
|
+
* The distinct_id for all events is the provisioned instanceId (one sprite = one user).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {PostHog} from 'posthog-node'
|
|
17
|
+
import pkg from '../package.json'
|
|
18
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
19
|
+
|
|
20
|
+
let client: PostHog | null = null
|
|
21
|
+
let distinctId: string | null = null
|
|
22
|
+
let superProperties: Record<string, unknown> = {}
|
|
23
|
+
|
|
24
|
+
export function initPostHog(pluginConfig?: Record<string, unknown>): boolean {
|
|
25
|
+
const cfg = readTelemetryPluginConfig(pluginConfig)
|
|
26
|
+
const apiKey = cfg.posthogApiKey ?? process.env.PLUGIN_POSTHOG_API_KEY
|
|
27
|
+
if (!apiKey) return false
|
|
28
|
+
if (client) return true
|
|
29
|
+
|
|
30
|
+
const host = cfg.posthogHost ?? process.env.PLUGIN_POSTHOG_HOST ?? 'https://us.i.posthog.com'
|
|
31
|
+
distinctId = cfg.instanceId ?? process.env.INSTANCE_ID ?? null
|
|
32
|
+
if (!distinctId) return false
|
|
33
|
+
|
|
34
|
+
client = new PostHog(apiKey, {host})
|
|
35
|
+
superProperties = {plugin_version: pkg.version}
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set the OpenClaw version (read from api.config after gateway init). */
|
|
40
|
+
export function setOpenClawVersion(version: string): void {
|
|
41
|
+
superProperties.openclaw_version = version
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EVENT_PREFIX = 'plugin.'
|
|
45
|
+
|
|
46
|
+
export function captureEvent(event: string, properties?: Record<string, unknown>): void {
|
|
47
|
+
if (!client || !distinctId) return
|
|
48
|
+
client.capture({
|
|
49
|
+
distinctId,
|
|
50
|
+
event: `${EVENT_PREFIX}${event}`,
|
|
51
|
+
properties: {...superProperties, ...properties},
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function shutdownPostHog(): Promise<void> {
|
|
56
|
+
if (client) {
|
|
57
|
+
await client.shutdown()
|
|
58
|
+
client = null
|
|
59
|
+
distinctId = null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
3
|
+
|
|
4
|
+
describe('readTelemetryPluginConfig', () => {
|
|
5
|
+
test('reads telemetry fields from pluginConfig', () => {
|
|
6
|
+
expect(
|
|
7
|
+
readTelemetryPluginConfig({
|
|
8
|
+
instanceId: 'inst-1',
|
|
9
|
+
otelToken: 'otel-token',
|
|
10
|
+
otelDataset: 'clawly-otel-logs-dev',
|
|
11
|
+
posthogApiKey: 'ph-key',
|
|
12
|
+
posthogHost: 'https://us.i.posthog.com',
|
|
13
|
+
}),
|
|
14
|
+
).toEqual({
|
|
15
|
+
instanceId: 'inst-1',
|
|
16
|
+
otelToken: 'otel-token',
|
|
17
|
+
otelDataset: 'clawly-otel-logs-dev',
|
|
18
|
+
posthogApiKey: 'ph-key',
|
|
19
|
+
posthogHost: 'https://us.i.posthog.com',
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('trims surrounding whitespace from string values', () => {
|
|
24
|
+
expect(
|
|
25
|
+
readTelemetryPluginConfig({
|
|
26
|
+
instanceId: ' inst-1 ',
|
|
27
|
+
otelToken: ' otel-token ',
|
|
28
|
+
otelDataset: ' clawly-otel-logs-dev ',
|
|
29
|
+
posthogApiKey: ' ph-key ',
|
|
30
|
+
posthogHost: ' https://us.i.posthog.com ',
|
|
31
|
+
}),
|
|
32
|
+
).toEqual({
|
|
33
|
+
instanceId: 'inst-1',
|
|
34
|
+
otelToken: 'otel-token',
|
|
35
|
+
otelDataset: 'clawly-otel-logs-dev',
|
|
36
|
+
posthogApiKey: 'ph-key',
|
|
37
|
+
posthogHost: 'https://us.i.posthog.com',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('drops empty and non-string values', () => {
|
|
42
|
+
expect(
|
|
43
|
+
readTelemetryPluginConfig({
|
|
44
|
+
instanceId: ' ',
|
|
45
|
+
otelToken: null,
|
|
46
|
+
otelDataset: 1,
|
|
47
|
+
posthogApiKey: '',
|
|
48
|
+
posthogHost: undefined,
|
|
49
|
+
}),
|
|
50
|
+
).toEqual({
|
|
51
|
+
instanceId: undefined,
|
|
52
|
+
otelToken: undefined,
|
|
53
|
+
otelDataset: undefined,
|
|
54
|
+
posthogApiKey: undefined,
|
|
55
|
+
posthogHost: undefined,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TelemetryPluginConfig {
|
|
2
|
+
instanceId?: string
|
|
3
|
+
otelToken?: string
|
|
4
|
+
otelDataset?: string
|
|
5
|
+
posthogApiKey?: string
|
|
6
|
+
posthogHost?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readString(value: unknown): string | undefined {
|
|
10
|
+
if (typeof value !== 'string') return undefined
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readTelemetryPluginConfig(
|
|
16
|
+
pluginConfig?: Record<string, unknown> | null,
|
|
17
|
+
): TelemetryPluginConfig {
|
|
18
|
+
const cfg = pluginConfig ?? {}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
instanceId: readString(cfg.instanceId),
|
|
22
|
+
otelToken: readString(cfg.otelToken),
|
|
23
|
+
otelDataset: readString(cfg.otelDataset),
|
|
24
|
+
posthogApiKey: readString(cfg.posthogApiKey),
|
|
25
|
+
posthogHost: readString(cfg.posthogHost),
|
|
26
|
+
}
|
|
27
|
+
}
|
package/index.ts
CHANGED
|
@@ -16,16 +16,22 @@
|
|
|
16
16
|
* Agent tools:
|
|
17
17
|
* - clawly_is_user_online — check if user's device is connected
|
|
18
18
|
* - clawly_send_app_push — send a push notification to user's device
|
|
19
|
+
* - clawly_send_image — send an image to the user (URL download or local file)
|
|
19
20
|
* - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
|
|
20
21
|
*
|
|
21
22
|
* Commands:
|
|
22
23
|
* - /clawly_echo — echo text back without LLM
|
|
23
24
|
*
|
|
25
|
+
* HTTP routes:
|
|
26
|
+
* - GET /clawly/file/outbound — serve files (hash lookup first, then direct path with allowlist)
|
|
27
|
+
*
|
|
24
28
|
* Hooks:
|
|
25
29
|
* - before_message_write — restores original /skill command in user messages (undoes gateway rewrite)
|
|
26
30
|
* - tool_result_persist — copies TTS audio to persistent outbound directory
|
|
27
31
|
* - before_tool_call — enforces delivery fields on cron.create
|
|
28
32
|
* - agent_end — sends push notification when client is offline; injects cron results into main session
|
|
33
|
+
* - after_tool_call — cron telemetry: captures cron job creation/deletion
|
|
34
|
+
* - agent_end (pri 100) — cron telemetry: captures cron execution outcomes with delivery/push flags
|
|
29
35
|
* - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
|
|
30
36
|
* - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
|
|
31
37
|
*/
|
|
@@ -76,6 +82,6 @@ export default {
|
|
|
76
82
|
registerCalendar(api, gw)
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
api.logger.info(`Loaded ${api.id} plugin
|
|
85
|
+
api.logger.info(`Loaded ${api.id} plugin. (debug-instrumented build)`)
|
|
80
86
|
},
|
|
81
87
|
}
|
package/model-gateway-setup.ts
CHANGED
|
@@ -65,6 +65,12 @@ export const EXTRA_GATEWAY_MODELS: Array<{
|
|
|
65
65
|
alias: 'Qwen3.5 Plus',
|
|
66
66
|
input: ['text', 'image'],
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
id: 'openai/gpt-5.4',
|
|
70
|
+
name: 'openai/gpt-5.4',
|
|
71
|
+
alias: 'GPT-5.4',
|
|
72
|
+
input: ['text', 'image'],
|
|
73
|
+
},
|
|
68
74
|
]
|
|
69
75
|
|
|
70
76
|
export function readOpenclawConfig(configPath: string): Record<string, unknown> {
|
package/openclaw.plugin.json
CHANGED
|
@@ -50,13 +50,18 @@
|
|
|
50
50
|
"skillGatewayToken": { "type": "string" },
|
|
51
51
|
"modelGatewayBaseUrl": { "type": "string" },
|
|
52
52
|
"modelGatewayToken": { "type": "string" },
|
|
53
|
+
"instanceId": { "type": "string" },
|
|
53
54
|
"agentId": { "type": "string" },
|
|
54
55
|
"agentName": { "type": "string" },
|
|
55
56
|
"workspaceDir": { "type": "string" },
|
|
56
57
|
"defaultModel": { "type": "string" },
|
|
57
58
|
"defaultImageModel": { "type": "string" },
|
|
58
59
|
"elevenlabsApiKey": { "type": "string" },
|
|
59
|
-
"elevenlabsVoiceId": { "type": "string" }
|
|
60
|
+
"elevenlabsVoiceId": { "type": "string" },
|
|
61
|
+
"otelToken": { "type": "string" },
|
|
62
|
+
"otelDataset": { "type": "string" },
|
|
63
|
+
"posthogApiKey": { "type": "string" },
|
|
64
|
+
"posthogHost": { "type": "string" }
|
|
60
65
|
},
|
|
61
66
|
"required": []
|
|
62
67
|
}
|