@2en/clawly-plugins 1.26.0-beta.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/auto-pair.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {captureEvent} from './gateway/posthog'
1
2
  import type {PluginApi} from './index'
2
3
 
3
4
  const AUTO_APPROVE_CLIENT_IDS = new Set(['openclaw-ios'])
@@ -49,6 +50,10 @@ export function registerAutoPair(api: PluginApi) {
49
50
  if (req.clientId && AUTO_APPROVE_CLIENT_IDS.has(req.clientId)) {
50
51
  const result = await sdk.approveDevicePairing(req.requestId)
51
52
  if (result) {
53
+ captureEvent('device.paired', {
54
+ client_id: req.clientId,
55
+ platform: result.device.platform ?? 'unknown',
56
+ })
52
57
  api.logger.info(
53
58
  `auto-pair: approved device=${result.device.deviceId} ` +
54
59
  `name=${result.device.displayName ?? 'unknown'} ` +
package/config-setup.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  /**
2
- * On plugin init, patches openclaw.json to set all business config that was
3
- * previously written by provision's buildConfig().
2
+ * Reconciles runtime config owned by clawly-plugins after provision has already
3
+ * established the bootstrap baseline (plugin entry, pluginConfig inputs,
4
+ * workspace files, install metadata).
4
5
  *
5
- * Domain helpers read the pluginConfig, apply enforce or set-if-missing
6
- * semantics, and write once if anything changed. This runs before
7
- * setupModelGateway so agents.defaults.model is available for the model
8
- * provider setup.
6
+ * This file intentionally owns two narrower responsibilities:
7
+ * - repair legacy state damaged by older provision flows
8
+ * - reconcile runtime-facing config derived from pluginConfig
9
9
  *
10
- * Backward compatibility: old sprites have pluginConfig with only 4 fields
11
- * (skill/model gateway credentials). Helpers check for the new fields and
12
- * skip gracefully when absent.
10
+ * It does not try to redefine provision's startup contract. Anything that must
11
+ * exist before the first gateway boot should remain provision-owned.
12
+ *
13
+ * Backward compatibility: old sprites may have only the original gateway
14
+ * credentials in pluginConfig. Helpers check for newer fields and skip
15
+ * gracefully when absent.
13
16
  */
14
17
 
18
+ import fs from 'node:fs'
15
19
  import path from 'node:path'
16
20
 
17
21
  import type {PluginApi} from './index'
@@ -23,6 +27,7 @@ import {
23
27
  } from './model-gateway-setup'
24
28
 
25
29
  export interface ConfigPluginConfig {
30
+ instanceId?: string
26
31
  agentId?: string
27
32
  agentName?: string
28
33
  workspaceDir?: string
@@ -32,6 +37,10 @@ export interface ConfigPluginConfig {
32
37
  elevenlabsVoiceId?: string
33
38
  modelGatewayBaseUrl?: string
34
39
  modelGatewayToken?: string
40
+ otelToken?: string
41
+ otelDataset?: string
42
+ posthogApiKey?: string
43
+ posthogHost?: string
35
44
  }
36
45
 
37
46
  function toPC(api: PluginApi): ConfigPluginConfig {
@@ -372,8 +381,100 @@ export function patchSession(config: Record<string, unknown>): boolean {
372
381
  return dirty
373
382
  }
374
383
 
384
+ const PLUGIN_ID = 'clawly-plugins'
385
+ const NPM_PKG_NAME = '@2en/clawly-plugins'
386
+
387
+ function asObj(value: unknown): Record<string, unknown> {
388
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
389
+ ? (value as Record<string, unknown>)
390
+ : {}
391
+ }
392
+
393
+ /**
394
+ * Self-healing: reconstruct missing `plugins.installs.clawly-plugins` record.
395
+ * Older provisioned sprites had this record destroyed by a full-overwrite bug
396
+ * in the provision write-plugins-config step. Without this record,
397
+ * `openclaw plugins update` cannot function.
398
+ */
399
+ export function patchInstallRecord(config: Record<string, unknown>, stateDir: string): boolean {
400
+ const plugins = asObj(config.plugins)
401
+ const installs = asObj(plugins.installs)
402
+
403
+ // Already has a valid install record — nothing to do
404
+ if (
405
+ installs[PLUGIN_ID] &&
406
+ typeof installs[PLUGIN_ID] === 'object' &&
407
+ (installs[PLUGIN_ID] as Record<string, unknown>).source &&
408
+ (installs[PLUGIN_ID] as Record<string, unknown>).spec
409
+ )
410
+ return false
411
+
412
+ // Read version from installed plugin's package.json
413
+ const installPath = path.join(stateDir, 'extensions', PLUGIN_ID)
414
+ const pkgPath = path.join(installPath, 'package.json')
415
+ let rawPkg: string
416
+ try {
417
+ rawPkg = fs.readFileSync(pkgPath, 'utf-8')
418
+ } catch {
419
+ // Plugin not installed on disk — skip
420
+ return false
421
+ }
422
+ let version: string | undefined
423
+ try {
424
+ const pkg = JSON.parse(rawPkg)
425
+ if (typeof pkg.version === 'string') version = pkg.version
426
+ } catch {
427
+ // package.json is malformed — proceed without version
428
+ }
429
+ let installedAt = new Date().toISOString()
430
+ try {
431
+ installedAt = fs.statSync(pkgPath).mtime.toISOString()
432
+ } catch {
433
+ // Fall back to repair time if file metadata is unavailable.
434
+ }
435
+
436
+ installs[PLUGIN_ID] = {
437
+ source: 'npm',
438
+ spec: version ? `${NPM_PKG_NAME}@${version}` : NPM_PKG_NAME,
439
+ installPath,
440
+ version,
441
+ installedAt,
442
+ }
443
+ plugins.installs = installs
444
+ config.plugins = plugins
445
+ return true
446
+ }
447
+
448
+ function repairLegacyProvisionState(
449
+ api: PluginApi,
450
+ config: Record<string, unknown>,
451
+ stateDir: string,
452
+ ) {
453
+ const installRecordPatched = patchInstallRecord(config, stateDir)
454
+ if (installRecordPatched) {
455
+ api.logger.warn('plugins.installs.clawly-plugins was missing — self-healed from disk.')
456
+ }
457
+ return installRecordPatched
458
+ }
459
+
460
+ function reconcileRuntimeConfig(
461
+ api: PluginApi,
462
+ config: Record<string, unknown>,
463
+ pc: ConfigPluginConfig,
464
+ ): boolean {
465
+ let dirty = false
466
+ dirty = patchAgent(config, pc) || dirty
467
+ dirty = patchGateway(config) || dirty
468
+ dirty = patchBrowser(config) || dirty
469
+ dirty = patchSession(config) || dirty
470
+ dirty = patchTts(config, pc) || dirty
471
+ dirty = patchWebSearch(config, pc) || dirty
472
+ dirty = patchModelGateway(config, api) || dirty
473
+ return dirty
474
+ }
475
+
375
476
  // ---------------------------------------------------------------------------
376
- // Entry point — single read, patch all, single write
477
+ // Entry point — single read, targeted reconcile, single write
377
478
  // ---------------------------------------------------------------------------
378
479
 
379
480
  export function setupConfig(api: PluginApi): void {
@@ -388,13 +489,8 @@ export function setupConfig(api: PluginApi): void {
388
489
  const pc = toPC(api)
389
490
 
390
491
  let dirty = false
391
- dirty = patchAgent(config, pc) || dirty
392
- dirty = patchGateway(config) || dirty
393
- dirty = patchBrowser(config) || dirty
394
- dirty = patchSession(config) || dirty
395
- dirty = patchTts(config, pc) || dirty
396
- dirty = patchWebSearch(config, pc) || dirty
397
- dirty = patchModelGateway(config, api) || dirty
492
+ dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
493
+ dirty = reconcileRuntimeConfig(api, config, pc) || dirty
398
494
 
399
495
  if (!dirty) {
400
496
  api.logger.info('Config setup: no changes needed.')
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Broad PostHog analytics — captures agent turns, LLM usage, and tool calls.
3
+ *
4
+ * Hooks:
5
+ * - agent_end — tracks every agent turn completion (trigger, duration, success)
6
+ * - llm_output — tracks LLM token usage per response (provider, model, tokens)
7
+ * - after_tool_call — tracks tool usage (tool name, duration, success)
8
+ *
9
+ * Cron-specific lifecycle events (created/executed/deleted) are handled by
10
+ * cron-telemetry.ts and are NOT duplicated here.
11
+ *
12
+ * Other modules (offline-push, auto-pair, plugins, clawhub2gateway) call
13
+ * captureEvent() directly for their domain-specific events.
14
+ */
15
+
16
+ import type {PluginApi} from '../types'
17
+ import {captureEvent} from './posthog'
18
+
19
+ export function registerAnalytics(api: PluginApi) {
20
+ // ── agent_end: track every agent turn ───────────────────────────
21
+ api.on('agent_end', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
22
+ const trigger = typeof ctx?.trigger === 'string' ? ctx.trigger : 'unknown'
23
+ const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
24
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
25
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
26
+ const success = event.error == null
27
+ const durationMs = typeof event.durationMs === 'number' ? event.durationMs : undefined
28
+
29
+ captureEvent('agent.turn_completed', {
30
+ trigger,
31
+ ...(agentId ? {agent_id: agentId} : {}),
32
+ ...(sessionKey ? {session_key: sessionKey} : {}),
33
+ ...(sessionId ? {session_id: sessionId} : {}),
34
+ success,
35
+ ...(durationMs != null ? {duration_ms: durationMs} : {}),
36
+ ...(typeof event.error === 'string' ? {error: event.error} : {}),
37
+ })
38
+ })
39
+
40
+ // ── llm_output: track token usage ──────────────────────────────
41
+ api.on('llm_output', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
42
+ const provider = typeof event.provider === 'string' ? event.provider : undefined
43
+ const model = typeof event.model === 'string' ? event.model : undefined
44
+ const usage = event.usage as Record<string, unknown> | undefined
45
+
46
+ if (!usage) return
47
+
48
+ const trigger = typeof ctx?.trigger === 'string' ? ctx.trigger : undefined
49
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
50
+
51
+ captureEvent('llm.response', {
52
+ ...(provider ? {provider} : {}),
53
+ ...(model ? {model} : {}),
54
+ ...(trigger ? {trigger} : {}),
55
+ ...(sessionId ? {session_id: sessionId} : {}),
56
+ ...(typeof usage.input === 'number' ? {tokens_input: usage.input} : {}),
57
+ ...(typeof usage.output === 'number' ? {tokens_output: usage.output} : {}),
58
+ ...(typeof usage.cacheRead === 'number' ? {tokens_cache_read: usage.cacheRead} : {}),
59
+ ...(typeof usage.cacheWrite === 'number' ? {tokens_cache_write: usage.cacheWrite} : {}),
60
+ ...(typeof usage.total === 'number' ? {tokens_total: usage.total} : {}),
61
+ })
62
+ })
63
+
64
+ // ── after_tool_call: track tool usage ──────────────────────────
65
+ api.on('after_tool_call', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
66
+ const toolName = typeof event.toolName === 'string' ? event.toolName : undefined
67
+ if (!toolName) return
68
+
69
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
70
+ const success = event.error == null
71
+ const durationMs = typeof event.durationMs === 'number' ? event.durationMs : undefined
72
+
73
+ captureEvent('tool.called', {
74
+ tool_name: toolName,
75
+ success,
76
+ ...(sessionId ? {session_id: sessionId} : {}),
77
+ ...(durationMs != null ? {duration_ms: durationMs} : {}),
78
+ ...(typeof event.error === 'string' ? {error: event.error} : {}),
79
+ })
80
+ })
81
+
82
+ api.logger.info('analytics: registered agent_end + llm_output + after_tool_call hooks')
83
+ }
@@ -20,6 +20,7 @@ import fs from 'node:fs/promises'
20
20
  import path from 'node:path'
21
21
 
22
22
  import type {PluginApi} from '../types'
23
+ import {captureEvent} from './posthog'
23
24
 
24
25
  type JsonSchema = Record<string, unknown>
25
26
 
@@ -182,13 +183,21 @@ export function registerClawhub2gateway(api: PluginApi) {
182
183
  env,
183
184
  })
184
185
  const output = joinOutput(res.stdout, res.stderr)
186
+ const action = rpc.method.split('.').pop() ?? rpc.method
185
187
  if (res.code && res.code !== 0) {
188
+ captureEvent('clawhub.action', {action, success: false})
186
189
  respond(false, undefined, {
187
190
  code: 'command_failed',
188
191
  message: output || `clawhub exited with code ${res.code}`,
189
192
  })
190
193
  return
191
194
  }
195
+ captureEvent('clawhub.action', {
196
+ action,
197
+ success: true,
198
+ ...(params.slug ? {slug: params.slug} : {}),
199
+ ...(params.query ? {query: params.query} : {}),
200
+ })
192
201
  respond(true, {
193
202
  ok: true,
194
203
  output,
@@ -201,6 +210,12 @@ export function registerClawhub2gateway(api: PluginApi) {
201
210
  cwd: built.cwd,
202
211
  })
203
212
  } catch (err) {
213
+ const action = rpc.method.split('.').pop() ?? rpc.method
214
+ captureEvent('clawhub.action', {
215
+ action,
216
+ success: false,
217
+ error: err instanceof Error ? err.message : String(err),
218
+ })
204
219
  respond(false, undefined, {
205
220
  code: 'error',
206
221
  message: err instanceof Error ? err.message : String(err),
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type {PluginApi} from '../types'
18
+ import {markCronDelivered, markCronDeliverySkipped} from './cron-telemetry'
18
19
  import {injectAssistantMessage, resolveSessionKey} from './inject'
19
20
  import {shouldSkipPushForMessage} from './offline-push'
20
21
 
@@ -51,6 +52,10 @@ export function registerCronDelivery(api: PluginApi) {
51
52
  const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
52
53
  const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
53
54
 
55
+ api.logger.info(
56
+ `cron-delivery[debug]: agent_end fired sessionKey=${sessionKey ?? 'undefined'} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
57
+ )
58
+
54
59
  // Only fire for cron sessions
55
60
  if (!sessionKey?.startsWith('agent:clawly:cron:')) return
56
61
 
@@ -59,6 +64,7 @@ export function registerCronDelivery(api: PluginApi) {
59
64
  const text = getRawLastAssistantText(event.messages)
60
65
  if (text == null) {
61
66
  api.logger.info('cron-delivery: skipped (no assistant message)')
67
+ if (sessionKey) markCronDeliverySkipped(sessionKey, 'no assistant message')
62
68
  return
63
69
  }
64
70
 
@@ -66,12 +72,14 @@ export function registerCronDelivery(api: PluginApi) {
66
72
  const reason = shouldSkipPushForMessage(text)
67
73
  if (reason) {
68
74
  api.logger.info(`cron-delivery: skipped (filtered: ${reason})`)
75
+ if (sessionKey) markCronDeliverySkipped(sessionKey, `filtered: ${reason}`)
69
76
  return
70
77
  }
71
78
 
72
79
  // Resolve main session key for this agent
73
80
  if (!agentId) {
74
81
  api.logger.error('cron-delivery: skipped (no agentId on ctx)')
82
+ if (sessionKey) markCronDeliverySkipped(sessionKey, 'no agentId')
75
83
  return
76
84
  }
77
85
 
@@ -86,13 +94,16 @@ export function registerCronDelivery(api: PluginApi) {
86
94
  api,
87
95
  )
88
96
 
97
+ if (sessionKey) markCronDelivered(sessionKey)
89
98
  api.logger.info(
90
99
  `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId})`,
91
100
  )
92
101
  } catch (err) {
93
- api.logger.error(`cron-delivery: ${err instanceof Error ? err.message : String(err)}`)
102
+ const msg = err instanceof Error ? err.message : String(err)
103
+ if (sessionKey) markCronDeliverySkipped(sessionKey, msg)
104
+ api.logger.error(`cron-delivery: ${msg}`)
94
105
  }
95
106
  })
96
107
 
97
- api.logger.info('cron-delivery: registered agent_end hook')
108
+ api.logger.info('cron-delivery: registered agent_end hook (debug-instrumented)')
98
109
  }