@2en/clawly-plugins 1.28.1 → 1.29.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -106,7 +106,7 @@ function extractJobId(sessionKey: string): string | null {
106
106
 
107
107
  function emit(attributes: CronTelemetryAttributes, api: PluginApi) {
108
108
  // Emit structured OTel LogRecord (if provider is initialized)
109
- const otelLogger = getOtelLogger()
109
+ const otelLogger = getOtelLogger('cron-telemetry')
110
110
  if (otelLogger) {
111
111
  otelLogger.emit({
112
112
  severityNumber: SeverityNumber.INFO,
package/gateway/index.ts CHANGED
@@ -3,12 +3,13 @@ import {registerAgentSend} from './agent'
3
3
  import {registerCalendarNative} from './calendar-native'
4
4
  import {registerAnalytics} from './analytics'
5
5
  import {registerAudit} from './audit'
6
- import {registerNodeBrowserAllowlist} from './node-browser-allowlist'
6
+ import {registerNodeDangerousAllowlist} from './node-dangerous-allowlist'
7
7
  import {registerClawhub2gateway} from './clawhub2gateway'
8
8
  import {registerConfigRepair} from './config-repair'
9
9
  import {registerConfigTimezone} from './config-timezone'
10
10
  import {registerCronDelivery} from './cron-delivery'
11
11
  import {registerCronTelemetry} from './cron-telemetry'
12
+ import {registerMessageLog} from './message-log'
12
13
  import {registerMemoryBrowser} from './memory'
13
14
  import {registerNotification} from './notification'
14
15
  import {registerOfflinePush} from './offline-push'
@@ -55,6 +56,7 @@ export function registerGateway(api: PluginApi) {
55
56
  registerOfflinePush(api)
56
57
  registerCronDelivery(api)
57
58
  registerCronTelemetry(api)
59
+ registerMessageLog(api)
58
60
  registerAnalytics(api)
59
61
  registerConfigRepair(api)
60
62
  registerConfigTimezone(api)
@@ -62,6 +64,6 @@ export function registerGateway(api: PluginApi) {
62
64
  registerPairing(api)
63
65
  registerVersion(api)
64
66
  registerAudit(api)
65
- registerNodeBrowserAllowlist(api)
67
+ registerNodeDangerousAllowlist(api)
66
68
  registerCalendarNative(api)
67
69
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Message lifecycle logging — captures LLM inputs, outputs, and channel
3
+ * deliveries via OTel structured logs and human-readable api.logger output.
4
+ *
5
+ * Gated by `messageLogEnabled` in pluginConfig (dev-only).
6
+ * Content is never logged — only metadata (model, provider, counts, success).
7
+ *
8
+ * Hooks:
9
+ * - llm_input — logs model, provider, history size, images count
10
+ * - llm_output — logs model, provider, content length
11
+ * - message_sent — logs channel delivery outcome
12
+ */
13
+
14
+ import {SeverityNumber} from '@opentelemetry/api-logs'
15
+ import type {PluginApi} from '../types'
16
+ import {getOtelLogger} from './otel'
17
+ import {readTelemetryPluginConfig} from './telemetry-config'
18
+
19
+ // ── Structured telemetry attributes (OTel LogRecord) ────────────
20
+
21
+ type MessageLogAttributes = {
22
+ 'message.lifecycle': 'llm_input' | 'llm_output' | 'message_sent'
23
+ 'message.model'?: string
24
+ 'message.provider'?: string
25
+ 'message.session_key'?: string
26
+ 'message.session_id'?: string
27
+ 'message.content_length'?: number
28
+ 'message.history_count'?: number
29
+ 'message.images_count'?: number
30
+ 'message.to'?: string
31
+ 'message.success'?: boolean
32
+ 'message.error'?: string
33
+ 'message.channel_id'?: string
34
+ 'instance.id'?: string
35
+ }
36
+
37
+ // ── Helpers ──────────────────────────────────────────────────────
38
+
39
+ function emit(attributes: MessageLogAttributes, api: PluginApi) {
40
+ const otelLogger = getOtelLogger('message-log')
41
+ if (otelLogger) {
42
+ otelLogger.emit({
43
+ severityNumber: SeverityNumber.INFO,
44
+ severityText: 'INFO',
45
+ body: `message.${attributes['message.lifecycle']}`,
46
+ attributes: attributes as Record<string, unknown>,
47
+ })
48
+ }
49
+
50
+ const a = attributes
51
+ switch (a['message.lifecycle']) {
52
+ case 'llm_input':
53
+ api.logger.info(
54
+ `message-log[llm-input]: model=${a['message.model'] ?? '?'} provider=${a['message.provider'] ?? '?'} history=${a['message.history_count'] ?? 0} images=${a['message.images_count'] ?? 0}`,
55
+ )
56
+ break
57
+ case 'llm_output':
58
+ api.logger.info(
59
+ `message-log[llm-output]: model=${a['message.model'] ?? '?'} provider=${a['message.provider'] ?? '?'} len=${a['message.content_length'] ?? 0}`,
60
+ )
61
+ break
62
+ case 'message_sent':
63
+ api.logger.info(
64
+ `message-log[sent]: to=${a['message.to'] ?? '?'} success=${a['message.success'] ?? '?'} channel=${a['message.channel_id'] ?? '?'}${a['message.error'] ? ` error="${a['message.error']}"` : ''}`,
65
+ )
66
+ break
67
+ }
68
+ }
69
+
70
+ // ── Registration ─────────────────────────────────────────────────
71
+
72
+ export function registerMessageLog(api: PluginApi) {
73
+ const cfg = readTelemetryPluginConfig(api.pluginConfig)
74
+
75
+ if (!cfg.messageLogEnabled) {
76
+ api.logger.info('message-log: disabled (messageLogEnabled not set in pluginConfig)')
77
+ return
78
+ }
79
+
80
+ const instanceId = cfg.instanceId ?? process.env.INSTANCE_ID
81
+
82
+ // llm_input: capture what goes into the LLM (metadata only)
83
+ api.on('llm_input', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
84
+ const model = typeof event.model === 'string' ? event.model : undefined
85
+ const provider = typeof event.provider === 'string' ? event.provider : undefined
86
+ const prompt = typeof event.prompt === 'string' ? event.prompt : ''
87
+ const historyMessages = Array.isArray(event.historyMessages) ? event.historyMessages : []
88
+ const imagesCount = typeof event.imagesCount === 'number' ? event.imagesCount : 0
89
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
90
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
91
+
92
+ emit(
93
+ {
94
+ 'message.lifecycle': 'llm_input',
95
+ ...(model ? {'message.model': model} : {}),
96
+ ...(provider ? {'message.provider': provider} : {}),
97
+ 'message.content_length': prompt.length,
98
+ 'message.history_count': historyMessages.length,
99
+ 'message.images_count': imagesCount,
100
+ ...(sessionKey ? {'message.session_key': sessionKey} : {}),
101
+ ...(sessionId ? {'message.session_id': sessionId} : {}),
102
+ ...(instanceId ? {'instance.id': instanceId} : {}),
103
+ },
104
+ api,
105
+ )
106
+ })
107
+
108
+ // llm_output: capture LLM response metadata
109
+ api.on('llm_output', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
110
+ const model = typeof event.model === 'string' ? event.model : undefined
111
+ const provider = typeof event.provider === 'string' ? event.provider : undefined
112
+ const assistantTexts = Array.isArray(event.assistantTexts) ? event.assistantTexts : []
113
+ const contentLength = assistantTexts
114
+ .filter((t): t is string => typeof t === 'string')
115
+ .reduce((sum, t) => sum + t.length, 0)
116
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
117
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
118
+
119
+ emit(
120
+ {
121
+ 'message.lifecycle': 'llm_output',
122
+ ...(model ? {'message.model': model} : {}),
123
+ ...(provider ? {'message.provider': provider} : {}),
124
+ 'message.content_length': contentLength,
125
+ ...(sessionKey ? {'message.session_key': sessionKey} : {}),
126
+ ...(sessionId ? {'message.session_id': sessionId} : {}),
127
+ ...(instanceId ? {'instance.id': instanceId} : {}),
128
+ },
129
+ api,
130
+ )
131
+ })
132
+
133
+ // message_sent: capture channel delivery outcome
134
+ api.on('message_sent', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
135
+ const to = typeof event.to === 'string' ? event.to : undefined
136
+ const content = typeof event.content === 'string' ? event.content : ''
137
+ const success = typeof event.success === 'boolean' ? event.success : undefined
138
+ const error = typeof event.error === 'string' ? event.error : undefined
139
+ const channelId = typeof ctx?.channelId === 'string' ? ctx.channelId : undefined
140
+
141
+ emit(
142
+ {
143
+ 'message.lifecycle': 'message_sent',
144
+ ...(to ? {'message.to': to} : {}),
145
+ 'message.content_length': content.length,
146
+ ...(success != null ? {'message.success': success} : {}),
147
+ ...(error ? {'message.error': error} : {}),
148
+ ...(channelId ? {'message.channel_id': channelId} : {}),
149
+ ...(instanceId ? {'instance.id': instanceId} : {}),
150
+ },
151
+ api,
152
+ )
153
+ })
154
+
155
+ api.logger.info('message-log: registered llm_input + llm_output + message_sent hooks')
156
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Ensures all dangerous node commands are in gateway.nodes.allowCommands
3
+ * so the AI agent can invoke them on connected Clawly nodes.
4
+ *
5
+ * Dangerous commands are defined by OpenClaw's node-command-policy and
6
+ * must be explicitly allowlisted. Safe commands work without allowlisting.
7
+ *
8
+ * Runs on gateway_start. Writes directly to openclaw.json via
9
+ * writeOpenclawConfig (no restart triggered — gateway.* changes are
10
+ * classified as "none" by the config watcher).
11
+ */
12
+ import path from 'node:path'
13
+
14
+ import type {PluginApi} from '../types'
15
+ import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
16
+
17
+ const DANGEROUS_COMMANDS = [
18
+ // Browser commands (Mac nodes)
19
+ 'browser.proxy',
20
+ 'browser.navigate',
21
+ 'browser.click',
22
+ 'browser.type',
23
+ 'browser.screenshot',
24
+ 'browser.read',
25
+ 'browser.tabs',
26
+ 'browser.back',
27
+ 'browser.scroll',
28
+ 'browser.evaluate',
29
+ // Reminders + calendar (iOS nodes)
30
+ 'reminders.add',
31
+ 'calendar.add',
32
+ ]
33
+
34
+ export function registerNodeDangerousAllowlist(api: PluginApi) {
35
+ api.on('gateway_start', async () => {
36
+ let stateDir: string
37
+ try {
38
+ stateDir = api.runtime.state.resolveStateDir()
39
+ } catch {
40
+ api.logger.warn('node-dangerous-allowlist: cannot resolve state dir, skipping')
41
+ return
42
+ }
43
+
44
+ const configPath = path.join(stateDir, 'openclaw.json')
45
+ let config: Record<string, unknown>
46
+ try {
47
+ config = readOpenclawConfig(configPath)
48
+ } catch {
49
+ api.logger.warn('node-dangerous-allowlist: failed to read config, skipping')
50
+ return
51
+ }
52
+
53
+ const gateway = (config.gateway as Record<string, unknown>) ?? {}
54
+ const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
55
+ const existing: string[] = Array.isArray(nodes.allowCommands)
56
+ ? (nodes.allowCommands as string[])
57
+ : []
58
+
59
+ const existingSet = new Set(existing)
60
+ const toAdd = DANGEROUS_COMMANDS.filter((cmd) => !existingSet.has(cmd))
61
+
62
+ if (toAdd.length === 0) {
63
+ api.logger.info('node-dangerous-allowlist: all commands already allowlisted')
64
+ return
65
+ }
66
+
67
+ nodes.allowCommands = [...existing, ...toAdd]
68
+ gateway.nodes = nodes
69
+ config.gateway = gateway
70
+
71
+ try {
72
+ writeOpenclawConfig(configPath, config)
73
+ } catch {
74
+ api.logger.warn('node-dangerous-allowlist: failed to write config')
75
+ return
76
+ }
77
+ api.logger.info(
78
+ `node-dangerous-allowlist: added ${toAdd.length} commands to allowlist: ${toAdd.join(', ')}`,
79
+ )
80
+ })
81
+ }
@@ -69,7 +69,15 @@ describe('initOtel', () => {
69
69
  },
70
70
  })
71
71
  expect(getOtelLogger()).toBeTruthy()
72
- expect(loggerName).toBe('cron-telemetry')
72
+ expect(loggerName).toBe('clawly-plugins')
73
+ })
74
+
75
+ test('returns scoped loggers by name', () => {
76
+ initOtel({otelToken: 'tok', otelDataset: 'ds'})
77
+ const a = getOtelLogger('cron-telemetry')
78
+ const b = getOtelLogger('message-log')
79
+ expect(a).toBeTruthy()
80
+ expect(b).toBeTruthy()
73
81
  })
74
82
 
75
83
  test('falls back to legacy env vars', () => {
package/gateway/otel.ts CHANGED
@@ -17,7 +17,7 @@ import {BatchLogRecordProcessor, LoggerProvider} from '@opentelemetry/sdk-logs'
17
17
  import {readTelemetryPluginConfig} from './telemetry-config'
18
18
 
19
19
  let provider: LoggerProvider | null = null
20
- let logger: Logger | null = null
20
+ const loggers = new Map<string, Logger>()
21
21
 
22
22
  export function initOtel(
23
23
  pluginConfig?: Record<string, unknown>,
@@ -40,11 +40,16 @@ export function initOtel(
40
40
 
41
41
  provider = new LoggerProvider({resource})
42
42
  provider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter))
43
- logger = provider.getLogger('cron-telemetry')
44
43
  return true
45
44
  }
46
45
 
47
- export function getOtelLogger(): Logger | null {
46
+ export function getOtelLogger(scope = 'clawly-plugins'): Logger | null {
47
+ if (!provider) return null
48
+ let logger = loggers.get(scope)
49
+ if (!logger) {
50
+ logger = provider.getLogger(scope)
51
+ loggers.set(scope, logger)
52
+ }
48
53
  return logger
49
54
  }
50
55
 
@@ -52,6 +57,6 @@ export async function shutdownOtel(): Promise<void> {
52
57
  if (provider) {
53
58
  await provider.shutdown()
54
59
  provider = null
55
- logger = null
60
+ loggers.clear()
56
61
  }
57
62
  }
@@ -17,6 +17,7 @@ describe('readTelemetryPluginConfig', () => {
17
17
  otelDataset: 'clawly-otel-logs-dev',
18
18
  posthogApiKey: 'ph-key',
19
19
  posthogHost: 'https://us.i.posthog.com',
20
+ messageLogEnabled: false,
20
21
  })
21
22
  })
22
23
 
@@ -35,6 +36,7 @@ describe('readTelemetryPluginConfig', () => {
35
36
  otelDataset: 'clawly-otel-logs-dev',
36
37
  posthogApiKey: 'ph-key',
37
38
  posthogHost: 'https://us.i.posthog.com',
39
+ messageLogEnabled: false,
38
40
  })
39
41
  })
