@2en/clawly-plugins 1.26.0 → 1.26.1-beta.1
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 +99 -18
- package/gateway/config-timezone.ts +56 -0
- package/gateway/index.ts +5 -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/model-gateway-setup.ts +22 -4
- 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,
|
|
@@ -43,8 +44,39 @@ export interface ConfigPluginConfig {
|
|
|
43
44
|
posthogHost?: string
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function
|
|
47
|
-
return
|
|
47
|
+
function asObj(value: unknown): Record<string, unknown> {
|
|
48
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
49
|
+
? (value as Record<string, unknown>)
|
|
50
|
+
: {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readFilePluginConfig(config: Record<string, unknown>): ConfigPluginConfig {
|
|
54
|
+
const plugins = asObj(config.plugins)
|
|
55
|
+
const entries = asObj(plugins.entries)
|
|
56
|
+
const raw = asObj(entries['clawly-plugins'])
|
|
57
|
+
const fileConfig = raw?.config
|
|
58
|
+
return fileConfig && typeof fileConfig === 'object' && !Array.isArray(fileConfig)
|
|
59
|
+
? (fileConfig as ConfigPluginConfig)
|
|
60
|
+
: {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mergeDefinedPluginConfig(
|
|
64
|
+
fileConfig: ConfigPluginConfig,
|
|
65
|
+
runtimeConfig: ConfigPluginConfig,
|
|
66
|
+
): ConfigPluginConfig {
|
|
67
|
+
const merged = {...fileConfig}
|
|
68
|
+
for (const [key, value] of Object.entries(runtimeConfig)) {
|
|
69
|
+
if (value !== undefined) {
|
|
70
|
+
merged[key as keyof ConfigPluginConfig] = value
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return merged
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toPC(api: PluginApi, config: Record<string, unknown>): ConfigPluginConfig {
|
|
77
|
+
const fileConfig = readFilePluginConfig(config)
|
|
78
|
+
const runtimeConfig = (api.pluginConfig ?? {}) as ConfigPluginConfig
|
|
79
|
+
return mergeDefinedPluginConfig(fileConfig, runtimeConfig)
|
|
48
80
|
}
|
|
49
81
|
|
|
50
82
|
const DEFAULT_MODEL = `${PROVIDER_NAME}/anthropic/claude-sonnet-4.6`
|
|
@@ -350,6 +382,58 @@ export function patchWebSearch(config: Record<string, unknown>, pc: ConfigPlugin
|
|
|
350
382
|
return dirty
|
|
351
383
|
}
|
|
352
384
|
|
|
385
|
+
export function patchMemorySearch(
|
|
386
|
+
config: Record<string, unknown>,
|
|
387
|
+
pc: ConfigPluginConfig,
|
|
388
|
+
): boolean {
|
|
389
|
+
if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) return false
|
|
390
|
+
|
|
391
|
+
let dirty = false
|
|
392
|
+
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
393
|
+
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
394
|
+
const ms = (defaults.memorySearch ?? {}) as Record<string, unknown>
|
|
395
|
+
const remote = (ms.remote ?? {}) as Record<string, unknown>
|
|
396
|
+
const batch = (remote.batch ?? {}) as Record<string, unknown>
|
|
397
|
+
|
|
398
|
+
// Provider: enforce (proxy mimics OpenAI API)
|
|
399
|
+
if (ms.provider !== 'openai') {
|
|
400
|
+
ms.provider = 'openai'
|
|
401
|
+
dirty = true
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Model: set-if-missing
|
|
405
|
+
if (ms.model === undefined) {
|
|
406
|
+
ms.model = 'text-embedding-3-small'
|
|
407
|
+
dirty = true
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Remote credentials: enforce
|
|
411
|
+
if (remote.baseUrl !== pc.modelGatewayBaseUrl) {
|
|
412
|
+
remote.baseUrl = pc.modelGatewayBaseUrl
|
|
413
|
+
dirty = true
|
|
414
|
+
}
|
|
415
|
+
if (remote.apiKey !== pc.modelGatewayToken) {
|
|
416
|
+
remote.apiKey = pc.modelGatewayToken
|
|
417
|
+
dirty = true
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Batch API not supported through proxy: enforce disabled
|
|
421
|
+
if (batch.enabled !== false) {
|
|
422
|
+
batch.enabled = false
|
|
423
|
+
dirty = true
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (dirty) {
|
|
427
|
+
remote.batch = batch
|
|
428
|
+
ms.remote = remote
|
|
429
|
+
defaults.memorySearch = ms
|
|
430
|
+
agents.defaults = defaults
|
|
431
|
+
config.agents = agents
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return dirty
|
|
435
|
+
}
|
|
436
|
+
|
|
353
437
|
export function patchSession(config: Record<string, unknown>): boolean {
|
|
354
438
|
let dirty = false
|
|
355
439
|
|
|
@@ -384,12 +468,6 @@ export function patchSession(config: Record<string, unknown>): boolean {
|
|
|
384
468
|
const PLUGIN_ID = 'clawly-plugins'
|
|
385
469
|
const NPM_PKG_NAME = '@2en/clawly-plugins'
|
|
386
470
|
|
|
387
|
-
function asObj(value: unknown): Record<string, unknown> {
|
|
388
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
389
|
-
? (value as Record<string, unknown>)
|
|
390
|
-
: {}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
471
|
/**
|
|
394
472
|
* Self-healing: reconstruct missing `plugins.installs.clawly-plugins` record.
|
|
395
473
|
* Older provisioned sprites had this record destroyed by a full-overwrite bug
|
|
@@ -469,7 +547,8 @@ function reconcileRuntimeConfig(
|
|
|
469
547
|
dirty = patchSession(config) || dirty
|
|
470
548
|
dirty = patchTts(config, pc) || dirty
|
|
471
549
|
dirty = patchWebSearch(config, pc) || dirty
|
|
472
|
-
dirty =
|
|
550
|
+
dirty = patchMemorySearch(config, pc) || dirty
|
|
551
|
+
dirty = patchModelGateway(config, api, pc) || dirty
|
|
473
552
|
return dirty
|
|
474
553
|
}
|
|
475
554
|
|
|
@@ -486,21 +565,23 @@ export function setupConfig(api: PluginApi): void {
|
|
|
486
565
|
|
|
487
566
|
const configPath = path.join(stateDir, 'openclaw.json')
|
|
488
567
|
const config = readOpenclawConfig(configPath)
|
|
489
|
-
const pc = toPC(api)
|
|
568
|
+
const pc = toPC(api, config)
|
|
490
569
|
|
|
491
570
|
let dirty = false
|
|
492
571
|
dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
|
|
493
572
|
dirty = reconcileRuntimeConfig(api, config, pc) || dirty
|
|
494
573
|
|
|
495
|
-
if (
|
|
574
|
+
if (dirty) {
|
|
575
|
+
try {
|
|
576
|
+
writeOpenclawConfig(configPath, config)
|
|
577
|
+
api.logger.info('Config setup: patched openclaw.json.')
|
|
578
|
+
} catch (err) {
|
|
579
|
+
api.logger.error(`Config setup failed: ${(err as Error).message}`)
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
496
582
|
api.logger.info('Config setup: no changes needed.')
|
|
497
|
-
return
|
|
498
583
|
}
|
|
499
584
|
|
|
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
|
-
}
|
|
585
|
+
// Best-effort: clear stale delivery fields from the main session on every restart
|
|
586
|
+
autoSanitizeSession(api)
|
|
506
587
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timezone sync RPC: writes agents.defaults.userTimezone to openclaw.json
|
|
3
|
+
* without triggering a gateway restart.
|
|
4
|
+
*
|
|
5
|
+
* Methods:
|
|
6
|
+
* - clawly.config.setTimezone({ timezone }) → { changed, timezone }
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
import type {PluginApi} from '../types'
|
|
12
|
+
import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
|
|
13
|
+
|
|
14
|
+
export function registerConfigTimezone(api: PluginApi) {
|
|
15
|
+
api.registerGatewayMethod('clawly.config.setTimezone', async ({params, respond}) => {
|
|
16
|
+
const timezone = typeof params.timezone === 'string' ? params.timezone : ''
|
|
17
|
+
if (!timezone) {
|
|
18
|
+
respond(true, {changed: false, timezone: '', error: 'Missing timezone param'})
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
23
|
+
if (!stateDir) {
|
|
24
|
+
respond(true, {changed: false, timezone, error: 'Cannot resolve state dir'})
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
29
|
+
const config = readOpenclawConfig(configPath)
|
|
30
|
+
|
|
31
|
+
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
32
|
+
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
33
|
+
const current = defaults.userTimezone
|
|
34
|
+
|
|
35
|
+
if (current === timezone) {
|
|
36
|
+
respond(true, {changed: false, timezone})
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
defaults.userTimezone = timezone
|
|
41
|
+
agents.defaults = defaults
|
|
42
|
+
config.agents = agents
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
writeOpenclawConfig(configPath, config)
|
|
46
|
+
api.logger.info(`config-timezone: set userTimezone to ${timezone}`)
|
|
47
|
+
respond(true, {changed: true, timezone})
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
50
|
+
api.logger.error(`config-timezone: write failed — ${msg}`)
|
|
51
|
+
respond(true, {changed: false, timezone, error: `Write failed: ${msg}`})
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
api.logger.info('config-timezone: registered clawly.config.setTimezone')
|
|
56
|
+
}
|
package/gateway/index.ts
CHANGED
|
@@ -3,6 +3,9 @@ import {registerAgentSend} from './agent'
|
|
|
3
3
|
import {registerAnalytics} from './analytics'
|
|
4
4
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
5
5
|
import {registerConfigRepair} from './config-repair'
|
|
6
|
+
import {registerConfigTimezone} from './config-timezone'
|
|
7
|
+
|
|
8
|
+
import {registerSessionSanitize} from './session-sanitize'
|
|
6
9
|
import {registerCronDelivery} from './cron-delivery'
|
|
7
10
|
import {registerCronTelemetry} from './cron-telemetry'
|
|
8
11
|
import {initOtel, shutdownOtel} from './otel'
|
|
@@ -54,6 +57,8 @@ export function registerGateway(api: PluginApi) {
|
|
|
54
57
|
registerCronTelemetry(api)
|
|
55
58
|
registerAnalytics(api)
|
|
56
59
|
registerConfigRepair(api)
|
|
60
|
+
registerConfigTimezone(api)
|
|
61
|
+
registerSessionSanitize(api)
|
|
57
62
|
registerPairing(api)
|
|
58
63
|
registerVersion(api)
|
|
59
64
|
}
|
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
|
+
}
|
package/model-gateway-setup.ts
CHANGED
|
@@ -13,6 +13,10 @@ import path from 'node:path'
|
|
|
13
13
|
import type {PluginApi} from './index'
|
|
14
14
|
|
|
15
15
|
export const PROVIDER_NAME = 'clawly-model-gateway'
|
|
16
|
+
type ModelGatewayConfigInputs = {
|
|
17
|
+
modelGatewayBaseUrl?: string
|
|
18
|
+
modelGatewayToken?: string
|
|
19
|
+
}
|
|
16
20
|
|
|
17
21
|
/** Additional models available through the model gateway (beyond env-configured defaults). */
|
|
18
22
|
export const EXTRA_GATEWAY_MODELS: Array<{
|
|
@@ -83,7 +87,17 @@ export function writeOpenclawConfig(configPath: string, config: Record<string, u
|
|
|
83
87
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
export function patchModelGateway(
|
|
90
|
+
export function patchModelGateway(
|
|
91
|
+
config: Record<string, unknown>,
|
|
92
|
+
api: PluginApi,
|
|
93
|
+
inputs?: ModelGatewayConfigInputs,
|
|
94
|
+
): boolean {
|
|
95
|
+
const cfg =
|
|
96
|
+
inputs ??
|
|
97
|
+
(api.pluginConfig as Record<string, unknown> | undefined as
|
|
98
|
+
| ModelGatewayConfigInputs
|
|
99
|
+
| undefined)
|
|
100
|
+
|
|
87
101
|
// If provider already exists, check if extra models or aliases need updating.
|
|
88
102
|
// This runs before the credentials check because provisioned sprites have
|
|
89
103
|
// credentials in openclaw.json directly, not in pluginConfig.
|
|
@@ -116,7 +130,6 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
|
|
|
116
130
|
|
|
117
131
|
// Backfill pluginConfig from existing provider credentials (legacy sprites
|
|
118
132
|
// provisioned before plugin config was written during configure phase).
|
|
119
|
-
const cfg = api.pluginConfig as Record<string, unknown> | undefined
|
|
120
133
|
if (
|
|
121
134
|
existingProvider.baseUrl &&
|
|
122
135
|
existingProvider.apiKey &&
|
|
@@ -149,7 +162,6 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
|
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
// No existing provider — need pluginConfig credentials to create one
|
|
152
|
-
const cfg = api.pluginConfig as Record<string, unknown> | undefined
|
|
153
165
|
const baseUrl =
|
|
154
166
|
typeof cfg?.modelGatewayBaseUrl === 'string' ? cfg.modelGatewayBaseUrl.replace(/\/$/, '') : ''
|
|
155
167
|
const token = typeof cfg?.modelGatewayToken === 'string' ? cfg.modelGatewayToken : ''
|
|
@@ -212,7 +224,13 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
|
|
|
212
224
|
return true
|
|
213
225
|
}
|
|
214
226
|
|
|
215
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Standalone wrapper — reads config, patches, writes. Used by tests.
|
|
229
|
+
*
|
|
230
|
+
* This helper intentionally does not apply the file-backed pluginConfig fallback
|
|
231
|
+
* from setupConfig(); production startup should go through the full runtime
|
|
232
|
+
* reconcile path there.
|
|
233
|
+
*/
|
|
216
234
|
export function setupModelGateway(api: PluginApi): void {
|
|
217
235
|
const stateDir = api.runtime.state.resolveStateDir()
|
|
218
236
|
if (!stateDir) {
|