@2en/clawly-plugins 1.26.0 → 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
@@ -19,6 +19,7 @@ import fs from 'node:fs'
19
19
  import path from 'node:path'
20
20
 
21
21
  import type {PluginApi} from './index'
22
+ import {autoSanitizeSession} from './gateway/session-sanitize'
22
23
  import {
23
24
  PROVIDER_NAME,
24
25
  patchModelGateway,
@@ -492,15 +493,17 @@ export function setupConfig(api: PluginApi): void {
492
493
  dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
493
494
  dirty = reconcileRuntimeConfig(api, config, pc) || dirty
494
495
 
495
- 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 {
496
504
  api.logger.info('Config setup: no changes needed.')
497
- return
498
505
  }
499
506
 
500
- try {
501
- writeOpenclawConfig(configPath, config)
502
- api.logger.info('Config setup: patched openclaw.json.')
503
- } catch (err) {
504
- api.logger.error(`Config setup failed: ${(err as Error).message}`)
505
- }
507
+ // Best-effort: clear stale delivery fields from the main session on every restart
508
+ autoSanitizeSession(api)
506
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.26.0",
3
+ "version": "1.26.1-beta.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {