@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.
@@ -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-role filter reasons — user-role triggers (heartbeat
81
- * prompt, memory flush, etc.) are not directly available on the event.
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
- // agentId and sessionKey live on ctx (PluginHookAgentContext), not event.
130
- const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
131
- const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
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
- api.logger.error(`offline-push: ${err instanceof Error ? err.message : String(err)}`)
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
+ })
@@ -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
+ }
@@ -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
  }
@@ -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> {
@@ -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
  }