40
42
 
@@ -53,6 +55,7 @@ describe('readTelemetryPluginConfig', () => {
53
55
  otelDataset: undefined,
54
56
  posthogApiKey: undefined,
55
57
  posthogHost: undefined,
58
+ messageLogEnabled: false,
56
59
  })
57
60
  })
58
61
  })
@@ -4,6 +4,7 @@ export interface TelemetryPluginConfig {
4
4
  otelDataset?: string
5
5
  posthogApiKey?: string
6
6
  posthogHost?: string
7
+ messageLogEnabled?: boolean
7
8
  }
8
9
 
9
10
  function readString(value: unknown): string | undefined {
@@ -23,5 +24,6 @@ export function readTelemetryPluginConfig(
23
24
  otelDataset: readString(cfg.otelDataset),
24
25
  posthogApiKey: readString(cfg.posthogApiKey),
25
26
  posthogHost: readString(cfg.posthogHost),
27
+ messageLogEnabled: cfg.messageLogEnabled === true || cfg.messageLogEnabled === 'true',
26
28
  }
27
29
  }
package/index.ts CHANGED
@@ -34,6 +34,9 @@
34
34
  * - agent_end — sends push notification when client is offline; injects cron results into main session
35
35
  * - after_tool_call — cron telemetry: captures cron job creation/deletion
36
36
  * - agent_end (pri 100) — cron telemetry: captures cron execution outcomes with delivery/push flags
37
+ * - llm_input — message-log (dev-only): logs model, provider, history size, images count (no content)
38
+ * - llm_output — message-log (dev-only): logs model, provider, content length (no content)
39
+ * - message_sent — message-log (dev-only): logs channel delivery outcome (no content)
37
40
  * - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
38
41
  * - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
39
42
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.28.1",
3
+ "version": "1.29.0-beta.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Agent tool: clawly_msg_break — split the current response into multiple messages.
3
+ *
4
+ * When called, this tool acts as a structural boundary: the text before the
5
+ * tool call becomes one assistant message, and the text after becomes a new one.
6
+ * The client relies on the existing "text reset" mechanism (post-tool-call text
7
+ * detection) to create the split — the tool itself is a no-op.
8
+ *
9
+ * This is preferred over in-band separator tokens (e.g. [MSG_BREAK]) because
10
+ * tool calls are structured and never leak to other channels (Telegram, etc.).
11
+ */
12
+
13
+ import type {PluginApi} from '../types'
14
+
15
+ const TOOL_NAME = 'clawly_msg_break'
16
+
17
+ export function registerMsgBreakTool(api: PluginApi) {
18
+ api.registerTool({
19
+ name: TOOL_NAME,
20
+ description:
21
+ 'Split the current response into multiple messages. Call this between distinct thoughts or topics to send them as separate message bubbles. The text you wrote before this call becomes one message; continue writing after the call to start a new message.',
22
+ parameters: {
23
+ type: 'object',
24
+ properties: {},
25
+ },
26
+ async execute() {
27
+ return {content: [{type: 'text', text: JSON.stringify({ok: true})}]}
28
+ },
29
+ })
30
+
31
+ api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
32
+ }
package/tools/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {PluginApi} from '../types'
2
2
  import {registerCalendarTools} from './clawly-calendar'
