@2en/clawly-plugins 1.26.0-beta.1 → 1.26.1-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.
package/config-setup.ts CHANGED
@@ -1,20 +1,25 @@
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'
22
+ import {autoSanitizeSession} from './gateway/session-sanitize'
18
23
  import {
19
24
  PROVIDER_NAME,
20
25
  patchModelGateway,
@@ -377,8 +382,100 @@ export function patchSession(config: Record<string, unknown>): boolean {
377
382
  return dirty
378
383
  }
379
384
 
385
+ const PLUGIN_ID = 'clawly-plugins'
386
+ const NPM_PKG_NAME = '@2en/clawly-plugins'
387
+
388
+ function asObj(value: unknown): Record<string, unknown> {
389
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
390
+ ? (value as Record<string, unknown>)
391
+ : {}
392
+ }
393
+
394
+ /**
395
+ * Self-healing: reconstruct missing `plugins.installs.clawly-plugins` record.
396
+ * Older provisioned sprites had this record destroyed by a full-overwrite bug
397
+ * in the provision write-plugins-config step. Without this record,
398
+ * `openclaw plugins update` cannot function.
399
+ */
400
+ export function patchInstallRecord(config: Record<string, unknown>, stateDir: string): boolean {
401
+ const plugins = asObj(config.plugins)
402
+ const installs = asObj(plugins.installs)
403
+
404
+ // Already has a valid install record — nothing to do
405
+ if (
406
+ installs[PLUGIN_ID] &&
407
+ typeof installs[PLUGIN_ID] === 'object' &&
408
+ (installs[PLUGIN_ID] as Record<string, unknown>).source &&
409
+ (installs[PLUGIN_ID] as Record<string, unknown>).spec
410
+ )
411
+ return false
412
+
413
+ // Read version from installed plugin's package.json
414
+ const installPath = path.join(stateDir, 'extensions', PLUGIN_ID)
415
+ const pkgPath = path.join(installPath, 'package.json')
416
+ let rawPkg: string
417
+ try {
418
+ rawPkg = fs.readFileSync(pkgPath, 'utf-8')
419
+ } catch {
420
+ // Plugin not installed on disk — skip
421
+ return false
422
+ }
423
+ let version: string | undefined
424
+ try {
425
+ const pkg = JSON.parse(rawPkg)
426
+ if (typeof pkg.version === 'string') version = pkg.version
427
+ } catch {
428
+ // package.json is malformed — proceed without version
429
+ }
430
+ let installedAt = new Date().toISOString()
431
+ try {
432
+ installedAt = fs.statSync(pkgPath).mtime.toISOString()
433
+ } catch {
434
+ // Fall back to repair time if file metadata is unavailable.
435
+ }
436
+
437
+ installs[PLUGIN_ID] = {
438
+ source: 'npm',
439
+ spec: version ? `${NPM_PKG_NAME}@${version}` : NPM_PKG_NAME,
440
+ installPath,
441
+ version,
442
+ installedAt,
443
+ }
444
+ plugins.installs = installs
445
+ config.plugins = plugins
446
+ return true
447
+ }
448
+
449
+ function repairLegacyProvisionState(
450
+ api: PluginApi,
451
+ config: Record<string, unknown>,
452
+ stateDir: string,
453
+ ) {
454
+ const installRecordPatched = patchInstallRecord(config, stateDir)
455
+ if (installRecordPatched) {
456
+ api.logger.warn('plugins.installs.clawly-plugins was missing — self-healed from disk.')
457
+ }
458
+ return installRecordPatched
459
+ }
460
+
461
+ function reconcileRuntimeConfig(
462
+ api: PluginApi,
463
+ config: Record<string, unknown>,
464
+ pc: ConfigPluginConfig,
465
+ ): boolean {
466
+ let dirty = false
467
+ dirty = patchAgent(config, pc) || dirty
468
+ dirty = patchGateway(config) || dirty
469
+ dirty = patchBrowser(config) || dirty
470
+ dirty = patchSession(config) || dirty
471
+ dirty = patchTts(config, pc) || dirty
472
+ dirty = patchWebSearch(config, pc) || dirty
473
+ dirty = patchModelGateway(config, api) || dirty
474
+ return dirty
475
+ }
476
+
380
477
  // ---------------------------------------------------------------------------
381
- // Entry point — single read, patch all, single write
478
+ // Entry point — single read, targeted reconcile, single write
382
479
  // ---------------------------------------------------------------------------
383
480
 
384
481
  export function setupConfig(api: PluginApi): void {
@@ -393,23 +490,20 @@ export function setupConfig(api: PluginApi): void {
393
490
  const pc = toPC(api)
394
491
 
395
492
  let dirty = false
396
- dirty = patchAgent(config, pc) || dirty
397
- dirty = patchGateway(config) || dirty
398
- dirty = patchBrowser(config) || dirty
399
- dirty = patchSession(config) || dirty
400
- dirty = patchTts(config, pc) || dirty
401
- dirty = patchWebSearch(config, pc) || dirty
402
- dirty = patchModelGateway(config, api) || dirty
493
+ dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
494
+ dirty = reconcileRuntimeConfig(api, config, pc) || dirty
403
495
 
404
- if (!dirty) {
496
+ if (dirty) {
497
+ try {
498
+ writeOpenclawConfig(configPath, config)
499
+ api.logger.info('Config setup: patched openclaw.json.')
500
+ } catch (err) {
501
+ api.logger.error(`Config setup failed: ${(err as Error).message}`)
502
+ }
503
+ } else {
405
504
  api.logger.info('Config setup: no changes needed.')
406
- return
407
505
  }
408
506
 
409
- try {
410
- writeOpenclawConfig(configPath, config)
411
- api.logger.info('Config setup: patched openclaw.json.')
412
- } catch (err) {
413
- api.logger.error(`Config setup failed: ${(err as Error).message}`)
414
- }
507
+ // Best-effort: clear stale delivery fields from the main session on every restart
508
+ autoSanitizeSession(api)
415
509
  }
package/gateway/index.ts CHANGED
@@ -3,6 +3,8 @@ import {registerAgentSend} from './agent'
3
3
  import {registerAnalytics} from './analytics'
4
4
  import {registerClawhub2gateway} from './clawhub2gateway'
5
5
  import {registerConfigRepair} from './config-repair'
6
+
7
+ import {registerSessionSanitize} from './session-sanitize'
6
8
  import {registerCronDelivery} from './cron-delivery'
7
9
  import {registerCronTelemetry} from './cron-telemetry'
8
10
  import {initOtel, shutdownOtel} from './otel'
@@ -54,6 +56,7 @@ export function registerGateway(api: PluginApi) {
54
56
  registerCronTelemetry(api)
55
57
  registerAnalytics(api)
56
58
  registerConfigRepair(api)
59
+ registerSessionSanitize(api)
57
60
  registerPairing(api)
58
61
  registerVersion(api)
59
62
  }
@@ -17,6 +17,7 @@ import path from 'node:path'
17
17
  import {$} from 'zx'
18
18
  import type {PluginApi} from '../types'
19
19
  import {stripCliLogs} from '../lib/stripCliLogs'
20
+ import {stripMarkdown} from '../lib/stripMarkdown'
20
21
 
21
22
  $.verbose = false
22
23
 
@@ -151,7 +152,12 @@ export async function sendPushNotification(
151
152
  return false
152
153
  }
153
154
 
154
- const title = opts.title ?? (await resolveAgentTitle(opts.agentId))
155
+ const title = stripMarkdown(opts.title ?? (await resolveAgentTitle(opts.agentId)))
156
+ const body = stripMarkdown(opts.body)
157
+ if (!body) {
158
+ api.logger.warn('notification: body stripped to empty, skipping push')
159
+ return false
160
+ }
155
161
 
156
162
  try {
157
163
  const res = await fetch(EXPO_PUSH_URL, {
@@ -161,7 +167,7 @@ export async function sendPushNotification(
161
167
  to: token,
162
168
  sound: 'default',
163
169
  title,
164
- body: opts.body,
170
+ body,
165
171
  data: opts.data,
166
172
  ...extras,
167
173
  }),
@@ -183,7 +189,7 @@ export async function sendPushNotification(
183
189
  return false
184
190
  }
185
191
 
186
- api.logger.info(`notification: push sent — "${opts.body}"`)
192
+ api.logger.info(`notification: push sent — "${body}"`)
187
193
  return true
188
194
  } catch (err) {
189
195
  api.logger.error(
@@ -374,6 +374,16 @@ describe('shouldSkipPushForMessage', () => {
374
374
  expect(shouldSkipPushForMessage(' NO_REPLY ')).toBe('silent reply')
375
375
  })
376
376
 
377
+ test('skips trailing NO_REPLY with reasoning text (newline-collapsed)', () => {
378
+ // getLastAssistantText collapses "\n" → " ", so these arrive as single-line
379
+ expect(
380
+ shouldSkipPushForMessage('User is online — stopping here, no action needed. NO_REPLY'),
381
+ ).toBe('silent reply')
382
+ expect(shouldSkipPushForMessage('no match in last 24 hours → silent response. NO_REPLY')).toBe(
383
+ 'silent reply',
384
+ )
385
+ })
386
+
377
387
  test('skips heartbeat ack when HEARTBEAT_OK is the only content', () => {
378
388
  expect(shouldSkipPushForMessage('HEARTBEAT_OK')).toBe('heartbeat ack')
379
389
  expect(shouldSkipPushForMessage('HEARTBEAT_OK.')).toBe('heartbeat ack')
@@ -136,8 +136,13 @@ export function shouldSkipPushForMessage(text: string): string | null {
136
136
  // Agent replied with empty content — mobile hides as "emptyAssistant"
137
137
  if (trimmed === '') return 'empty assistant'
138
138
 
139
- // Agent sentinel "nothing to say" — mobile hides as "silentReply"
140
- if (trimmed === 'NO_REPLY') return 'silent reply'
139
+ // Agent sentinel "nothing to say" — mobile hides as "silentReply".
140
+ // Mobile uses (?:^|\n) anchor on raw text; here text is already newline-collapsed
141
+ // by getLastAssistantText, so we match NO_REPLY at string end without line anchor.
142
+ // Known limitation: a message ending with literal "NO_REPLY" as inline text (e.g.
143
+ // "The sentinel is called NO_REPLY") would suppress the push. Acceptable trade-off
144
+ // — the scenario is extremely unlikely and the cost is a missed push, not hidden UI.
145
+ if (/NO_REPLY[\p{P}\s]*$/u.test(text)) return 'silent reply'
141
146
 
142
147
  // Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) — only skip if no substantial content before it
143
148
  if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) {
@@ -127,6 +127,22 @@ async function withPluginBackup<T>(
127
127
  }
128
128
  }
129
129
 
130
+ interface UpdateParams {
131
+ pluginId: string
132
+ npmPkgName: string
133
+ strategy: 'force' | 'update' | 'install'
134
+ targetVersion?: string
135
+ restart?: boolean
136
+ /**
137
+ * Skip update if installed version already matches target. Default `true`.
138
+ *
139
+ * Applies to all strategies including `force`. `force` means "use the
140
+ * hardened install flow" (clear plugin config → reinstall), not "always
141
+ * reinstall regardless of version". Set `false` to bypass (testing only).
142
+ */
143
+ skipIfCurrent?: boolean
144
+ }
145
+
130
146
  export function registerPlugins(api: PluginApi) {
131
147
  // ── clawly.plugins.version ──────────────────────────────────────
132
148
 
@@ -194,13 +210,15 @@ export function registerPlugins(api: PluginApi) {
194
210
 
195
211
  // ── clawly.plugins.update ──────────────────────────────────────
196
212
 
197
- api.registerGatewayMethod('clawly.plugins.update', async ({params, respond}) => {
213
+ api.registerGatewayMethod('clawly.plugins.update', async (args) => {
214
+ const {params, respond} = args as {params: Partial<UpdateParams>; respond: typeof args.respond}
198
215
  const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
199
216
  const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
200
217
  const strategy = typeof params.strategy === 'string' ? params.strategy.trim() : ''
201
218
  const targetVersion =
202
219
  typeof params.targetVersion === 'string' ? params.targetVersion.trim() : undefined
203
220
  const restart = params.restart === true
221
+ const skipIfCurrent = params.skipIfCurrent !== false
204
222
 
205
223
  if (!pluginId || !npmPkgName || !strategy) {
206
224
  respond(false, undefined, {
@@ -220,11 +238,41 @@ export function registerPlugins(api: PluginApi) {
220
238
 
221
239
  const installTarget = targetVersion ? `${npmPkgName}@${targetVersion}` : npmPkgName
222
240
 
241
+ // ── Skip if already at target version ──────────────────────
242
+ const stateDir = api.runtime.state.resolveStateDir()
243
+ const installedVersion = stateDir ? readExtensionVersion(stateDir, pluginId) : null
244
+
245
+ if (skipIfCurrent && installedVersion) {
246
+ let resolvedTarget: string | null = null
247
+
248
+ if (targetVersion) {
249
+ // Explicit target — compare directly
250
+ resolvedTarget = targetVersion
251
+ } else {
252
+ // No target — resolve latest from npm
253
+ const npm = await fetchNpmView(npmPkgName)
254
+ if (npm?.version) resolvedTarget = npm.version
255
+ }
256
+
257
+ if (resolvedTarget && installedVersion === resolvedTarget) {
258
+ api.logger.info(
259
+ `plugins: ${pluginId} already at version ${installedVersion}, skipping ${strategy}`,
260
+ )
261
+ captureEvent('plugin.updated', {
262
+ plugin_id: pluginId,
263
+ strategy,
264
+ success: true,
265
+ skipped: true,
266
+ })
267
+ respond(true, {ok: true, strategy, skipped: true})
268
+ return
269
+ }
270
+ }
271
+
223
272
  try {
224
273
  let output = ''
225
274
 
226
275
  if (strategy === 'force') {
227
- const stateDir = api.runtime.state.resolveStateDir()
228
276
  if (!stateDir) throw new Error('cannot resolve openclaw state dir')
229
277
 
230
278
  const configPath = path.join(stateDir, 'openclaw.json')
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Session sanitize RPC: detects and clears stale delivery-context fields
3
+ * from the main webchat session that were left by cross-channel delivery.
4
+ *
5
+ * Methods:
6
+ * - clawly.session.sanitize({ dryRun?, sessionKey? }) → { ok/repaired, issues?, detail }
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+
12
+ import type {PluginApi} from '../types'
13
+
14
+ const DEFAULT_AGENT_ID = 'clawly'
15
+
16
+ function mainSessionKey(api: PluginApi): string {
17
+ const agentId =
18
+ (api.pluginConfig as Record<string, unknown> | undefined)?.agentId ?? DEFAULT_AGENT_ID
19
+ return `agent:${agentId}:main`
20
+ }
21
+
22
+ /** Fields on SessionEntry that should be absent (or "webchat") for a webchat-only session. */
23
+ const STALE_CHANNEL_VALUES = new Set([
24
+ 'telegram',
25
+ 'discord',
26
+ 'slack',
27
+ 'signal',
28
+ 'whatsapp',
29
+ 'imessage',
30
+ 'line',
31
+ 'matrix',
32
+ 'msteams',
33
+ 'zalo',
34
+ ])
35
+
36
+ interface SessionsStore {
37
+ sessions: Record<string, Record<string, unknown>>
38
+ }
39
+
40
+ /** Returns the parsed store, or null if the file doesn't exist, or throws on read/parse errors. */
41
+ function readSessionsStore(storePath: string): SessionsStore | null {
42
+ let raw: string
43
+ try {
44
+ raw = fs.readFileSync(storePath, 'utf-8')
45
+ const parsed = JSON.parse(raw)
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
47
+ throw new Error('not a JSON object')
48
+ return parsed
49
+ } catch (err: unknown) {
50
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
51
+ throw err
52
+ }
53
+ }
54
+
55
+ function writeSessionsStore(storePath: string, store: SessionsStore): void {
56
+ const tmpPath = storePath + '.tmp'
57
+ fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2) + '\n')
58
+ fs.renameSync(tmpPath, storePath)
59
+ }
60
+
61
+ /**
62
+ * Check a session entry for stale delivery fields that point to a non-webchat channel.
63
+ * Returns a list of issue descriptions, or empty array if clean.
64
+ */
65
+ function detectIssues(entry: Record<string, unknown>): string[] {
66
+ const issues: string[] = []
67
+
68
+ // deliveryContext.channel
69
+ const dc = entry.deliveryContext as Record<string, unknown> | undefined
70
+ if (dc?.channel && typeof dc.channel === 'string' && STALE_CHANNEL_VALUES.has(dc.channel)) {
71
+ issues.push(`deliveryContext.channel = "${dc.channel}"`)
72
+ }
73
+
74
+ // lastChannel
75
+ if (typeof entry.lastChannel === 'string' && STALE_CHANNEL_VALUES.has(entry.lastChannel)) {
76
+ issues.push(`lastChannel = "${entry.lastChannel}"`)
77
+ }
78
+
79
+ // lastTo, lastAccountId, lastThreadId — should be absent for webchat
80
+ // Values may contain PII (phone numbers, platform user IDs) — omit from logs/UI
81
+ if (entry.lastTo != null) issues.push('lastTo')
82
+ if (entry.lastAccountId != null) issues.push('lastAccountId')
83
+ if (entry.lastThreadId != null) issues.push('lastThreadId')
84
+
85
+ // origin.provider / origin.surface
86
+ const origin = entry.origin as Record<string, unknown> | undefined
87
+ if (
88
+ origin?.provider &&
89
+ typeof origin.provider === 'string' &&
90
+ STALE_CHANNEL_VALUES.has(origin.provider)
91
+ ) {
92
+ issues.push(`origin.provider = "${origin.provider}"`)
93
+ }
94
+ if (
95
+ origin?.surface &&
96
+ typeof origin.surface === 'string' &&
97
+ STALE_CHANNEL_VALUES.has(origin.surface)
98
+ ) {
99
+ issues.push(`origin.surface = "${origin.surface}"`)
100
+ }
101
+
102
+ return issues
103
+ }
104
+
105
+ /** Clear stale delivery fields from a session entry (mutates in place). */
106
+ function repairEntry(entry: Record<string, unknown>): void {
107
+ // Only delete channel-guarded fields when they match a stale channel value
108
+ const dc = entry.deliveryContext as Record<string, unknown> | undefined
109
+ if (dc && typeof dc.channel === 'string' && STALE_CHANNEL_VALUES.has(dc.channel)) {
110
+ delete dc.channel
111
+ if (Object.keys(dc).length === 0) delete entry.deliveryContext
112
+ }
113
+ if (typeof entry.lastChannel === 'string' && STALE_CHANNEL_VALUES.has(entry.lastChannel))
114
+ delete entry.lastChannel
115
+ // lastTo/lastAccountId/lastThreadId are stale for webchat when present
116
+ if (entry.lastTo != null) delete entry.lastTo
117
+ if (entry.lastAccountId != null) delete entry.lastAccountId
118
+ if (entry.lastThreadId != null) delete entry.lastThreadId
119
+
120
+ const origin = entry.origin as Record<string, unknown> | undefined
121
+ if (origin) {
122
+ if (typeof origin.provider === 'string' && STALE_CHANNEL_VALUES.has(origin.provider))
123
+ delete origin.provider
124
+ if (typeof origin.surface === 'string' && STALE_CHANNEL_VALUES.has(origin.surface))
125
+ delete origin.surface
126
+ if (Object.keys(origin).length === 0) delete entry.origin
127
+ }
128
+ }
129
+
130
+ export function registerSessionSanitize(api: PluginApi) {
131
+ // Capture agentId + defaultKey once at registration time so the handler
132
+ // stays consistent with the sessionKey guard even if pluginConfig mutates.
133
+ const agentId =
134
+ ((api.pluginConfig as Record<string, unknown> | undefined)?.agentId as string | undefined) ??
135
+ DEFAULT_AGENT_ID
136
+ const defaultKey = `agent:${agentId}:main`
137
+
138
+ api.registerGatewayMethod('clawly.session.sanitize', async ({params, respond}) => {
139
+ const dryRun = params.dryRun === true
140
+ const sessionKey = typeof params.sessionKey === 'string' ? params.sessionKey : defaultKey
141
+
142
+ // Only sanitize the main webchat session — non-main sessions (e.g. telegram/discord)
143
+ // legitimately use lastTo/lastAccountId/lastThreadId for routing.
144
+ if (sessionKey !== defaultKey) {
145
+ respond(true, {
146
+ ok: true,
147
+ ...(dryRun ? {} : {repaired: false}),
148
+ detail: `Skipped — sanitize only applies to "${defaultKey}"`,
149
+ })
150
+ return
151
+ }
152
+
153
+ const stateDir = api.runtime.state.resolveStateDir()
154
+ if (!stateDir) {
155
+ respond(true, {
156
+ ok: false,
157
+ ...(dryRun ? {} : {repaired: false}),
158
+ detail: 'Cannot resolve state dir',
159
+ })
160
+ return
161
+ }
162
+
163
+ const storePath = path.join(stateDir, 'agents', agentId, 'sessions', 'sessions.json')
164
+ let store: SessionsStore | null
165
+ try {
166
+ store = readSessionsStore(storePath)
167
+ } catch (err) {
168
+ const msg = err instanceof Error ? err.message : String(err)
169
+ respond(true, {
170
+ // dryRun: treat corrupt as "can't check, assume clean" — avoids
171
+ // showing a misleading Fix button for an unfixable environment error.
172
+ ...(dryRun ? {ok: true} : {ok: false, repaired: false}),
173
+ detail: `Sessions store unreadable or malformed: ${msg}`,
174
+ })
175
+ return
176
+ }
177
+ if (!store) {
178
+ respond(true, {
179
+ ok: true,
180
+ ...(dryRun ? {} : {repaired: false}),
181
+ detail: 'Sessions store not found — nothing to sanitize',
182
+ })
183
+ return
184
+ }
185
+
186
+ const entry = store.sessions?.[sessionKey] as Record<string, unknown> | undefined
187
+ if (!entry) {
188
+ respond(true, {
189
+ ok: true,
190
+ ...(dryRun ? {} : {repaired: false}),
191
+ detail: `Session "${sessionKey}" not found — nothing to sanitize`,
192
+ })
193
+ return
194
+ }
195
+
196
+ const issues = detectIssues(entry)
197
+ if (issues.length === 0) {
198
+ respond(true, {
199
+ ok: true,
200
+ ...(dryRun ? {} : {repaired: false}),
201
+ detail: 'Session delivery context is clean',
202
+ })
203
+ return
204
+ }
205
+
206
+ if (dryRun) {
207
+ respond(true, {ok: false, issues, detail: `Stale fields: ${issues.join(', ')}`})
208
+ return
209
+ }
210
+
211
+ repairEntry(entry)
212
+
213
+ try {
214
+ writeSessionsStore(storePath, store)
215
+ api.logger.info(`session.sanitize: cleared stale delivery fields — ${issues.join(', ')}`)
216
+ respond(true, {ok: true, repaired: true, issues, detail: `Cleared: ${issues.join(', ')}`})
217
+ } catch (err) {
218
+ const msg = err instanceof Error ? err.message : String(err)
219
+ api.logger.error(`session.sanitize: write failed — ${msg}`)
220
+ respond(true, {ok: false, repaired: false, detail: `Write failed: ${msg}`})
221
+ }
222
+ })
223
+
224
+ api.logger.info('session-sanitize: registered clawly.session.sanitize')
225
+ }
226
+
227
+ /**
228
+ * Standalone auto-sanitize — reads, checks, writes. Called from setupConfig on startup.
229
+ * Best-effort: logs warnings on failure, never throws.
230
+ */
231
+ export function autoSanitizeSession(api: PluginApi): void {
232
+ const stateDir = api.runtime.state.resolveStateDir()
233
+ if (!stateDir) return
234
+
235
+ const agentId =
236
+ (api.pluginConfig as Record<string, unknown> | undefined)?.agentId ?? DEFAULT_AGENT_ID
237
+ const storePath = path.join(stateDir, 'agents', String(agentId), 'sessions', 'sessions.json')
238
+ let store: SessionsStore | null
239
+ try {
240
+ store = readSessionsStore(storePath)
241
+ } catch (err) {
242
+ api.logger.warn(
243
+ `session-sanitize (auto): cannot read sessions store — ${(err as Error).message}`,
244
+ )
245
+ return
246
+ }
247
+ if (!store) return
248
+
249
+ const sessionKey = mainSessionKey(api)
250
+ const entry = store.sessions?.[sessionKey] as Record<string, unknown> | undefined
251
+ if (!entry) return
252
+
253
+ const issues = detectIssues(entry)
254
+ if (issues.length === 0) return
255
+
256
+ repairEntry(entry)
257
+ try {
258
+ writeSessionsStore(storePath, store)
259
+ api.logger.info(`session-sanitize (auto): cleared stale fields — ${issues.join(', ')}`)
260
+ } catch (err) {
261
+ api.logger.warn(`session-sanitize (auto): write failed — ${(err as Error).message}`)
262
+ }
263
+ }
@@ -0,0 +1,156 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {stripMarkdown} from './stripMarkdown'
3
+
4
+ describe('stripMarkdown', () => {
5
+ test('strips bold (**text**)', () => {
6
+ expect(stripMarkdown('Hello **world**!')).toBe('Hello world!')
7
+ })
8
+
9
+ test('strips italic (*text*)', () => {
10
+ expect(stripMarkdown('Hello *world*!')).toBe('Hello world!')
11
+ })
12
+
13
+ test('strips bold+italic (***text***)', () => {
14
+ expect(stripMarkdown('Hello ***world***!')).toBe('Hello world!')
15
+ })
16
+
17
+ test('strips strikethrough (~~text~~)', () => {
18
+ expect(stripMarkdown('Hello ~~world~~!')).toBe('Hello world!')
19
+ })
20
+
21
+ test('strips inline code (`text`)', () => {
22
+ expect(stripMarkdown('Run `npm install` to start')).toBe('Run npm install to start')
23
+ })
24
+
25
+ test('strips code block fences, keeps content', () => {
26
+ expect(stripMarkdown('Before ```const x = 1``` after')).toBe('Before const x = 1 after')
27
+ })
28
+
29
+ test('strips multiline code block fences with language tag', () => {
30
+ expect(stripMarkdown('Before\n```js\nconst x = 1\n```\nafter')).toBe(
31
+ 'Before\nconst x = 1\n\nafter',
32
+ )
33
+ })
34
+
35
+ test('strips multiline code block fences without language tag', () => {
36
+ expect(stripMarkdown('Before\n```\nconst x = 1\n```\nafter')).toBe(
37
+ 'Before\nconst x = 1\n\nafter',
38
+ )
39
+ })
40
+
41
+ test('strips links [text](url) → text', () => {
42
+ expect(stripMarkdown('Visit [our site](https://example.com) now')).toBe('Visit our site now')
43
+ })
44
+
45
+ test('strips links with parenthesized URLs', () => {
46
+ expect(
47
+ stripMarkdown('See [Function](https://en.wikipedia.org/wiki/Function_(mathematics)) here'),
48
+ ).toBe('See Function here')
49
+ })
50
+
51
+ test('strips images ![alt](url) → alt', () => {
52
+ expect(stripMarkdown('See ![photo](https://example.com/img.png) here')).toBe('See photo here')
53
+ })
54
+
55
+ test('strips heading markers', () => {
56
+ expect(stripMarkdown('# Hello')).toBe('Hello')
57
+ expect(stripMarkdown('## Subheading')).toBe('Subheading')
58
+ expect(stripMarkdown('### Deep heading')).toBe('Deep heading')
59
+ })
60
+
61
+ test('strips heading markers in multiline text', () => {
62
+ expect(stripMarkdown('Line one\n## Heading')).toBe('Line one\nHeading')
63
+ })
64
+
65
+ test('strips blockquote markers', () => {
66
+ expect(stripMarkdown('> This is a quote')).toBe('This is a quote')
67
+ })
68
+
69
+ test('strips nested blockquotes', () => {
70
+ expect(stripMarkdown('> > Nested content')).toBe('Nested content')
71
+ expect(stripMarkdown('> > > Deep nested')).toBe('Deep nested')
72
+ })
73
+
74
+ test('strips blockquotes without space', () => {
75
+ expect(stripMarkdown('>text')).toBe('text')
76
+ })
77
+
78
+ test('strips blockquoted headings and lists', () => {
79
+ expect(stripMarkdown('> ## Heading')).toBe('Heading')
80
+ expect(stripMarkdown('> - Item')).toBe('Item')
81
+ expect(stripMarkdown('> 1. First')).toBe('First')
82
+ })
83
+
84
+ test('strips unordered list markers', () => {
85
+ expect(stripMarkdown('- Item one')).toBe('Item one')
86
+ expect(stripMarkdown('* Item two')).toBe('Item two')
87
+ expect(stripMarkdown('+ Item three')).toBe('Item three')
88
+ })
89
+
90
+ test('strips ordered list markers', () => {
91
+ expect(stripMarkdown('1. First item')).toBe('First item')
92
+ expect(stripMarkdown('2. Second item')).toBe('Second item')
93
+ expect(stripMarkdown('10. Tenth item')).toBe('Tenth item')
94
+ })
95
+
96
+ test('strips horizontal rules', () => {
97
+ expect(stripMarkdown('Before\n---\nAfter')).toBe('Before\n\nAfter')
98
+ expect(stripMarkdown('Before\n***\nAfter')).toBe('Before\n\nAfter')
99
+ expect(stripMarkdown('Before\n___\nAfter')).toBe('Before\n\nAfter')
100
+ })
101
+
102
+ test('handles multiple markdown features combined', () => {
103
+ expect(stripMarkdown('**Hey!** Check [this](https://x.com) out — `code` here')).toBe(
104
+ 'Hey! Check this out — code here',
105
+ )
106
+ })
107
+
108
+ test('leaves plain text unchanged', () => {
109
+ expect(stripMarkdown('Just a normal message')).toBe('Just a normal message')
110
+ })
111
+
112
+ test('handles empty string', () => {
113
+ expect(stripMarkdown('')).toBe('')
114
+ })
115
+
116
+ test('collapses extra spaces from stripping', () => {
117
+ expect(stripMarkdown('Hello **world** !')).toBe('Hello world !')
118
+ })
119
+
120
+ test('preserves snake_case identifiers', () => {
121
+ expect(stripMarkdown('Use some_variable_name in your code')).toBe(
122
+ 'Use some_variable_name in your code',
123
+ )
124
+ })
125
+
126
+ test('preserves underscores in technical text', () => {
127
+ expect(stripMarkdown('Set __proto__ and __name__ carefully')).toBe(
128
+ 'Set __proto__ and __name__ carefully',
129
+ )
130
+ })
131
+
132
+ test('handles multiline LLM-style output', () => {
133
+ const input = '**Summary:**\n1. First point\n2. Second point\n> Note: be careful'
134
+ expect(stripMarkdown(input)).toBe('Summary:\nFirst point\nSecond point\nNote: be careful')
135
+ })
136
+
137
+ test('does not match bold across line boundaries', () => {
138
+ const input = '* Item one\n* Item two'
139
+ expect(stripMarkdown(input)).toBe('Item one\nItem two')
140
+ })
141
+
142
+ test('strips * list marker with inline emphasis', () => {
143
+ expect(stripMarkdown('* Use *caution* here')).toBe('Use caution here')
144
+ })
145
+
146
+ test('preserves asterisks in math/technical expressions', () => {
147
+ expect(stripMarkdown('Calculate 2*3*4 for the result')).toBe('Calculate 2*3*4 for the result')
148
+ expect(stripMarkdown('Calculate 2 * 3 * 4 for the result')).toBe(
149
+ 'Calculate 2 * 3 * 4 for the result',
150
+ )
151
+ })
152
+
153
+ test('preserves content from code-only input', () => {
154
+ expect(stripMarkdown('```const x = 1```')).toBe('const x = 1')
155
+ })
156
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Strip common Markdown formatting from text to produce plain-text
3
+ * suitable for push notification bodies.
4
+ *
5
+ * Handles: bold, italic, strikethrough, inline code, code blocks,
6
+ * links, images, headings, blockquotes, list markers, and horizontal rules.
7
+ */
8
+ export function stripMarkdown(text: string): string {
9
+ return (
10
+ text
11
+ // Code blocks — strip fences (and optional language tag), keep content
12
+ .replace(/```(?:\w*\n)?([\s\S]*?)```/g, '$1')
13
+ // Images ![alt](url) → alt (supports one level of balanced parens in URL)
14
+ .replace(/!\[([^\]]*)\]\([^()]*(?:\([^()]*\))*[^()]*\)/g, '$1')
15
+ // Links [text](url) → text (supports one level of balanced parens in URL)
16
+ .replace(/\[([^\]]*)\]\([^()]*(?:\([^()]*\))*[^()]*\)/g, '$1')
17
+ // ── Line-start structural markers (before inline formatting) ──
18
+ // Must run before bold/italic so that `* *text*` strips the list
19
+ // marker first, leaving `*text*` for the emphasis regex.
20
+ // Blockquote markers first — so `> ## Heading` and `> - Item` are
21
+ // unquoted before heading/list regexes run. Handles nested (`> > text`)
22
+ // and no-space (`>text`) variants in a single pass.
23
+ .replace(/(^|\n)(>\s*)+/g, '$1')
24
+ // Heading markers (# at start of line)
25
+ .replace(/(^|\n)#{1,6}\s+/g, '$1')
26
+ // Ordered list markers (1. 2. etc at start of line)
27
+ .replace(/(^|\n)\d+\.\s+/g, '$1')
28
+ // Unordered list markers (- * +) at start of line
29
+ .replace(/(^|\n)[*+-]\s+/g, '$1')
30
+ // ── Inline formatting ──
31
+ // Bold/italic with * only (**text**, *text*, ***text***)
32
+ // Intentionally skips _underscore_ variants to avoid corrupting
33
+ // snake_case identifiers and __dunder__ names common in LLM output.
34
+ // Uses CommonMark-style flanking rules: opening * must be followed by
35
+ // non-whitespace, closing * must be preceded by non-whitespace. This
36
+ // rejects spaced math like `2 * 3 * 4` while matching `*italic*`.
37
+ .replace(/(?<!\w)\*{1,3}(?!\s)([^*\n]+?)(?<!\s)\*{1,3}(?!\w)/g, '$1')
38
+ // Strikethrough ~~text~~
39
+ .replace(/~~([^~]+?)~~/g, '$1')
40
+ // Inline code `text`
41
+ .replace(/`([^`]+?)`/g, '$1')
42
+ // Horizontal rules (--- *** ___) on their own line
43
+ .replace(/(^|\n)([-*_]){3,}(\n|$)/g, '$1$3')
44
+ // Collapse multiple spaces and normalize whitespace
45
+ .replace(/ {2,}/g, ' ')
46
+ .trim()
47
+ )
48
+ }
@@ -1,12 +1,10 @@
1
1
  /**
2
- * On plugin init, patches openclaw.json to add the `clawly-model-gateway`
3
- * model provider entry. Credentials come from pluginConfig; the model list
4
- * is derived from `agents.defaults.model`
5
- * already present in the config.
2
+ * Reconciles the `clawly-model-gateway` provider in openclaw.json from
3
+ * pluginConfig inputs and the current runtime-facing agent defaults.
6
4
  *
7
- * This runs synchronously during plugin registration (before gateway_start).
8
- * OpenClaw loads the config file once at startup, so writing before the
9
- * gateway fully starts ensures the provider is active on first boot.
5
+ * This is a runtime reconcile path, not a provision/bootstrap contract.
6
+ * The first-boot correctness of gateway startup should not rely on this file
7
+ * write winning a race against OpenClaw's internal startup snapshot timing.
10
8
  */
11
9
 
12
10
  import fs from 'node:fs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.26.0-beta.1",
3
+ "version": "1.26.1-beta.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {