@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 +11 -8
- package/gateway/index.ts +3 -0
- package/gateway/notification.ts +9 -3
- package/gateway/offline-push.test.ts +10 -0
- package/gateway/offline-push.ts +7 -2
- package/gateway/plugins.ts +50 -2
- package/gateway/session-sanitize.ts +263 -0
- package/lib/stripMarkdown.test.ts +156 -0
- package/lib/stripMarkdown.ts +48 -0
- package/package.json +1 -1
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 (
|
|
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
|
-
|
|
501
|
-
|
|
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
|
}
|
package/gateway/notification.ts
CHANGED
|
@@ -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
|
|
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 — "${
|
|
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')
|
package/gateway/offline-push.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/gateway/plugins.ts
CHANGED
|
@@ -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 (
|
|
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', () => {
|
|
52
|
+
expect(stripMarkdown('See  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 (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
|
+
}
|