3
3
  import {registerIsUserOnlineTool} from './clawly-is-user-online'
4
+ import {registerMsgBreakTool} from './clawly-msg-break'
4
5
  import {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
5
6
  import {registerSendAppPushTool} from './clawly-send-app-push'
6
7
  import {registerSendImageTool} from './clawly-send-image'
@@ -9,6 +10,7 @@ import {registerSendMessageTool} from './clawly-send-message'
9
10
  export function registerTools(api: PluginApi) {
10
11
  registerCalendarTools(api)
11
12
  registerIsUserOnlineTool(api)
13
+ registerMsgBreakTool(api)
12
14
  registerSearchTool(api)
13
15
  registerDeepSearchTool(api)
14
16
  registerGrokSearchTool(api)
@@ -1,62 +0,0 @@
1
- /**
2
- * Ensures browser.* node commands are in gateway.nodes.allowCommands
3
- * so the AI agent can invoke them on connected Clawly Mac nodes.
4
- *
5
- * Runs on gateway_start. Writes directly to openclaw.json via
6
- * writeOpenclawConfig (no restart triggered — gateway.* changes are
7
- * classified as "none" by the config watcher).
8
- */
9
- import path from 'node:path'
10
-
11
- import type {PluginApi} from '../types'
12
- import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
13
-
14
- const BROWSER_COMMANDS = [
15
- 'browser.proxy',
16
- 'browser.navigate',
17
- 'browser.click',
18
- 'browser.type',
19
- 'browser.screenshot',
20
- 'browser.read',
21
- 'browser.tabs',
22
- 'browser.back',
23
- 'browser.scroll',
24
- 'browser.evaluate',
25
- ]
26
-
27
- export function registerNodeBrowserAllowlist(api: PluginApi) {
28
- api.on('gateway_start', async () => {
29
- let stateDir: string
30
- try {
31
- stateDir = api.runtime.state.resolveStateDir()
32
- } catch {
33
- api.logger.warn('node-browser-allowlist: cannot resolve state dir, skipping')
34
- return
35
- }
36
-
37
- const configPath = path.join(stateDir, 'openclaw.json')
38
- const config = readOpenclawConfig(configPath)
39
-
40
- // Ensure gateway.nodes.allowCommands includes browser.* commands
41
- const gateway = (config.gateway as Record<string, unknown>) ?? {}
42
- const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
43
- const existing: string[] = Array.isArray(nodes.allowCommands)
44
- ? (nodes.allowCommands as string[])
45
- : []
46
-
47
- const existingSet = new Set(existing)
48
- const toAdd = BROWSER_COMMANDS.filter((cmd) => !existingSet.has(cmd))
49
-
50
- if (toAdd.length === 0) {
51
- api.logger.info('node-browser-allowlist: browser commands already allowlisted')
52
- return
53
- }
54
-
55
- nodes.allowCommands = [...existing, ...toAdd]
56
- gateway.nodes = nodes
57
- config.gateway = gateway
58
-
59
- writeOpenclawConfig(configPath, config)
60
- api.logger.info(`node-browser-allowlist: added ${toAdd.length} browser commands to allowlist`)
61
- })
62
- }