@dcrays/dcgchat-test 0.4.29 → 0.5.0-alpha.2
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/index.ts +8 -10
- package/openclaw.plugin.json +17 -1
- package/package.json +15 -2
- package/src/bot.ts +79 -64
- package/src/channel.ts +11 -7
- package/src/cron.ts +7 -2
- package/src/gateway/cronFinishedPayload.ts +118 -0
- package/src/gateway/index.ts +7 -2
- package/src/monitor.ts +5 -5
- package/src/request/userInfo.ts +1 -7
- package/src/sessionTermination.ts +14 -0
- package/src/skill.ts +17 -22
- package/src/tool.ts +10 -29
- package/src/tools/messageTool.ts +59 -10
- package/src/transport.ts +3 -0
- package/src/utils/agentErrors.ts +23 -0
- package/src/utils/gatewayMsgHanlder.ts +38 -9
- package/src/utils/global.ts +2 -1
- package/src/utils/workspaceFilePaths.ts +89 -0
package/index.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk'
|
|
1
|
+
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core'
|
|
3
2
|
import { dcgchatPlugin } from './src/channel.js'
|
|
4
3
|
import { setDcgchatRuntime, setWorkspaceDir } from './src/utils/global.js'
|
|
5
4
|
import { monitoringToolMessage } from './src/tool.js'
|
|
6
5
|
import { setOpenClawConfig } from './src/utils/global.js'
|
|
7
6
|
import { createDcgchatMessageTool } from './src/tools/messageTool.js'
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
export default defineChannelPluginEntry({
|
|
10
9
|
id: "dcgchat-test",
|
|
11
10
|
name: '书灵墨宝',
|
|
12
11
|
description: '连接 OpenClaw 与 书灵墨宝 产品(WebSocket)',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
setDcgchatRuntime(
|
|
12
|
+
plugin: dcgchatPlugin,
|
|
13
|
+
setRuntime: (runtime) => {
|
|
14
|
+
setDcgchatRuntime(runtime)
|
|
15
|
+
},
|
|
16
|
+
registerFull: (api) => {
|
|
16
17
|
monitoringToolMessage(api)
|
|
17
18
|
setOpenClawConfig(api.config)
|
|
18
|
-
api.registerChannel({ plugin: dcgchatPlugin })
|
|
19
19
|
setWorkspaceDir(api.config?.agents?.defaults?.workspace)
|
|
20
20
|
api.registerTool((ctx) => {
|
|
21
21
|
return createDcgchatMessageTool(ctx)
|
|
22
22
|
})
|
|
23
23
|
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export default plugin
|
|
24
|
+
})
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,9 +3,25 @@
|
|
|
3
3
|
"channels": [
|
|
4
4
|
"dcgchat-test"
|
|
5
5
|
],
|
|
6
|
+
"description": "Gateway `event=cron` + `action=finished` 时:优先在 payload 中按 schemas/gateway-cron-finished.payload.json 提供 `attachments`;若缺省,插件在工作区规则下从 summary 回退提取路径并经 sendMedia 发送。",
|
|
6
7
|
"configSchema": {
|
|
7
8
|
"type": "object",
|
|
8
9
|
"additionalProperties": false,
|
|
9
|
-
"properties": {
|
|
10
|
+
"properties": {
|
|
11
|
+
"allowedPaths": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "string"
|
|
15
|
+
},
|
|
16
|
+
"description": "允许发送文件的额外路径列表"
|
|
17
|
+
},
|
|
18
|
+
"allowedAttachmentExtensions": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"description": "在插件默认附件扩展名之外额外允许的后缀,如 [\".go\", \".rs\"]。默认已含常见办公/媒体及 .py/.ipynb 等;可省略本项。"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
10
26
|
}
|
|
11
27
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcrays/dcgchat-test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"index.ts",
|
|
10
10
|
"src",
|
|
11
|
+
"schemas",
|
|
11
12
|
"openclaw.plugin.json"
|
|
12
13
|
],
|
|
13
14
|
"keywords": [
|
|
@@ -16,6 +17,14 @@
|
|
|
16
17
|
"websocket",
|
|
17
18
|
"ai"
|
|
18
19
|
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"openclaw": ">=2026.4.12"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"openclaw": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
19
28
|
"dependencies": {
|
|
20
29
|
"ali-oss": "file:src/libs/ali-oss-6.23.0.tgz",
|
|
21
30
|
"axios": "file:src/libs/axios-1.13.6.tgz",
|
|
@@ -39,7 +48,11 @@
|
|
|
39
48
|
"install": {
|
|
40
49
|
"npmSpec": "@dcrays/dcgchat-test",
|
|
41
50
|
"localPath": "extensions/dcgchat-test",
|
|
42
|
-
"defaultChoice": "npm"
|
|
51
|
+
"defaultChoice": "npm",
|
|
52
|
+
"minHostVersion": ">=2026.4.12"
|
|
53
|
+
},
|
|
54
|
+
"compat": {
|
|
55
|
+
"pluginApi": ">=2026.4.12"
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
58
|
}
|
package/src/bot.ts
CHANGED
|
@@ -1,34 +1,20 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import type { ReplyPayload } from 'openclaw/plugin-sdk'
|
|
4
|
-
import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
|
|
3
|
+
import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline'
|
|
5
4
|
import type { InboundMessage } from './types.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
getDcgchatRuntime,
|
|
9
|
-
getOpenClawConfig,
|
|
10
|
-
getSessionKey,
|
|
11
|
-
getWorkspaceDir,
|
|
12
|
-
setMsgStatus
|
|
13
|
-
} from './utils/global.js'
|
|
5
|
+
import { getSessionKey, getWorkspaceDir, setMsgStatus } from './utils/global.js'
|
|
6
|
+
import { clearSentMediaKeys, getDcgchatRuntime, getOpenClawConfig } from './utils/global.js'
|
|
14
7
|
import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
|
|
15
8
|
import { generateSignUrl } from './request/api.js'
|
|
16
9
|
import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
|
|
17
10
|
import { dcgLogger } from './utils/log.js'
|
|
11
|
+
import { contextOverflowUserHint, isContextOverflowError } from './utils/agentErrors.js'
|
|
18
12
|
import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
|
|
19
13
|
import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
|
|
20
14
|
import { waitUntilSubagentsIdle } from './tool.js'
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
clearSessionStreamSuppression,
|
|
25
|
-
interruptLocalDispatchAndGateway,
|
|
26
|
-
isSessionStreamSuppressed,
|
|
27
|
-
preemptInboundQueueForStop,
|
|
28
|
-
releaseDispatchAbortIfCurrent,
|
|
29
|
-
runInboundTurnSequenced,
|
|
30
|
-
setActiveRunIdForSession
|
|
31
|
-
} from './sessionTermination.js'
|
|
15
|
+
import { beginSupersedingUserTurn, clearActiveRunIdForSession, clearSessionStreamSuppression } from './sessionTermination.js'
|
|
16
|
+
import { interruptLocalDispatchAndGateway, isSessionStreamSuppressed, preemptInboundQueueForStop } from './sessionTermination.js'
|
|
17
|
+
import { releaseDispatchAbortIfCurrent, runInboundTurnSequenced, setActiveRunIdForSession } from './sessionTermination.js'
|
|
32
18
|
|
|
33
19
|
type MediaInfo = {
|
|
34
20
|
path: string
|
|
@@ -75,7 +61,6 @@ async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promi
|
|
|
75
61
|
const core = getDcgchatRuntime()
|
|
76
62
|
const out: MediaInfo[] = []
|
|
77
63
|
dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
|
|
78
|
-
|
|
79
64
|
for (let i = 0; i < files.length; i++) {
|
|
80
65
|
const file = files[i]
|
|
81
66
|
try {
|
|
@@ -232,6 +217,8 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
232
217
|
}
|
|
233
218
|
|
|
234
219
|
try {
|
|
220
|
+
/** 为 true 表示 createReplyDispatcherWithTyping 的 onError 已执行(含 sendFinal),内部 catch 勿再收尾 */
|
|
221
|
+
let dispatchReplyErrorHandledByOnError = false
|
|
235
222
|
if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
|
|
236
223
|
const workspaceDir = getWorkspaceDir()
|
|
237
224
|
const skill = msg.content.skills_scope[0]
|
|
@@ -287,6 +274,27 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
287
274
|
/** 与 Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
|
|
288
275
|
let lastStreamSnapshot = ''
|
|
289
276
|
|
|
277
|
+
/** Core 在 block/final(及可选 tool)路径走 `deliver`,流式 token 才走 `onPartialReply`;二者需共用快照,避免双发或漏发 */
|
|
278
|
+
const emitAssistantTextChunkFromSnapshot = (raw: string | undefined) => {
|
|
279
|
+
if (!raw) return
|
|
280
|
+
const t = raw
|
|
281
|
+
let delta = ''
|
|
282
|
+
if (t.startsWith(lastStreamSnapshot)) {
|
|
283
|
+
delta = t.slice(lastStreamSnapshot.length)
|
|
284
|
+
lastStreamSnapshot = t
|
|
285
|
+
} else if (lastStreamSnapshot.startsWith(t)) {
|
|
286
|
+
// 快照缩短(模型修订等):不重复下发
|
|
287
|
+
} else {
|
|
288
|
+
delta = t
|
|
289
|
+
lastStreamSnapshot = t
|
|
290
|
+
}
|
|
291
|
+
if (delta.trim()) {
|
|
292
|
+
const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
|
|
293
|
+
streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
|
|
294
|
+
sendChunk(delta, outboundCtx, prev)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
290
298
|
if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
|
|
291
299
|
const workspaceDir = getWorkspaceDir()
|
|
292
300
|
const skill = msg.content.skills_scope[0]
|
|
@@ -308,6 +316,7 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
308
316
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
|
|
309
317
|
onReplyStart: async () => {},
|
|
310
318
|
deliver: async (payload: ReplyPayload, info) => {
|
|
319
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) return
|
|
311
320
|
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
312
321
|
const mediaList = resolveReplyMediaList(payload)
|
|
313
322
|
for (const mediaUrl of mediaList) {
|
|
@@ -316,14 +325,27 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
316
325
|
sentMediaKeys.add(key)
|
|
317
326
|
await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
|
|
318
327
|
}
|
|
319
|
-
|
|
328
|
+
if (payload?.text?.trim()) {
|
|
329
|
+
emitAssistantTextChunkFromSnapshot(payload.text)
|
|
330
|
+
}
|
|
331
|
+
dcgLogger(
|
|
332
|
+
`[deliver]: kind=${info.kind} len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`
|
|
333
|
+
)
|
|
320
334
|
},
|
|
321
335
|
onError: (err: unknown, info: { kind: string }) => {
|
|
336
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
|
|
337
|
+
dcgLogger(`${info.kind} reply failed (stale handler, ignored): ${String(err)}`, 'error')
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
dispatchReplyErrorHandledByOnError = true
|
|
322
341
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
342
|
+
const suppressed = isSessionStreamSuppressed(dcgSessionKey)
|
|
343
|
+
if (!suppressed && isContextOverflowError(err)) {
|
|
344
|
+
sendText(contextOverflowUserHint(), outboundCtx)
|
|
345
|
+
}
|
|
323
346
|
sendFinal(outboundCtx, 'error')
|
|
324
347
|
clearActiveRunIdForSession(dcgSessionKey)
|
|
325
348
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
326
|
-
const suppressed = isSessionStreamSuppressed(dcgSessionKey)
|
|
327
349
|
dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
|
|
328
350
|
},
|
|
329
351
|
onIdle: () => {
|
|
@@ -338,27 +360,28 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
338
360
|
dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
|
|
339
361
|
}
|
|
340
362
|
|
|
341
|
-
if (systemCommand.includes(text?.trim())) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
} else
|
|
363
|
+
// if (systemCommand.includes(text?.trim())) {
|
|
364
|
+
// dcgLogger(`dispatching ${text?.trim()}`)
|
|
365
|
+
// await core.channel.reply.withReplyDispatcher({
|
|
366
|
+
// dispatcher,
|
|
367
|
+
// onSettled: () => markDispatchIdle(),
|
|
368
|
+
// run: () =>
|
|
369
|
+
// core.channel.reply.dispatchReplyFromConfig({
|
|
370
|
+
// ctx: ctxPayload,
|
|
371
|
+
// cfg: config,
|
|
372
|
+
// dispatcher,
|
|
373
|
+
// replyOptions: {
|
|
374
|
+
// ...replyOptions,
|
|
375
|
+
// abortSignal: dispatchAbort!.signal,
|
|
376
|
+
// onModelSelected: prefixContext.onModelSelected,
|
|
377
|
+
// onAgentRunStart: (runId) => {
|
|
378
|
+
// setActiveRunIdForSession(dcgSessionKey, runId)
|
|
379
|
+
// }
|
|
380
|
+
// }
|
|
381
|
+
// })
|
|
382
|
+
// })
|
|
383
|
+
// } else
|
|
384
|
+
if (stopCommand.includes(text?.trim())) {
|
|
362
385
|
const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
|
|
363
386
|
await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
|
|
364
387
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
@@ -401,27 +424,11 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
401
424
|
setActiveRunIdForSession(dcgSessionKey, runId)
|
|
402
425
|
},
|
|
403
426
|
onPartialReply: async (payload: ReplyPayload) => {
|
|
427
|
+
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) return
|
|
404
428
|
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
405
|
-
|
|
406
429
|
// --- Streaming text chunks ---
|
|
407
430
|
if (payload.text) {
|
|
408
|
-
|
|
409
|
-
let delta = ''
|
|
410
|
-
if (t.startsWith(lastStreamSnapshot)) {
|
|
411
|
-
delta = t.slice(lastStreamSnapshot.length)
|
|
412
|
-
lastStreamSnapshot = t
|
|
413
|
-
} else if (lastStreamSnapshot.startsWith(t)) {
|
|
414
|
-
// 快照缩短(模型修订等):不重复下发
|
|
415
|
-
} else {
|
|
416
|
-
// 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
|
|
417
|
-
delta = t
|
|
418
|
-
lastStreamSnapshot = t
|
|
419
|
-
}
|
|
420
|
-
if (delta.trim()) {
|
|
421
|
-
const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
|
|
422
|
-
streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
|
|
423
|
-
sendChunk(delta, outboundCtx, prev)
|
|
424
|
-
}
|
|
431
|
+
emitAssistantTextChunkFromSnapshot(payload.text)
|
|
425
432
|
} else {
|
|
426
433
|
dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
|
|
427
434
|
}
|
|
@@ -442,6 +449,13 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
442
449
|
dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
|
|
443
450
|
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
444
451
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
452
|
+
if (!dispatchReplyErrorHandledByOnError && isContextOverflowError(err) && !isSessionStreamSuppressed(dcgSessionKey)) {
|
|
453
|
+
sendText(contextOverflowUserHint(), outboundCtx)
|
|
454
|
+
sendFinal(outboundCtx, 'error')
|
|
455
|
+
clearActiveRunIdForSession(dcgSessionKey)
|
|
456
|
+
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
445
459
|
}
|
|
446
460
|
} finally {
|
|
447
461
|
releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
|
|
@@ -495,6 +509,7 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
495
509
|
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
496
510
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
497
511
|
}
|
|
498
|
-
|
|
512
|
+
const rawErr = err instanceof Error ? err.message : String(err)
|
|
513
|
+
sendError(isContextOverflowError(err) ? contextOverflowUserHint() : rawErr, outboundCtx)
|
|
499
514
|
}
|
|
500
515
|
}
|
package/src/channel.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
-
import type { ChannelPlugin, OpenClawConfig
|
|
3
|
-
import {
|
|
2
|
+
import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk'
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id'
|
|
4
4
|
import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
|
|
5
5
|
import { ossUpload } from './request/oss.js'
|
|
6
6
|
import {
|
|
@@ -204,14 +204,17 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
|
|
|
204
204
|
dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
let mediaStat: fs.Stats
|
|
208
208
|
try {
|
|
209
|
-
|
|
210
|
-
dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
|
|
211
|
-
return
|
|
212
|
-
}
|
|
209
|
+
mediaStat = fs.statSync(mediaUrl)
|
|
213
210
|
} catch (err) {
|
|
214
211
|
dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (!mediaStat.isFile()) {
|
|
215
|
+
const kind = mediaStat.isDirectory() ? 'directory' : 'non-regular'
|
|
216
|
+
dcgLogger(`dcgchat: sendMedia skipped (not a regular file, ${kind}): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
|
|
217
|
+
return
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
if (mediaUrl && msgCtx.sessionId) {
|
|
@@ -363,6 +366,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
363
366
|
'书灵墨宝 / 内置 `message`:回复当前会话时 **不要传 `target`**,由 OpenClaw 使用工具上下文里的 `currentChannelId`(来自入站 `To`,即当前 SessionKey)。',
|
|
364
367
|
'必须指定会话时:`target` 须为上下文中出现的 **整段 SessionKey,逐字一致**(如 `agent:…:mobook:direct:…` 或以 `agent:` 开头的路由键);**禁止**使用 `userId`、`From`、纯数字会话号等代替。',
|
|
365
368
|
'生成文件后,**尽可能不要**把文件路径、地址直接告诉用户;把文件名告诉用户;须通过工具发文件,勿在正文里直接输出可访问路径。',
|
|
369
|
+
'用户待发送文件若**不在**兼容挂载 **`/workspace/`**、**`/mobook/`**(及 Windows 下 `workspace`、`mobook` 盘符路径),且也不在 **当前 Agent 工作区** 或通道 **`allowedPaths`** 所列目录内:**建议**用户「先复制进工作区再发」作为首选。',
|
|
366
370
|
'使用 `dcgchat_message` 时同样遵守上述 SessionKey 规则(该工具通常由插件注入当前会话,一般无需自造 target)。'
|
|
367
371
|
]
|
|
368
372
|
},
|
package/src/cron.ts
CHANGED
|
@@ -135,7 +135,12 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
|
|
|
135
135
|
)
|
|
136
136
|
sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* 发送定时完成摘要(纯文本)。附件须由网关在 `payload.attachments` 中给出,
|
|
140
|
+
* 并由 `gatewayMsgHanlder` 在调用本函数前经 `sendDcgchatMedia` 发送。
|
|
141
|
+
* @param hasSchemaAttachments `payload.attachments` 非空时为 true,用于 `message_tags.hasFile`
|
|
142
|
+
*/
|
|
143
|
+
export const finishedDcgchatCron = async (jobId: string, summary: string, hasSchemaAttachments?: boolean) => {
|
|
139
144
|
const id = jobId?.trim()
|
|
140
145
|
if (!id) {
|
|
141
146
|
dcgLogger('finishedDcgchatCron: empty jobId', 'error')
|
|
@@ -160,7 +165,7 @@ export const finishedDcgchatCron = async (jobId: string, summary: string, hasFil
|
|
|
160
165
|
real_mobook: !sessionId ? 1 : ''
|
|
161
166
|
})
|
|
162
167
|
const message_tags = { source: 'cron' } as Record<string, string | boolean>
|
|
163
|
-
if (
|
|
168
|
+
if (hasSchemaAttachments) {
|
|
164
169
|
message_tags.hasFile = true
|
|
165
170
|
}
|
|
166
171
|
wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与 `schemas/gateway-cron-finished.payload.json` 对齐:`attachments` 为首选。
|
|
3
|
+
* 若宿主未下发 `attachments`(当前 Gateway 常见情况),则仅在 `summary` 中按工作区规则提取路径(见 `resolveCronFinishedLocalPaths`),仍经 `sendDcgchatMedia` 发送。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
expandTildePath,
|
|
8
|
+
extractWorkspaceFilePathsFromText,
|
|
9
|
+
isAllowedSendPath,
|
|
10
|
+
trimArtifactPathCandidate
|
|
11
|
+
} from '../utils/workspaceFilePaths.js'
|
|
12
|
+
|
|
13
|
+
export type CronFinishedAttachmentItem = string | { path?: string; file?: string }
|
|
14
|
+
|
|
15
|
+
/** 网关 `event: "cron"` 且 `action === "finished"` 时插件消费的 payload 子集 */
|
|
16
|
+
export type CronGatewayFinishedPayload = {
|
|
17
|
+
jobId?: string
|
|
18
|
+
action?: string
|
|
19
|
+
status?: string
|
|
20
|
+
summary?: string
|
|
21
|
+
delivered?: boolean
|
|
22
|
+
deliveryStatus?: string
|
|
23
|
+
sessionId?: string
|
|
24
|
+
sessionKey?: string
|
|
25
|
+
attachments?: CronFinishedAttachmentItem[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** JSON Schema 中 `attachments` 数组片段(便于在代码里引用、与仓库内 JSON 文件保持语义一致) */
|
|
29
|
+
export const CRON_FINISHED_ATTACHMENTS_ITEMS_SCHEMA = {
|
|
30
|
+
type: 'array',
|
|
31
|
+
description: '本轮定时任务产出的本地文件路径,按顺序经通道发送为附件',
|
|
32
|
+
items: {
|
|
33
|
+
oneOf: [
|
|
34
|
+
{ type: 'string', minLength: 1, description: '绝对路径' },
|
|
35
|
+
{
|
|
36
|
+
type: 'object',
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
properties: {
|
|
39
|
+
path: { type: 'string', minLength: 1 },
|
|
40
|
+
file: { type: 'string', minLength: 1 }
|
|
41
|
+
},
|
|
42
|
+
description: '优先 path,否则 file'
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
function pushAttachmentItem(item: unknown, out: string[]): void {
|
|
49
|
+
if (typeof item === 'string') {
|
|
50
|
+
const s = item.trim()
|
|
51
|
+
if (s) out.push(s)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (item && typeof item === 'object') {
|
|
55
|
+
const o = item as Record<string, unknown>
|
|
56
|
+
const p = typeof o.path === 'string' ? o.path.trim() : ''
|
|
57
|
+
const f = typeof o.file === 'string' ? o.file.trim() : ''
|
|
58
|
+
const s = p || f
|
|
59
|
+
if (s) out.push(s)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 从网关 payload 取出 `attachments` 中的路径字符串(去重保序)。
|
|
65
|
+
* 不解析 `summary`、不扫描 Markdown。
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeCronFinishedAttachmentPaths(payload: unknown): string[] {
|
|
68
|
+
if (payload == null || typeof payload !== 'object') return []
|
|
69
|
+
const p = payload as CronGatewayFinishedPayload
|
|
70
|
+
const raw = p.attachments
|
|
71
|
+
if (!Array.isArray(raw)) return []
|
|
72
|
+
const acc: string[] = []
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
pushAttachmentItem(item, acc)
|
|
75
|
+
}
|
|
76
|
+
const seen = new Set<string>()
|
|
77
|
+
const deduped: string[] = []
|
|
78
|
+
for (const s of acc) {
|
|
79
|
+
if (seen.has(s)) continue
|
|
80
|
+
seen.add(s)
|
|
81
|
+
deduped.push(s)
|
|
82
|
+
}
|
|
83
|
+
return deduped
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dedupePaths(paths: string[]): string[] {
|
|
87
|
+
const seen = new Set<string>()
|
|
88
|
+
const out: string[] = []
|
|
89
|
+
for (const s of paths) {
|
|
90
|
+
if (!s || seen.has(s)) continue
|
|
91
|
+
seen.add(s)
|
|
92
|
+
out.push(s)
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 解析本轮待经通道发送的本地路径:优先 `payload.attachments`;
|
|
99
|
+
* 若为空则用 `summary` 与 `extractWorkspaceFilePathsFromText` 一致的工作区/挂载规则提取(非任意全文扫描)。
|
|
100
|
+
*/
|
|
101
|
+
export function resolveCronFinishedLocalPaths(
|
|
102
|
+
payload: unknown,
|
|
103
|
+
summary: string | undefined,
|
|
104
|
+
workspaceDir: string,
|
|
105
|
+
allowedPaths?: string[]
|
|
106
|
+
): string[] {
|
|
107
|
+
const fromSchema = normalizeCronFinishedAttachmentPaths(payload)
|
|
108
|
+
.map((s) => expandTildePath(trimArtifactPathCandidate(s)))
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
const schemaOk = fromSchema.filter((p: string) => isAllowedSendPath(p, workspaceDir, allowedPaths))
|
|
111
|
+
if (schemaOk.length > 0) {
|
|
112
|
+
return dedupePaths(schemaOk)
|
|
113
|
+
}
|
|
114
|
+
const fromSummary = extractWorkspaceFilePathsFromText(summary, workspaceDir).filter((p: string) =>
|
|
115
|
+
isAllowedSendPath(p, workspaceDir, allowedPaths)
|
|
116
|
+
)
|
|
117
|
+
return dedupePaths(fromSummary)
|
|
118
|
+
}
|
package/src/gateway/index.ts
CHANGED
|
@@ -358,8 +358,13 @@ export class GatewayConnection {
|
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
if (msg.type === 'event') {
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
void handleGatewayEventMessage(msg)
|
|
362
|
+
.then((event) => {
|
|
363
|
+
this.eventHandlers.forEach((h) => h(event))
|
|
364
|
+
})
|
|
365
|
+
.catch((err) => {
|
|
366
|
+
dcgLogger(`[Gateway] event 处理异步失败: ${String(err)}`, 'error')
|
|
367
|
+
})
|
|
363
368
|
}
|
|
364
369
|
}
|
|
365
370
|
|
package/src/monitor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OpenClawConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
|
|
2
2
|
import WebSocket from 'ws'
|
|
3
3
|
import { resolveAccount } from './channel.js'
|
|
4
4
|
import { setWsConnection, getOpenClawConfig } from './utils/global.js'
|
|
@@ -7,15 +7,12 @@ import { isWsOpen } from './transport.js'
|
|
|
7
7
|
import { handleParsedWsMessage } from './utils/wsMessageHandler.js'
|
|
8
8
|
|
|
9
9
|
export type MonitorDcgchatOpts = {
|
|
10
|
-
config?:
|
|
10
|
+
config?: OpenClawConfig
|
|
11
11
|
runtime?: RuntimeEnv
|
|
12
12
|
abortSignal?: AbortSignal
|
|
13
13
|
accountId?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const RECONNECT_DELAY_MS = 3000
|
|
17
|
-
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
18
|
-
|
|
19
16
|
function buildConnectUrl(account: Record<string, string>): string {
|
|
20
17
|
const { wsUrl, botToken, userId, domainId, appId } = account
|
|
21
18
|
const url = new URL(wsUrl)
|
|
@@ -27,6 +24,9 @@ function buildConnectUrl(account: Record<string, string>): string {
|
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<void> {
|
|
27
|
+
// WebSocket `open` 可能在 connect() 同 tick 内同步触发,间隔须在此行之前完成初始化
|
|
28
|
+
const RECONNECT_DELAY_MS = 3000
|
|
29
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
30
30
|
const { abortSignal, accountId } = opts
|
|
31
31
|
|
|
32
32
|
const config = getOpenClawConfig()
|
package/src/request/userInfo.ts
CHANGED
|
@@ -24,9 +24,7 @@ const tokenCache = new Map<string, TokenCacheEntry>()
|
|
|
24
24
|
export function setUserTokenCache(botToken: string, userToken: string): void {
|
|
25
25
|
const expiresAt = Date.now() + TOKEN_CACHE_DURATION
|
|
26
26
|
tokenCache.set(botToken, { token: userToken, expiresAt })
|
|
27
|
-
dcgLogger(
|
|
28
|
-
`[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`
|
|
29
|
-
)
|
|
27
|
+
dcgLogger(`[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`)
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
/**
|
|
@@ -47,10 +45,6 @@ export function getUserTokenCache(botToken: string): string | null {
|
|
|
47
45
|
tokenCache.delete(botToken)
|
|
48
46
|
return null
|
|
49
47
|
}
|
|
50
|
-
|
|
51
|
-
dcgLogger(
|
|
52
|
-
`[token-cache] cache hit for botToken=${botToken.slice(0, 10)}..., valid until ${new Date(entry.expiresAt).toISOString()}`
|
|
53
|
-
)
|
|
54
48
|
return entry.token
|
|
55
49
|
}
|
|
56
50
|
|
|
@@ -59,6 +59,13 @@ export function preemptInboundQueueForStop(sessionKey: string): void {
|
|
|
59
59
|
dispatchAbortBySessionKey.delete(sessionKey)
|
|
60
60
|
}
|
|
61
61
|
inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
|
|
62
|
+
// 立即标记流抑制,防止 /stop 到 interruptLocalDispatchAndGateway 之间网关事件仍能推送
|
|
63
|
+
markSessionStreamSuppressed(sessionKey)
|
|
64
|
+
// 队尾被重置后旧 handler 会变成「僵尸」与后续 /stop 并发;仅靠后续 interrupt 晚一拍时网关 run 仍占位。
|
|
65
|
+
// 尽早对网关发 interrupt 级 abort(与 /stop 内 interrupt 重复无害),缩短僵尸窗口。
|
|
66
|
+
void abortGatewayRunsForSession(sessionKey, 'interrupt').catch((e) =>
|
|
67
|
+
dcgLogger(`preempt: gateway abort: ${String(e)}`, 'error')
|
|
68
|
+
)
|
|
62
69
|
dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -123,6 +130,13 @@ export async function beginSupersedingUserTurn(sessionKey: string): Promise<Abor
|
|
|
123
130
|
sessionStreamSuppressed.delete(sessionKey)
|
|
124
131
|
dispatchAbortBySessionKey.get(sessionKey)?.abort()
|
|
125
132
|
await abortGatewayRunsForSession(sessionKey, 'supersede')
|
|
133
|
+
// `/stop` 的 interrupt 会先清空 activeRunId;此时 supersede 可能整段跳过主会话 abort,网关仍跑着僵尸 run,
|
|
134
|
+
// 新一轮会「秒结束」且无回复。再发一次无 runId 的 main abort 作为兜底(幂等)。
|
|
135
|
+
try {
|
|
136
|
+
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
|
|
137
|
+
} catch (e) {
|
|
138
|
+
dcgLogger(`supersede: best-effort main chat.abort ${sessionKey}: ${String(e)}`, 'error')
|
|
139
|
+
}
|
|
126
140
|
const ac = new AbortController()
|
|
127
141
|
dispatchAbortBySessionKey.set(sessionKey, ac)
|
|
128
142
|
return ac
|
package/src/skill.ts
CHANGED
|
@@ -19,14 +19,13 @@ type ISkillParams = {
|
|
|
19
19
|
function sendEvent(msgContent: Record<string, any>) {
|
|
20
20
|
const ws = getWsConnection()
|
|
21
21
|
if (isWsOpen()) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
dcgLogger(`技能安装: ${JSON.stringify(msgContent)}`)
|
|
22
|
+
const msg = JSON.stringify({
|
|
23
|
+
messageType: 'openclaw_bot_event',
|
|
24
|
+
source: 'client',
|
|
25
|
+
content: msgContent
|
|
26
|
+
});
|
|
27
|
+
ws?.send(msg);
|
|
28
|
+
dcgLogger(`[Send]技能安装: ${msg}`)
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -55,7 +54,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
55
54
|
// 创建目标目录
|
|
56
55
|
fs.mkdirSync(skillDir, { recursive: true })
|
|
57
56
|
// 解压文件到目标目录,跳过顶层文件夹
|
|
58
|
-
await new Promise((resolve, reject) => {
|
|
57
|
+
const result = await new Promise((resolve, reject) => {
|
|
59
58
|
const tasks: Promise<void>[] = []
|
|
60
59
|
let rootDir: string | null = null
|
|
61
60
|
let hasError = false
|
|
@@ -64,7 +63,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
64
63
|
.pipe(unzipper.Parse())
|
|
65
64
|
.on('entry', (entry: any) => {
|
|
66
65
|
if (hasError) {
|
|
67
|
-
entry.autodrain()
|
|
66
|
+
entry.autodrain() // 消耗并丢弃当前 zip 条目的数据流
|
|
68
67
|
return
|
|
69
68
|
}
|
|
70
69
|
try {
|
|
@@ -113,6 +112,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
113
112
|
await Promise.all(tasks)
|
|
114
113
|
resolve(null)
|
|
115
114
|
} catch (err) {
|
|
115
|
+
hasError = true
|
|
116
116
|
reject(err)
|
|
117
117
|
}
|
|
118
118
|
})
|
|
@@ -121,7 +121,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
121
121
|
reject(new Error(`解压流错误: ${err.message}`))
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
|
-
sendEvent({ ...msgContent, status: 'ok' })
|
|
124
|
+
sendEvent({ ...msgContent, status: result === null ? 'ok' : 'fail' })
|
|
125
125
|
sendMessageToGateway(JSON.stringify({ method: 'skills.status', params: {} }))
|
|
126
126
|
} catch (error) {
|
|
127
127
|
// 如果安装失败,清理目录
|
|
@@ -136,16 +136,11 @@ export function uninstallSkill(params: Omit<ISkillParams, 'path'>, msgContent: R
|
|
|
136
136
|
const { code } = params
|
|
137
137
|
|
|
138
138
|
const workspacePath = getWorkspaceDir()
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (fs.existsSync(skillDir)) {
|
|
146
|
-
fs.rmSync(skillDir, { recursive: true, force: true })
|
|
147
|
-
sendEvent({ ...msgContent, status: 'ok' })
|
|
148
|
-
} else {
|
|
149
|
-
sendEvent({ ...msgContent, status: 'ok' })
|
|
139
|
+
if (workspacePath) {
|
|
140
|
+
const skillDir = path.join(workspacePath, 'skills', code)
|
|
141
|
+
if (fs.existsSync(skillDir)) {
|
|
142
|
+
fs.rmSync(skillDir, { recursive: true, force: true })
|
|
143
|
+
}
|
|
150
144
|
}
|
|
145
|
+
sendEvent({ ...msgContent, status: 'ok' })
|
|
151
146
|
}
|
package/src/tool.ts
CHANGED
|
@@ -5,34 +5,11 @@ import { sendFinal, sendText, wsSendRaw } from './transport.js'
|
|
|
5
5
|
import { getEffectiveMsgParams, deleteSessionKeyBySubAgentRunId, setSessionKeyBySubAgentRunId } from './utils/params.js'
|
|
6
6
|
import { cronToolCall } from './cronToolCall.js'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
| 'before_prompt_build'
|
|
11
|
-
| 'before_agent_start'
|
|
12
|
-
| 'llm_input'
|
|
13
|
-
| 'llm_output'
|
|
14
|
-
| 'agent_end'
|
|
15
|
-
| 'before_compaction'
|
|
16
|
-
| 'after_compaction'
|
|
17
|
-
| 'before_reset'
|
|
18
|
-
| 'message_received'
|
|
19
|
-
| 'message_sending'
|
|
20
|
-
| 'message_sent'
|
|
21
|
-
| 'before_tool_call'
|
|
22
|
-
| 'after_tool_call'
|
|
23
|
-
| 'tool_result_persist'
|
|
24
|
-
| 'before_message_write'
|
|
25
|
-
| 'session_start'
|
|
26
|
-
| 'session_end'
|
|
27
|
-
| 'subagent_spawning'
|
|
28
|
-
| 'subagent_delivery_target'
|
|
29
|
-
| 'subagent_spawned'
|
|
30
|
-
| 'subagent_ended'
|
|
31
|
-
| 'gateway_start'
|
|
32
|
-
| 'gateway_stop'
|
|
8
|
+
/** 与 `OpenClawPluginApi['on']` 对齐,随宿主 SDK 扩展钩子名时自动一致 */
|
|
9
|
+
type PluginHookName = Parameters<OpenClawPluginApi['on']>[0]
|
|
33
10
|
|
|
34
11
|
// message_received 没有 sessionKey 前置到bot中执行
|
|
35
|
-
const eventList = [
|
|
12
|
+
const eventList: ReadonlyArray<{ event: PluginHookName; message: string }> = [
|
|
36
13
|
// { event: 'message_received', message: '' },
|
|
37
14
|
// {event: 'before_model_resolve', message: ''},
|
|
38
15
|
// {event: 'before_prompt_build', message: '正在查阅背景资料,构建思考逻辑'},
|
|
@@ -357,7 +334,11 @@ function trackSubagentLifecycle(eventName: string, event: any, args: any): void
|
|
|
357
334
|
|
|
358
335
|
export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
359
336
|
for (const item of eventList) {
|
|
360
|
-
api.on(item.event
|
|
337
|
+
api.on(item.event, (event: any, args: any) => {
|
|
338
|
+
// ACP 等非 subagent 的 ended 事件不应驱动书灵子会话状态机(见 SDK PluginHookSubagentEndedEvent.targetKind)
|
|
339
|
+
if (item.event === 'subagent_ended' && event?.targetKind === 'acp') {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
361
342
|
trackSubagentLifecycle(item.event, event, args)
|
|
362
343
|
const sk = resolveHookSessionKey(item.event, args ?? {})
|
|
363
344
|
if (sk) {
|
|
@@ -397,9 +378,9 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
397
378
|
const msgCtx = resolveOutboundParamsForSession(sk)
|
|
398
379
|
if (item.event === 'llm_output') {
|
|
399
380
|
if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
|
|
400
|
-
const message = '
|
|
381
|
+
const message = '您的墨滴已消耗完,您可以通过充值墨滴来继续使用'
|
|
401
382
|
sendText(message, msgCtx, { message_tags: { insufficient_balance: 1 }, is_finish: -1 })
|
|
402
|
-
sendFinal(msgCtx, '
|
|
383
|
+
sendFinal(msgCtx, '墨滴不足')
|
|
403
384
|
return
|
|
404
385
|
}
|
|
405
386
|
}
|
package/src/tools/messageTool.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import type { AnyAgentTool } from 'openclaw/plugin-sdk'
|
|
5
|
-
import { jsonResult } from 'openclaw/plugin-sdk'
|
|
5
|
+
import { jsonResult } from 'openclaw/plugin-sdk/channel-actions'
|
|
6
6
|
import { sendDcgchatMedia } from '../channel.js'
|
|
7
7
|
import { getOutboundMsgParams } from '../utils/params.js'
|
|
8
8
|
import { sendText } from '../transport.js'
|
|
@@ -11,6 +11,9 @@ import { sendText } from '../transport.js'
|
|
|
11
11
|
export type DcgchatMessageToolContext = {
|
|
12
12
|
sessionKey?: string
|
|
13
13
|
workspaceDir?: string
|
|
14
|
+
allowedPaths?: string[]
|
|
15
|
+
/** 通道配置 `allowedAttachmentExtensions`:在插件内置扩展名之外额外允许发送的附件后缀(如 `.py`、`.ipynb`),项可写 `.py` 或 `py`。 */
|
|
16
|
+
allowedAttachmentExtensions?: string[]
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/** 统一为 POSIX 风格斜杠,便于跨平台判断(不改变语义,仅用于匹配)。 */
|
|
@@ -32,9 +35,19 @@ function isPathInsideDir(filepath: string, rootDir: string): boolean {
|
|
|
32
35
|
* - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
|
|
33
36
|
* - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
|
|
34
37
|
*/
|
|
35
|
-
function isSafePath(filepath: string, workspaceDir?: string): boolean {
|
|
38
|
+
function isSafePath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
|
|
39
|
+
// Check workspaceDir
|
|
36
40
|
const ws = workspaceDir?.trim()
|
|
37
41
|
if (ws && isPathInsideDir(filepath, ws)) return true
|
|
42
|
+
|
|
43
|
+
// Check allowedPaths from config
|
|
44
|
+
if (allowedPaths?.length) {
|
|
45
|
+
for (const allowed of allowedPaths) {
|
|
46
|
+
if (isPathInsideDir(filepath, allowed)) return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check legacy mounts
|
|
38
51
|
const p = toPosixPath(filepath)
|
|
39
52
|
if (p.startsWith('/workspace/') || p === '/workspace') return true
|
|
40
53
|
if (p.startsWith('/mobook/') || p === '/mobook') return true
|
|
@@ -48,10 +61,43 @@ function pathKey(filepath: string): string {
|
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
const fileType1 = ['.webp', '.gif', '.bmp', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.rtf', '.odt', '.json']
|
|
51
|
-
const fileType2 = [
|
|
64
|
+
const fileType2 = [
|
|
65
|
+
'.xml',
|
|
66
|
+
'.csv',
|
|
67
|
+
'.yaml',
|
|
68
|
+
'.yml',
|
|
69
|
+
'.html',
|
|
70
|
+
'.htm',
|
|
71
|
+
'.md',
|
|
72
|
+
'.markdown',
|
|
73
|
+
'.css',
|
|
74
|
+
'.js',
|
|
75
|
+
'.ts',
|
|
76
|
+
'.py',
|
|
77
|
+
'.pyi',
|
|
78
|
+
'.ipynb',
|
|
79
|
+
'.png',
|
|
80
|
+
'.jpg',
|
|
81
|
+
'.jpeg'
|
|
82
|
+
]
|
|
52
83
|
const fileType3 = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.exe', '.dmg', '.pkg', '.apk', '.ipa', '.log', '.dat', '.bin']
|
|
53
84
|
const fileType4 = ['.svg', '.ico', '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm']
|
|
54
|
-
const
|
|
85
|
+
const DEFAULT_SAFE_EXTENSIONS = new Set([...fileType1, ...fileType2, ...fileType3, ...fileType4])
|
|
86
|
+
|
|
87
|
+
function normalizeAttachmentExt(raw: string): string | null {
|
|
88
|
+
const t = raw.trim().toLowerCase()
|
|
89
|
+
if (!t) return null
|
|
90
|
+
return t.startsWith('.') ? t : `.${t}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildSafeExtensions(extra?: string[]): Set<string> {
|
|
94
|
+
const set = new Set(DEFAULT_SAFE_EXTENSIONS)
|
|
95
|
+
for (const e of extra ?? []) {
|
|
96
|
+
const n = normalizeAttachmentExt(e)
|
|
97
|
+
if (n && n !== '.') set.add(n)
|
|
98
|
+
}
|
|
99
|
+
return set
|
|
100
|
+
}
|
|
55
101
|
|
|
56
102
|
const messageToolParameters = {
|
|
57
103
|
type: 'object',
|
|
@@ -82,6 +128,7 @@ const messageToolParameters = {
|
|
|
82
128
|
}
|
|
83
129
|
}
|
|
84
130
|
},
|
|
131
|
+
// 须至少提供正文或附件之一;用 anyOf(非 oneOf),否则同时带 content+media 时两个分支都满足会违反「恰好其一」而校验失败
|
|
85
132
|
anyOf: [{ required: ['content'] }, { required: ['media'] }]
|
|
86
133
|
}
|
|
87
134
|
|
|
@@ -113,13 +160,13 @@ function extractPaths(text: string | undefined, workspaceDir?: string): string[]
|
|
|
113
160
|
return [...new Set([...unix, ...win, ...underWs])]
|
|
114
161
|
}
|
|
115
162
|
|
|
116
|
-
function isSafeFile(filepath: string) {
|
|
163
|
+
function isSafeFile(filepath: string, extensions: Set<string>) {
|
|
117
164
|
if (!fs.existsSync(filepath)) return false
|
|
118
165
|
const stat = fs.statSync(filepath)
|
|
119
166
|
if (!stat.isFile()) return false
|
|
120
167
|
if (stat.size === 0) return false
|
|
121
168
|
const ext = path.extname(filepath).toLowerCase()
|
|
122
|
-
return
|
|
169
|
+
return extensions.has(ext)
|
|
123
170
|
}
|
|
124
171
|
|
|
125
172
|
/**
|
|
@@ -128,6 +175,7 @@ function isSafeFile(filepath: string) {
|
|
|
128
175
|
* 通过注册时的 `OpenClawPluginToolContext.sessionKey` 出站,不再使用非标准的 `execute(args, ctx)`。
|
|
129
176
|
*/
|
|
130
177
|
export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext): AnyAgentTool {
|
|
178
|
+
const safeExtensions = buildSafeExtensions(pluginCtx.allowedAttachmentExtensions)
|
|
131
179
|
return {
|
|
132
180
|
name: 'dcgchat_message',
|
|
133
181
|
label: 'dcgchat_message',
|
|
@@ -155,13 +203,14 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
155
203
|
const sentFiles = new Set<string>()
|
|
156
204
|
const sentKeys = new Set<string>()
|
|
157
205
|
const workspaceDir = pluginCtx.workspaceDir
|
|
206
|
+
const allowedPaths = pluginCtx.allowedPaths
|
|
158
207
|
|
|
159
208
|
if (args.media?.length) {
|
|
160
209
|
for (const media of args.media) {
|
|
161
210
|
const filepath = media.file
|
|
162
211
|
if (!filepath) continue
|
|
163
|
-
if (!isSafePath(filepath, workspaceDir)) continue
|
|
164
|
-
if (!isSafeFile(filepath)) continue
|
|
212
|
+
if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
|
|
213
|
+
if (!isSafeFile(filepath, safeExtensions)) continue
|
|
165
214
|
const key = pathKey(filepath)
|
|
166
215
|
if (sentKeys.has(key)) continue
|
|
167
216
|
|
|
@@ -173,8 +222,8 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
173
222
|
|
|
174
223
|
const fallbackPaths = extractPaths(args.content, workspaceDir)
|
|
175
224
|
for (const filepath of fallbackPaths) {
|
|
176
|
-
if (!isSafePath(filepath, workspaceDir)) continue
|
|
177
|
-
if (!isSafeFile(filepath)) continue
|
|
225
|
+
if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
|
|
226
|
+
if (!isSafeFile(filepath, safeExtensions)) continue
|
|
178
227
|
const key = pathKey(filepath)
|
|
179
228
|
if (sentKeys.has(key)) continue
|
|
180
229
|
|
package/src/transport.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
|
|
2
|
+
import { isSessionStreamSuppressed } from './sessionTermination.js'
|
|
2
3
|
import { dcgLogger } from './utils/log.js'
|
|
3
4
|
import type { IMsgParams } from './types.js'
|
|
4
5
|
import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
|
|
@@ -139,6 +140,7 @@ export function isWsOpen(): boolean {
|
|
|
139
140
|
* `ctx` 须由调用方用 getEffectiveMsgParams(sessionKey) 等解析好;`content` 为完整业务 payload。
|
|
140
141
|
*/
|
|
141
142
|
export function wsSend(ctx: IMsgParams, content: Record<string, unknown>): boolean {
|
|
143
|
+
if (ctx.sessionKey && isSessionStreamSuppressed(ctx.sessionKey)) return false
|
|
142
144
|
const ws = getWsConnection()
|
|
143
145
|
if (ws?.readyState !== WebSocket.OPEN) return false
|
|
144
146
|
const envelope = buildOpenclawBotChat(ctx, content)
|
|
@@ -151,6 +153,7 @@ export function wsSend(ctx: IMsgParams, content: Record<string, unknown>): boole
|
|
|
151
153
|
* `ctx` 须由调用方解析(如需合并覆盖可先 mergeSessionParams)。
|
|
152
154
|
*/
|
|
153
155
|
export function wsSendRaw(ctx: IMsgParams, content: Record<string, unknown>, isLog = true): boolean {
|
|
156
|
+
if (ctx.sessionKey && isSessionStreamSuppressed(ctx.sessionKey)) return false
|
|
154
157
|
const ws = getWsConnection()
|
|
155
158
|
if (ws?.readyState !== WebSocket.OPEN) {
|
|
156
159
|
dcgLogger(`server socket not ready ${ws?.readyState}`, 'error')
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 识别 OpenClaw embedded agent 抛出的上下文/压缩相关错误(日志与异常文案可能略有差异)。
|
|
3
|
+
*/
|
|
4
|
+
function errorText(err: unknown): string {
|
|
5
|
+
if (err instanceof Error) return err.message
|
|
6
|
+
if (typeof err === 'string') return err
|
|
7
|
+
return String(err)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isContextOverflowError(err: unknown): boolean {
|
|
11
|
+
const t = errorText(err)
|
|
12
|
+
return (
|
|
13
|
+
/context overflow/i.test(t) ||
|
|
14
|
+
/prompt too large/i.test(t) ||
|
|
15
|
+
/auto-compaction failed/i.test(t) ||
|
|
16
|
+
/\(precheck\)/i.test(t)
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 用户可见说明(不含 transport 层前缀) */
|
|
21
|
+
export function contextOverflowUserHint(): string {
|
|
22
|
+
return '当前对话过长,已超过模型上下文限制;自动压缩未完全生效时会出现此情况。请尝试新开对话、缩短任务,或换用更大上下文的模型。'
|
|
23
|
+
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import type { GatewayEvent } from '../gateway/index.js'
|
|
2
|
-
import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
|
|
2
|
+
import { finishedDcgchatCron, getCronJobsPath, readCronJob, sendDcgchatCron } from '../cron.js'
|
|
3
|
+
import { sendDcgchatMedia } from '../channel.js'
|
|
4
|
+
import { resolveCronFinishedLocalPaths } from '../gateway/cronFinishedPayload.js'
|
|
5
|
+
import { channelInfo, ENV } from './constant.js'
|
|
3
6
|
import { dcgLogger } from './log.js'
|
|
4
|
-
import {
|
|
5
|
-
import { sendChunk
|
|
6
|
-
import {
|
|
7
|
-
import { setMsgStatus } from './global.js'
|
|
7
|
+
import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
|
|
8
|
+
import { sendChunk } from '../transport.js'
|
|
9
|
+
import { getCronMessageId, getOpenClawConfig, getWorkspaceDir, setCronMessageId } from './global.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
|
|
11
13
|
*/
|
|
12
|
-
export function handleGatewayEventMessage(msg: {
|
|
14
|
+
export async function handleGatewayEventMessage(msg: {
|
|
15
|
+
event?: string
|
|
16
|
+
payload?: Record<string, unknown>
|
|
17
|
+
seq?: number
|
|
18
|
+
}): Promise<GatewayEvent> {
|
|
13
19
|
try {
|
|
14
20
|
// 子agent消息输出
|
|
15
21
|
if (msg.event === 'agent') {
|
|
@@ -36,12 +42,35 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
|
|
|
36
42
|
sendDcgchatCron(p?.jobId as string)
|
|
37
43
|
}
|
|
38
44
|
if (p?.action === 'finished' && p?.status === 'ok') {
|
|
39
|
-
|
|
40
|
-
let summary = p?.summary as string
|
|
45
|
+
let summary = typeof p?.summary === 'string' ? p.summary : ''
|
|
41
46
|
if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
|
|
42
47
|
summary = summary.replace('HEARTBEAT_OK', '')
|
|
43
48
|
}
|
|
44
|
-
|
|
49
|
+
|
|
50
|
+
const jobIdStr = typeof p?.jobId === 'string' ? p.jobId.trim() : ''
|
|
51
|
+
const cfg = getOpenClawConfig()
|
|
52
|
+
const chCfg = cfg?.channels?.["dcgchat-test" as keyof NonNullable<typeof cfg.channels>] as { allowedPaths?: string[] } | undefined
|
|
53
|
+
const attachmentPaths = resolveCronFinishedLocalPaths(p, summary, getWorkspaceDir(), chCfg?.allowedPaths)
|
|
54
|
+
const jobPath = getCronJobsPath()
|
|
55
|
+
const job = jobIdStr ? readCronJob(jobPath, jobIdStr) : null
|
|
56
|
+
const sessionKey = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
|
|
57
|
+
|
|
58
|
+
if (sessionKey && attachmentPaths.length > 0) {
|
|
59
|
+
const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
|
|
60
|
+
setCronMessageId(sessionKey, messageId)
|
|
61
|
+
for (const mediaPath of attachmentPaths) {
|
|
62
|
+
try {
|
|
63
|
+
await sendDcgchatMedia({ sessionKey, mediaUrl: mediaPath, messageId })
|
|
64
|
+
} catch (err) {
|
|
65
|
+
dcgLogger(`[Gateway] cron 附件经通道发送失败: ${mediaPath} ${String(err)}`, 'error')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (attachmentPaths.length > 0 && !sessionKey) {
|
|
69
|
+
dcgLogger(`[Gateway] cron finished 有可发送附件路径但 jobs.json 无 sessionKey: jobId=${jobIdStr}`, 'error')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hasAttachments = attachmentPaths.length > 0
|
|
73
|
+
finishedDcgchatCron(jobIdStr, summary, hasAttachments)
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
} catch (error) {
|
package/src/utils/global.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type WebSocket from 'ws'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import {
|
|
5
|
+
import type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
|
|
6
|
+
import { createPluginRuntimeStore } from 'openclaw/plugin-sdk/runtime-store'
|
|
6
7
|
import { channelInfo, ENV } from './constant.js'
|
|
7
8
|
import { dcgLogger } from './log.js'
|
|
8
9
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** 统一为 POSIX 风格斜杠,便于跨平台判断。 */
|
|
5
|
+
export function toPosixPath(p: string): string {
|
|
6
|
+
return path.normalize(p.trim()).replace(/\\/g, '/')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
|
|
10
|
+
export function isPathInsideDir(filepath: string, rootDir: string): boolean {
|
|
11
|
+
const root = path.resolve(rootDir)
|
|
12
|
+
const resolved = path.resolve(filepath)
|
|
13
|
+
const rel = path.relative(root, resolved)
|
|
14
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 与 dcgchat_message 一致:允许的路径为当前工作区根、`allowedPaths`、或兼容挂载 /workspace、/mobook。
|
|
20
|
+
*/
|
|
21
|
+
export function isAllowedSendPath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
|
|
22
|
+
const ws = workspaceDir?.trim()
|
|
23
|
+
if (ws && isPathInsideDir(filepath, ws)) return true
|
|
24
|
+
if (allowedPaths?.length) {
|
|
25
|
+
for (const allowed of allowedPaths) {
|
|
26
|
+
if (isPathInsideDir(filepath, allowed)) return true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const p = toPosixPath(filepath)
|
|
30
|
+
if (p.startsWith('/workspace/') || p === '/workspace') return true
|
|
31
|
+
if (p.startsWith('/mobook/') || p === '/mobook') return true
|
|
32
|
+
return /^[A-Za-z]:\/(workspace|mobook)(\/|$)/.test(p)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 去掉 Markdown/标点误粘在路径末尾的字符(如 `**`、反引号、右括号)。 */
|
|
36
|
+
export function trimArtifactPathCandidate(raw: string): string {
|
|
37
|
+
return raw.replace(/[`'",,*_)\]]+$/u, '').trimEnd()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 将正文里的 `~/...` / `~\...` 展开为绝对路径(`path.resolve` 不会处理 `~`)。
|
|
42
|
+
*/
|
|
43
|
+
export function expandTildePath(input: string): string {
|
|
44
|
+
const s = input.trim()
|
|
45
|
+
if (!s) return s
|
|
46
|
+
if (s === '~') return os.homedir()
|
|
47
|
+
if (s.startsWith('~/')) {
|
|
48
|
+
return path.resolve(os.homedir(), s.slice(2))
|
|
49
|
+
}
|
|
50
|
+
if (s.startsWith('~\\')) {
|
|
51
|
+
return path.resolve(os.homedir(), s.slice(2))
|
|
52
|
+
}
|
|
53
|
+
return s
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 从正文中提取可作为附件发送的本地路径(与 messageTool 规则一致:工作区前缀、`/workspace`、`~` 等)。
|
|
58
|
+
*/
|
|
59
|
+
export function extractWorkspaceFilePathsFromText(text: string | undefined, workspaceDir?: string): string[] {
|
|
60
|
+
if (!text) return []
|
|
61
|
+
const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
|
|
62
|
+
const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
|
|
63
|
+
const tildePaths = text.match(/~[/\\][^\s`'")\]]+/g) ?? []
|
|
64
|
+
const underWs: string[] = []
|
|
65
|
+
const ws = workspaceDir?.trim()
|
|
66
|
+
if (ws) {
|
|
67
|
+
const variants = new Set<string>()
|
|
68
|
+
variants.add(ws)
|
|
69
|
+
variants.add(toPosixPath(ws))
|
|
70
|
+
if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
|
|
71
|
+
for (const prefix of variants) {
|
|
72
|
+
if (!prefix) continue
|
|
73
|
+
let from = 0
|
|
74
|
+
while (from < text.length) {
|
|
75
|
+
const i = text.indexOf(prefix, from)
|
|
76
|
+
if (i === -1) break
|
|
77
|
+
let end = i + prefix.length
|
|
78
|
+
while (end < text.length && !/\s/.test(text[end])) end++
|
|
79
|
+
underWs.push(text.slice(i, end))
|
|
80
|
+
from = i + 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const cleaned = [...unix, ...win, ...tildePaths, ...underWs]
|
|
85
|
+
.map((s) => trimArtifactPathCandidate(s))
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
const resolved = cleaned.map((s) => (s.startsWith('~') ? expandTildePath(s) : s))
|
|
88
|
+
return [...new Set(resolved)]
|
|
89
|
+
}
|