dsh-caduceus 0.1.1 → 0.5.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/README.md +12 -0
- package/README.zh.md +12 -0
- package/dist/bridge/bridge.d.ts +9 -0
- package/dist/bridge/bridge.js +319 -16
- package/dist/bridge/types.d.ts +38 -0
- package/dist/commands/parse.d.ts +0 -11
- package/dist/commands/parse.js +5 -1
- package/dist/config.d.ts +68 -1
- package/dist/config.js +39 -0
- package/dist/gate.d.ts +3 -1
- package/dist/gate.js +6 -5
- package/dist/index.js +21 -1
- package/dist/interactive/approval.d.ts +9 -0
- package/dist/interactive/approval.js +85 -0
- package/dist/interactive/cards.d.ts +36 -0
- package/dist/interactive/cards.js +125 -0
- package/dist/interactive/pending.d.ts +29 -0
- package/dist/interactive/pending.js +39 -0
- package/dist/interactive/question.d.ts +25 -0
- package/dist/interactive/question.js +86 -0
- package/dist/interactive/runtime.d.ts +44 -0
- package/dist/interactive/runtime.js +147 -0
- package/dist/interactive/types.d.ts +50 -0
- package/dist/interactive/types.js +25 -0
- package/dist/mentions.d.ts +15 -0
- package/dist/mentions.js +21 -0
- package/dist/transport/feishu-api.d.ts +14 -1
- package/dist/transport/lark.d.ts +11 -1
- package/dist/transport/lark.js +61 -2
- package/dist/transport/normalize.d.ts +24 -23
- package/dist/transport/normalize.js +148 -23
- package/dist/transport/reliable.d.ts +9 -1
- package/dist/transport/reliable.js +15 -0
- package/dist/transport/types.d.ts +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,18 @@ onlyBuiltDependencies:
|
|
|
78
78
|
| `personaAutoSync` | `true` | Sync SOUL into `$DSH_HOME/AGENTS.md` at startup |
|
|
79
79
|
| `agentPreset` | `standard` | dsh agent preset mounted for new sessions |
|
|
80
80
|
| `logLevel` | `info` | File log level: `info` / `debug` |
|
|
81
|
+
| `approvalCard` | `true` | Send interactive approval cards (`allow once` / `reject`) |
|
|
82
|
+
| `questionCard` | `true` | Send interactive question cards; single-select ≤4 options get buttons |
|
|
83
|
+
| `interactiveTimeoutMs` | `300000` | How long an approval/question card waits before it expires |
|
|
84
|
+
| `approvalPolicy` | `ask` | Session approval policy: `ask` (card confirmation, default) or `never` |
|
|
85
|
+
| `reactions` | `true` | Emoji reactions on messages: `OnIt` while running, `DONE`/`CROSS_MARK` at turn end |
|
|
86
|
+
| `reactionOnStart` | `OnIt` | Reaction added when a user message is accepted |
|
|
87
|
+
| `reactionOnDone` | `DONE` | Reaction added when a turn completes |
|
|
88
|
+
| `reactionOnFail` | `CROSS_MARK` | Reaction added when a turn errors/interrupts (falls back to `THINKING`) |
|
|
89
|
+
| `gateRequireBotIdentity` | `true` | Group @ gate fails closed when the bot open id is unknown |
|
|
90
|
+
| `images` | `true` | Accept image messages and attach them to the agent prompt |
|
|
91
|
+
| `files` | `true` | Accept file messages (durable attachment or saved to cwd) |
|
|
92
|
+
| `maxAttachmentBytes` | `20971520` | Per-attachment download limit (20 MiB) |
|
|
81
93
|
|
|
82
94
|
## Commands
|
|
83
95
|
|
package/README.zh.md
CHANGED
|
@@ -77,6 +77,18 @@ onlyBuiltDependencies:
|
|
|
77
77
|
| `personaAutoSync` | `true` | 启动时把 SOUL 同步到 `$DSH_HOME/AGENTS.md` |
|
|
78
78
|
| `agentPreset` | `standard` | 新会话挂载的 dsh agent preset |
|
|
79
79
|
| `logLevel` | `info` | 文件日志级别:`info` / `debug` |
|
|
80
|
+
| `approvalCard` | `true` | 发交互审批卡(允许一次 / 拒绝) |
|
|
81
|
+
| `questionCard` | `true` | 发交互选项卡;单选且 ≤4 个选项时显示按钮 |
|
|
82
|
+
| `interactiveTimeoutMs` | `300000` | 审批/选项卡等待多久后过期 |
|
|
83
|
+
| `approvalPolicy` | `ask` | 会话审批策略:`ask`(弹卡确认,默认)或 `never` |
|
|
84
|
+
| `reactions` | `true` | 消息表情反馈:处理中 `OnIt`,结束 `DONE`/`CROSS_MARK` |
|
|
85
|
+
| `reactionOnStart` | `OnIt` | 收到用户消息时添加的表情 |
|
|
86
|
+
| `reactionOnDone` | `DONE` | 回合完成时添加的表情 |
|
|
87
|
+
| `reactionOnFail` | `CROSS_MARK` | 回合出错/中断时添加的表情(失败退回 `THINKING`) |
|
|
88
|
+
| `gateRequireBotIdentity` | `true` | bot open id 未知时群 @ 门禁 fail-closed(false 恢复旧放行) |
|
|
89
|
+
| `images` | `true` | 接收图片消息并作为附件交给 agent |
|
|
90
|
+
| `files` | `true` | 接收文件消息(附件服务或落到 cwd) |
|
|
91
|
+
| `maxAttachmentBytes` | `20971520` | 单个附件下载上限(20 MiB) |
|
|
80
92
|
|
|
81
93
|
## 命令
|
|
82
94
|
|
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -19,6 +19,15 @@ export declare class Bridge {
|
|
|
19
19
|
handleSessionEvent(session: {
|
|
20
20
|
readonly id: string;
|
|
21
21
|
}, event: StatusEventLike): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Send an interactive card the same way process cards go out: reply into the
|
|
24
|
+
* originating topic/thread when the session still has a live scope. Falls
|
|
25
|
+
* back to a chat send only when the scope state is gone.
|
|
26
|
+
*/
|
|
27
|
+
sendInteractiveCard(sessionId: string, card: FeishuCard): Promise<{
|
|
28
|
+
readonly messageId: string;
|
|
29
|
+
readonly threadId?: string;
|
|
30
|
+
}>;
|
|
22
31
|
}
|
|
23
32
|
/** Build the process card for one snapshot (exported for render tests). */
|
|
24
33
|
export declare function progressCardForSnapshot(snapshot: StatusSnapshot, sessionTitle: string | undefined, recentEvent: string, tail: string, now: number, presetMissing?: boolean): FeishuCard;
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* @module dsh-caduceus/bridge/bridge
|
|
18
18
|
*/
|
|
19
19
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
20
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
20
22
|
import { planSlashLine } from '../commands/dispatch.js';
|
|
21
23
|
import { helpText } from '../commands/parse.js';
|
|
22
24
|
import { evaluateGate, logGate } from '../gate.js';
|
|
@@ -27,7 +29,7 @@ import { writeOwnership } from '../ownership.js';
|
|
|
27
29
|
import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
|
|
28
30
|
import { CardStream, systemClock } from '../render/stream.js';
|
|
29
31
|
import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
|
|
30
|
-
import { StatusTracker, shouldNotifyTerminal, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
|
|
32
|
+
import { StatusTracker, shouldNotifyTerminal, stateForTurnEnd, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
|
|
31
33
|
import { ScopeQueue } from './scope-queue.js';
|
|
32
34
|
function errorMessage(error) {
|
|
33
35
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -81,6 +83,8 @@ export class Bridge {
|
|
|
81
83
|
#selfCheck;
|
|
82
84
|
#agentCtxBySession = new Map();
|
|
83
85
|
#toolsCheckedSessions = new Set();
|
|
86
|
+
#approvalMissingWarned = false;
|
|
87
|
+
#botIdentityErrorLogged = false;
|
|
84
88
|
/** Last fail-id set already notified into the conversation. */
|
|
85
89
|
#selfCheckNotifiedSignature;
|
|
86
90
|
/** Session ids whose preset mount failed (shown as `(preset 未挂载)`). */
|
|
@@ -88,6 +92,7 @@ export class Bridge {
|
|
|
88
92
|
#sessionTitleProvider;
|
|
89
93
|
#queue = new ScopeQueue();
|
|
90
94
|
#states = new Map();
|
|
95
|
+
#chatIdBySession = new Map();
|
|
91
96
|
#scopeBySession = new Map();
|
|
92
97
|
constructor(options) {
|
|
93
98
|
this.#options = options;
|
|
@@ -117,6 +122,12 @@ export class Bridge {
|
|
|
117
122
|
if (sessionId !== undefined)
|
|
118
123
|
this.#scopeBySession.set(sessionId, key);
|
|
119
124
|
}
|
|
125
|
+
if (options.interactive !== undefined && options.rootCtx !== undefined) {
|
|
126
|
+
options.interactive.registerRoot(options.rootCtx, {
|
|
127
|
+
ownsSession: (sessionId) => this.ownsSession(sessionId),
|
|
128
|
+
contextFor: (sessionId) => this.#interactiveContext(sessionId),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
120
131
|
}
|
|
121
132
|
/** True when a session id belongs to a scope this bridge renders. */
|
|
122
133
|
ownsSession(sessionId) {
|
|
@@ -124,16 +135,35 @@ export class Bridge {
|
|
|
124
135
|
}
|
|
125
136
|
/** Process one inbound message (serialized per scope). */
|
|
126
137
|
async handleInbound(message) {
|
|
138
|
+
if (message.unsupportedType !== undefined) {
|
|
139
|
+
const state = this.#stateFor(message);
|
|
140
|
+
this.#logger.info(`unsupported message type: ${message.unsupportedType}`);
|
|
141
|
+
await this.#replyText(state, '这类消息暂不支持。');
|
|
142
|
+
await this.#safeAddReaction(message.messageId, 'CROSS_MARK');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Bot identity may have failed at startup; retry on the next group message
|
|
146
|
+
// before deciding, so a late bot/v3/info success restores normal gating.
|
|
147
|
+
if (message.chatType === 'group' && (this.#transport.getBotOpenId?.() ?? '').length === 0) {
|
|
148
|
+
try {
|
|
149
|
+
await this.#transport.refreshBotOpenId?.();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
this.#logger.warn(`bot identity refresh failed: ${errorMessage(error)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const botOpenId = this.#transport.getBotOpenId?.();
|
|
127
156
|
const decision = evaluateGate({
|
|
128
157
|
chatType: message.chatType,
|
|
129
158
|
chatId: message.chatId,
|
|
130
159
|
...(message.threadId === undefined ? {} : { threadId: message.threadId }),
|
|
131
160
|
senderOpenId: message.senderOpenId,
|
|
132
|
-
text: message.text
|
|
161
|
+
text: message.text.trim().length === 0 && (message.attachments?.length ?? 0) > 0
|
|
162
|
+
? '[attachment]'
|
|
163
|
+
: message.text,
|
|
133
164
|
...(message.mentions === undefined ? {} : { mentions: message.mentions }),
|
|
134
|
-
...(
|
|
135
|
-
|
|
136
|
-
: { botOpenId: this.#transport.getBotOpenId?.() }),
|
|
165
|
+
...(botOpenId === undefined ? {} : { botOpenId }),
|
|
166
|
+
requireBotIdentity: this.#config.gateRequireBotIdentity !== false,
|
|
137
167
|
allowedChats: this.#config.allowedChats,
|
|
138
168
|
allowedUsers: this.#config.allowedUsers,
|
|
139
169
|
});
|
|
@@ -142,6 +172,12 @@ export class Bridge {
|
|
|
142
172
|
...(message.threadId === undefined ? {} : { threadId: message.threadId }),
|
|
143
173
|
senderOpenId: message.senderOpenId,
|
|
144
174
|
});
|
|
175
|
+
if (!decision.accept && decision.reason === 'bot-identity-unknown') {
|
|
176
|
+
if (!this.#botIdentityErrorLogged) {
|
|
177
|
+
this.#botIdentityErrorLogged = true;
|
|
178
|
+
this.#logger.error('gate: bot identity unknown; group @ messages are rejected until resolved');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
145
181
|
if (!decision.accept)
|
|
146
182
|
return;
|
|
147
183
|
await this.#queue.run(scopeKey(message.chatType, message.chatId, message.threadId, message.senderOpenId, this.#config.groupSessionsPerUser), () => this.#processInbound(message));
|
|
@@ -218,6 +254,7 @@ export class Bridge {
|
|
|
218
254
|
async #processInbound(message) {
|
|
219
255
|
const state = this.#stateFor(message);
|
|
220
256
|
state.lastInboundMessageId = message.messageId;
|
|
257
|
+
state.ownerOpenId = message.senderOpenId;
|
|
221
258
|
// Startup-stage failures have no session to notify yet; the first message
|
|
222
259
|
// with a session catches up here (once per fail combination).
|
|
223
260
|
await this.#maybeNotifySelfCheck(state);
|
|
@@ -226,7 +263,12 @@ export class Bridge {
|
|
|
226
263
|
await this.#handleSlash(state, message.text);
|
|
227
264
|
return;
|
|
228
265
|
}
|
|
229
|
-
|
|
266
|
+
state.reactionQueue.push(message.messageId);
|
|
267
|
+
this.#startReaction(state, message.messageId);
|
|
268
|
+
await this.#deliverTurn(state, {
|
|
269
|
+
text: message.text,
|
|
270
|
+
...(message.attachments === undefined ? {} : { attachments: message.attachments }),
|
|
271
|
+
});
|
|
230
272
|
}
|
|
231
273
|
async #handleSlash(state, raw) {
|
|
232
274
|
const plan = planSlashLine(raw);
|
|
@@ -382,14 +424,136 @@ export class Bridge {
|
|
|
382
424
|
}
|
|
383
425
|
}
|
|
384
426
|
}
|
|
385
|
-
async #
|
|
427
|
+
async #prepareAttachments(state, input) {
|
|
428
|
+
const attachments = input.attachments ?? [];
|
|
429
|
+
if (attachments.length === 0) {
|
|
430
|
+
return { content: [{ type: 'text', text: input.text }] };
|
|
431
|
+
}
|
|
432
|
+
const maxBytes = this.#config.maxAttachmentBytes ?? 20 * 1024 * 1024;
|
|
433
|
+
const imagesEnabled = this.#config.images !== false;
|
|
434
|
+
const filesEnabled = this.#config.files !== false;
|
|
435
|
+
const kept = [];
|
|
436
|
+
let imageCount = 0;
|
|
437
|
+
let truncatedImages = 0;
|
|
438
|
+
let ignored = 0;
|
|
439
|
+
for (const attachment of attachments) {
|
|
440
|
+
if (attachment.kind === 'image') {
|
|
441
|
+
if (!imagesEnabled) {
|
|
442
|
+
ignored += 1;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (imageCount >= 9) {
|
|
446
|
+
truncatedImages += 1;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
imageCount += 1;
|
|
450
|
+
kept.push(attachment);
|
|
451
|
+
}
|
|
452
|
+
else if (!filesEnabled) {
|
|
453
|
+
ignored += 1;
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
kept.push(attachment);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (ignored > 0) {
|
|
460
|
+
await this.#replyText(state, '当前未启用图片/文件消息,已忽略部分附件。');
|
|
461
|
+
}
|
|
462
|
+
let annotated = input.text;
|
|
463
|
+
if (truncatedImages > 0) {
|
|
464
|
+
annotated += `(图片超过 9 张,已截断 ${truncatedImages} 张)`;
|
|
465
|
+
}
|
|
466
|
+
const store = this.#options.attachmentProvider?.();
|
|
467
|
+
const parts = [];
|
|
468
|
+
for (const attachment of kept) {
|
|
469
|
+
let downloaded;
|
|
470
|
+
try {
|
|
471
|
+
downloaded = await this.#transport.downloadResource(state.lastInboundMessageId ?? '', attachment.fileKey, attachment.kind, maxBytes);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
const tooLarge = errorMessage(error).includes('too large');
|
|
475
|
+
await this.#replyText(state, tooLarge ? '文件太大,已跳过。' : '附件下载失败,已跳过。');
|
|
476
|
+
await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
if (downloaded.bytes.length > maxBytes) {
|
|
480
|
+
await this.#replyText(state, '文件太大,已跳过。');
|
|
481
|
+
await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
const base64 = Buffer.from(downloaded.bytes).toString('base64');
|
|
485
|
+
if (attachment.kind === 'image') {
|
|
486
|
+
if (store === undefined) {
|
|
487
|
+
await this.#replyText(state, '当前无法处理图片(attachments 服务不可用)。');
|
|
488
|
+
await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
parts.push({
|
|
492
|
+
type: 'image',
|
|
493
|
+
mediaType: imageMediaType(downloaded.contentType),
|
|
494
|
+
data: base64,
|
|
495
|
+
...(attachment.fileName === undefined ? {} : { name: attachment.fileName }),
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const fileName = attachment.fileName ?? attachment.fileKey;
|
|
500
|
+
let admitted = false;
|
|
501
|
+
if (store !== undefined) {
|
|
502
|
+
try {
|
|
503
|
+
const ref = await store.admitEncodedFile({ data: base64, name: fileName });
|
|
504
|
+
parts.push({ type: 'file', attachment: ref });
|
|
505
|
+
admitted = true;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
admitted = false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!admitted) {
|
|
512
|
+
try {
|
|
513
|
+
const dir = this.#config.defaultCwd;
|
|
514
|
+
mkdirSync(dir, { recursive: true });
|
|
515
|
+
const target = join(dir, `${Date.now()}-${fileName.replace(/[^\w.-]+/g, '_')}`);
|
|
516
|
+
writeFileSync(target, downloaded.bytes);
|
|
517
|
+
annotated += `(文件已保存:${target})`;
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
this.#logger.warn(`attachment file save failed: ${errorMessage(error)}`);
|
|
521
|
+
await this.#replyText(state, '附件保存失败,已跳过。');
|
|
522
|
+
await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (parts.length === 0)
|
|
528
|
+
return { content: [{ type: 'text', text: annotated }] };
|
|
529
|
+
if (store !== undefined) {
|
|
530
|
+
try {
|
|
531
|
+
const admitted = await store.admitPromptContent([
|
|
532
|
+
{ type: 'text', text: annotated },
|
|
533
|
+
...parts,
|
|
534
|
+
]);
|
|
535
|
+
return { content: admitted };
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
this.#logger.warn(`attachment admission failed: ${errorMessage(error)}`);
|
|
539
|
+
await this.#replyText(state, '附件处理失败,已跳过。');
|
|
540
|
+
await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { content: [{ type: 'text', text: annotated }, ...parts] };
|
|
545
|
+
}
|
|
546
|
+
async #deliverTurn(state, input) {
|
|
547
|
+
const prepared = await this.#prepareAttachments(state, input);
|
|
548
|
+
if (prepared === undefined)
|
|
549
|
+
return;
|
|
386
550
|
const resolved = await this.#tryResolve(state);
|
|
387
551
|
if (resolved === undefined) {
|
|
388
552
|
await this.#replyText(state, '无法创建会话(agents 服务不可用)。');
|
|
389
553
|
return;
|
|
390
554
|
}
|
|
391
555
|
const message = createUserMessage({
|
|
392
|
-
content:
|
|
556
|
+
content: prepared.content,
|
|
393
557
|
source: { kind: 'user' },
|
|
394
558
|
});
|
|
395
559
|
const agent = resolved.agent;
|
|
@@ -412,11 +576,17 @@ export class Bridge {
|
|
|
412
576
|
}
|
|
413
577
|
async #settleTurn(state, event) {
|
|
414
578
|
state.running = false;
|
|
579
|
+
const kind = turnEndReasonKind(event.data);
|
|
580
|
+
const finalState = stateForTurnEnd(kind);
|
|
581
|
+
const finalStateLabel = statusLabel(finalState);
|
|
582
|
+
// The tracker is usually already terminal (apply ran before this method),
|
|
583
|
+
// but re-apply to guarantee /status never disagrees with the final card.
|
|
584
|
+
state.status.apply(event);
|
|
415
585
|
const snapshot = state.status.get();
|
|
416
586
|
const body = state.lastOutput.trim().length > 0
|
|
417
587
|
? state.lastOutput
|
|
418
|
-
: `本轮已结束(${
|
|
419
|
-
const finalInput = this.#finalCardInput(state, snapshot, body);
|
|
588
|
+
: `本轮已结束(${finalStateLabel})。`;
|
|
589
|
+
const finalInput = this.#finalCardInput(state, snapshot, body, finalState, finalStateLabel);
|
|
420
590
|
const stream = state.progress;
|
|
421
591
|
const messageId = stream?.messageId;
|
|
422
592
|
if (stream !== undefined) {
|
|
@@ -441,19 +611,20 @@ export class Bridge {
|
|
|
441
611
|
await this.#finalFallback(state, finalInput, error);
|
|
442
612
|
}
|
|
443
613
|
}
|
|
444
|
-
const kind = turnEndReasonKind(event.data);
|
|
445
614
|
if (shouldNotifyTerminal(kind)) {
|
|
446
615
|
const notice = terminalNotice(kind, snapshot.errorMessage);
|
|
447
616
|
if (notice !== undefined)
|
|
448
617
|
await this.#replyText(state, notice);
|
|
449
618
|
}
|
|
619
|
+
await this.#finishReactions(state, kind);
|
|
450
620
|
state.lastOutput = '';
|
|
451
621
|
}
|
|
452
|
-
#finalCardInput(state, snapshot, body) {
|
|
622
|
+
#finalCardInput(state, snapshot, body, finalState, finalStateLabel) {
|
|
453
623
|
const sessionTitle = this.#titleOf(state)?.trim() ?? state.title;
|
|
454
624
|
return {
|
|
455
625
|
title: '飞书会话 · 过程',
|
|
456
|
-
|
|
626
|
+
// Event-derived, never the (possibly stale) tracker snapshot.
|
|
627
|
+
stateLabel: finalStateLabel,
|
|
457
628
|
steps: snapshot.steps,
|
|
458
629
|
tools: snapshot.tools,
|
|
459
630
|
...(sessionTitle === undefined ? {} : { sessionTitle }),
|
|
@@ -461,7 +632,7 @@ export class Bridge {
|
|
|
461
632
|
...(snapshot.startedAt === undefined ? {} : { startedAt: snapshot.startedAt }),
|
|
462
633
|
...(snapshot.endedAt === undefined ? {} : { endedAt: snapshot.endedAt }),
|
|
463
634
|
body,
|
|
464
|
-
template: statusTemplate(
|
|
635
|
+
template: statusTemplate(finalState),
|
|
465
636
|
};
|
|
466
637
|
}
|
|
467
638
|
/** The ONLY fallback path: card edit failed, so send the same content as text once. */
|
|
@@ -698,11 +869,20 @@ export class Bridge {
|
|
|
698
869
|
async #switchSession(state, target, verb = '已恢复会话') {
|
|
699
870
|
const current = state.sessionId ?? this.#sessionMap.get(state.key);
|
|
700
871
|
try {
|
|
701
|
-
const
|
|
872
|
+
const persistedPreset = this.#sessionPresetLookup?.(target);
|
|
873
|
+
const { agent } = await this.#agents.resume({
|
|
874
|
+
resumeSessionId: target,
|
|
875
|
+
setup: async (agentCtx) => {
|
|
876
|
+
this.#agentCtxBySession.set(target, agentCtx);
|
|
877
|
+
if (persistedPreset === undefined)
|
|
878
|
+
await this.#mountPreset(target, agentCtx);
|
|
879
|
+
},
|
|
880
|
+
});
|
|
702
881
|
if (current !== undefined && current !== target) {
|
|
703
882
|
state.previousSessionId = current;
|
|
704
883
|
state.previousSessionAt = this.#lastActivityAt(state) ?? this.#now();
|
|
705
884
|
}
|
|
885
|
+
this.#applyApprovalPolicy(agent);
|
|
706
886
|
this.#sessionMap.set(state.key, target);
|
|
707
887
|
state.sessionId = target;
|
|
708
888
|
state.pendingCreate = false;
|
|
@@ -775,6 +955,45 @@ export class Bridge {
|
|
|
775
955
|
this.#agentPresetsCache = service;
|
|
776
956
|
return service;
|
|
777
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* Apply the configured session approval policy to a freshly created/resumed
|
|
960
|
+
* agent. Missing service warns once; setPolicy errors never block the turn.
|
|
961
|
+
*/
|
|
962
|
+
#applyApprovalPolicy(agent) {
|
|
963
|
+
const policy = this.#options.approvalPolicy ?? 'ask';
|
|
964
|
+
this.#logger.info(`agent approval policy=${policy}`);
|
|
965
|
+
const provider = this.#options.approvalProvider;
|
|
966
|
+
if (provider === undefined)
|
|
967
|
+
return;
|
|
968
|
+
const service = provider();
|
|
969
|
+
if (service === undefined) {
|
|
970
|
+
if (!this.#approvalMissingWarned) {
|
|
971
|
+
this.#approvalMissingWarned = true;
|
|
972
|
+
this.#logger.warn('approval service unavailable; session approval policy not applied');
|
|
973
|
+
}
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
service.setPolicy(agent, policy);
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
this.#logger.warn(`agent approval policy=${policy} failed: ${errorMessage(error)}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
#interactiveContext(sessionId) {
|
|
984
|
+
const key = this.#scopeBySession.get(sessionId);
|
|
985
|
+
if (key === undefined)
|
|
986
|
+
return undefined;
|
|
987
|
+
const state = this.#states.get(key);
|
|
988
|
+
if (state === undefined || state.ownerOpenId === undefined)
|
|
989
|
+
return undefined;
|
|
990
|
+
return {
|
|
991
|
+
scopeKey: state.key,
|
|
992
|
+
chatId: state.chatId,
|
|
993
|
+
ownerOpenId: state.ownerOpenId,
|
|
994
|
+
sessionId,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
778
997
|
/**
|
|
779
998
|
* Mount the configured agent preset in the unpublished agent scope.
|
|
780
999
|
* Idempotent for resumes: a context that already reports a composed preset is
|
|
@@ -827,6 +1046,7 @@ export class Bridge {
|
|
|
827
1046
|
catch (error) {
|
|
828
1047
|
this.#logger.warn(`ownership write failed for ${sessionId}: ${errorMessage(error)}`);
|
|
829
1048
|
}
|
|
1049
|
+
this.#applyApprovalPolicy(agent);
|
|
830
1050
|
await this.#runSelfCheckStageB(state, sessionId);
|
|
831
1051
|
return { agent };
|
|
832
1052
|
}
|
|
@@ -1020,6 +1240,7 @@ export class Bridge {
|
|
|
1020
1240
|
},
|
|
1021
1241
|
});
|
|
1022
1242
|
this.#bindSession(state, String(agent.session.id));
|
|
1243
|
+
this.#applyApprovalPolicy(agent);
|
|
1023
1244
|
await this.#runSelfCheckStageB(state, sessionId);
|
|
1024
1245
|
return { agent, sessionId, cwd };
|
|
1025
1246
|
}
|
|
@@ -1045,8 +1266,77 @@ export class Bridge {
|
|
|
1045
1266
|
this.#scopeBySession.delete(id);
|
|
1046
1267
|
}
|
|
1047
1268
|
this.#scopeBySession.set(sessionId, state.key);
|
|
1269
|
+
this.#chatIdBySession.set(sessionId, state.chatId);
|
|
1048
1270
|
state.sessionId = sessionId;
|
|
1049
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Send an interactive card the same way process cards go out: reply into the
|
|
1274
|
+
* originating topic/thread when the session still has a live scope. Falls
|
|
1275
|
+
* back to a chat send only when the scope state is gone.
|
|
1276
|
+
*/
|
|
1277
|
+
async sendInteractiveCard(sessionId, card) {
|
|
1278
|
+
const key = this.#scopeBySession.get(sessionId);
|
|
1279
|
+
const state = key === undefined ? undefined : this.#states.get(key);
|
|
1280
|
+
if (state === undefined) {
|
|
1281
|
+
const chatId = this.#chatIdBySession.get(sessionId) ?? '';
|
|
1282
|
+
this.#logger.warn(`interactive: no scope state for ${sessionId}; falling back to chat send`);
|
|
1283
|
+
return this.#transport.sendCard(chatId, card);
|
|
1284
|
+
}
|
|
1285
|
+
const sent = await this.#sendNewCard(state, card);
|
|
1286
|
+
return {
|
|
1287
|
+
...sent,
|
|
1288
|
+
...(state.threadId === undefined ? {} : { threadId: state.threadId }),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
#startReaction(state, messageId) {
|
|
1292
|
+
if (this.#config.reactions === false)
|
|
1293
|
+
return;
|
|
1294
|
+
const promise = this.#safeAddReaction(messageId, this.#config.reactionOnStart ?? 'OnIt');
|
|
1295
|
+
state.reactionStarts.set(messageId, promise);
|
|
1296
|
+
void promise;
|
|
1297
|
+
}
|
|
1298
|
+
/** Remove the start reaction, then add the terminal reaction (best effort). */
|
|
1299
|
+
async #finishReactions(state, turnKind) {
|
|
1300
|
+
if (this.#config.reactions === false)
|
|
1301
|
+
return;
|
|
1302
|
+
const messageId = state.reactionQueue.shift();
|
|
1303
|
+
if (messageId === undefined)
|
|
1304
|
+
return;
|
|
1305
|
+
const start = state.reactionStarts.get(messageId);
|
|
1306
|
+
state.reactionStarts.delete(messageId);
|
|
1307
|
+
const reactionId = start === undefined ? undefined : await start;
|
|
1308
|
+
if (reactionId !== undefined)
|
|
1309
|
+
await this.#safeRemoveReaction(messageId, reactionId);
|
|
1310
|
+
const completed = turnKind === 'completed';
|
|
1311
|
+
const emoji = completed
|
|
1312
|
+
? (this.#config.reactionOnDone ?? 'DONE')
|
|
1313
|
+
: (this.#config.reactionOnFail ?? 'CROSS_MARK');
|
|
1314
|
+
const added = await this.#safeAddReaction(messageId, emoji);
|
|
1315
|
+
if (!completed && added === undefined && emoji !== 'THINKING') {
|
|
1316
|
+
await this.#safeAddReaction(messageId, 'THINKING');
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async #safeAddReaction(messageId, emojiType) {
|
|
1320
|
+
try {
|
|
1321
|
+
return await this.#transport.addReaction(messageId, emojiType);
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
this.#logReactionSkipped(error);
|
|
1325
|
+
return undefined;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
async #safeRemoveReaction(messageId, reactionId) {
|
|
1329
|
+
try {
|
|
1330
|
+
await this.#transport.removeReaction(messageId, reactionId);
|
|
1331
|
+
}
|
|
1332
|
+
catch (error) {
|
|
1333
|
+
this.#logReactionSkipped(error);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
#logReactionSkipped(error) {
|
|
1337
|
+
const code = error.code;
|
|
1338
|
+
this.#logger.debug(`reaction skipped: ${typeof code === 'number' ? code : 'unknown'} ${errorMessage(error)}`);
|
|
1339
|
+
}
|
|
1050
1340
|
#stateFor(message) {
|
|
1051
1341
|
const key = scopeKey(message.chatType, message.chatId, message.threadId, message.senderOpenId, this.#config.groupSessionsPerUser);
|
|
1052
1342
|
const existing = this.#states.get(key);
|
|
@@ -1061,6 +1351,8 @@ export class Bridge {
|
|
|
1061
1351
|
running: false,
|
|
1062
1352
|
status: new StatusTracker(),
|
|
1063
1353
|
lastOutput: '',
|
|
1354
|
+
reactionQueue: [],
|
|
1355
|
+
reactionStarts: new Map(),
|
|
1064
1356
|
};
|
|
1065
1357
|
this.#states.set(key, created);
|
|
1066
1358
|
return created;
|
|
@@ -1080,7 +1372,7 @@ export class Bridge {
|
|
|
1080
1372
|
}
|
|
1081
1373
|
async #sendNewCard(state, card) {
|
|
1082
1374
|
if (state.lastInboundMessageId !== undefined) {
|
|
1083
|
-
return this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'card', card }, { threadId: state.threadId });
|
|
1375
|
+
return this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'card', card }, state.threadId === undefined ? {} : { threadId: state.threadId });
|
|
1084
1376
|
}
|
|
1085
1377
|
return this.#transport.sendCard(state.chatId, card);
|
|
1086
1378
|
}
|
|
@@ -1125,3 +1417,14 @@ export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tai
|
|
|
1125
1417
|
now,
|
|
1126
1418
|
});
|
|
1127
1419
|
}
|
|
1420
|
+
/** Normalize a resource content-type to one of dsh's accepted image types. */
|
|
1421
|
+
function imageMediaType(contentType) {
|
|
1422
|
+
const normalized = (contentType ?? '').split(';')[0]?.trim().toLowerCase();
|
|
1423
|
+
if (normalized === 'image/png' ||
|
|
1424
|
+
normalized === 'image/jpeg' ||
|
|
1425
|
+
normalized === 'image/webp' ||
|
|
1426
|
+
normalized === 'image/gif') {
|
|
1427
|
+
return normalized;
|
|
1428
|
+
}
|
|
1429
|
+
return 'image/jpeg';
|
|
1430
|
+
}
|
package/dist/bridge/types.d.ts
CHANGED
|
@@ -13,10 +13,25 @@ import type { HarnessCommandResult } from '../commands/harness.js';
|
|
|
13
13
|
import type { LoggerLike } from '../logger.js';
|
|
14
14
|
import type { Clock } from '../render/stream.js';
|
|
15
15
|
import type { DoctorInputs, DoctorSessionInfo } from '../doctor.js';
|
|
16
|
+
import type { InteractiveRuntime } from '../interactive/runtime.js';
|
|
16
17
|
import type { SessionStore } from '../session-key.js';
|
|
17
18
|
import type { Transport } from '../transport/types.js';
|
|
18
19
|
/** Busy-turn delivery policy (design doc §6 L3). */
|
|
19
20
|
export type BusyMode = 'queue' | 'steer' | 'interrupt';
|
|
21
|
+
/** Session approval policy understood by dsh's approval service. */
|
|
22
|
+
export type ApprovalPolicy = 'ask' | 'never';
|
|
23
|
+
/** The slice of dsh's attachment service the bridge needs. */
|
|
24
|
+
export interface AttachmentStoreLike {
|
|
25
|
+
admitPromptContent(content: readonly unknown[]): Promise<readonly unknown[]>;
|
|
26
|
+
admitEncodedFile(input: {
|
|
27
|
+
readonly data: string;
|
|
28
|
+
readonly name?: string;
|
|
29
|
+
}): Promise<unknown>;
|
|
30
|
+
}
|
|
31
|
+
/** The slice of dsh's approval service the bridge needs. */
|
|
32
|
+
export interface ApprovalServiceLike {
|
|
33
|
+
setPolicy(agent: AgentLike, policy: ApprovalPolicy): void;
|
|
34
|
+
}
|
|
20
35
|
/** Structural subset of a dsh session. */
|
|
21
36
|
export interface SessionLike {
|
|
22
37
|
readonly id: string;
|
|
@@ -86,6 +101,19 @@ export interface BridgeConfig {
|
|
|
86
101
|
readonly groupSessionsPerUser: boolean;
|
|
87
102
|
/** Agent preset mounted for caduceus sessions (default `standard`). */
|
|
88
103
|
readonly agentPreset?: string;
|
|
104
|
+
/** Emoji reactions enabled (default true). */
|
|
105
|
+
readonly reactions?: boolean;
|
|
106
|
+
readonly reactionOnStart?: string;
|
|
107
|
+
readonly reactionOnDone?: string;
|
|
108
|
+
readonly reactionOnFail?: string;
|
|
109
|
+
/** Default true: group @ gate fails closed when bot open id is unknown. */
|
|
110
|
+
readonly gateRequireBotIdentity?: boolean;
|
|
111
|
+
/** Process image attachments (default true). */
|
|
112
|
+
readonly images?: boolean;
|
|
113
|
+
/** Process file attachments (default true). */
|
|
114
|
+
readonly files?: boolean;
|
|
115
|
+
/** Per-attachment byte cap (default 20 MiB). */
|
|
116
|
+
readonly maxAttachmentBytes?: number;
|
|
89
117
|
}
|
|
90
118
|
/** Everything the bridge needs; unit tests provide fakes for every seam. */
|
|
91
119
|
export interface BridgeOptions {
|
|
@@ -111,6 +139,16 @@ export interface BridgeOptions {
|
|
|
111
139
|
readonly persona?: (action: 'show' | 'sync') => Promise<PersonaCommandResult>;
|
|
112
140
|
/** Time source for elapsed rendering; defaults to the clock. */
|
|
113
141
|
readonly now?: () => number;
|
|
142
|
+
/** Interactive approval/question cards (wired by the gateway). */
|
|
143
|
+
readonly interactive?: InteractiveRuntime;
|
|
144
|
+
/** Plugin root Cordis context; answerers register here once, not per agent. */
|
|
145
|
+
readonly rootCtx?: unknown;
|
|
146
|
+
/** Lazily resolve dsh's attachment service (image admission / file refs). */
|
|
147
|
+
readonly attachmentProvider?: () => AttachmentStoreLike | undefined;
|
|
148
|
+
/** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
|
|
149
|
+
readonly approvalProvider?: () => ApprovalServiceLike | undefined;
|
|
150
|
+
/** Desired session approval policy (default `ask`). */
|
|
151
|
+
readonly approvalPolicy?: ApprovalPolicy;
|
|
114
152
|
/** `/doctor` data collector (wired by the gateway; not available in tests by default). */
|
|
115
153
|
readonly doctorInputs?: (session: DoctorSessionInfo) => DoctorInputs | Promise<DoctorInputs>;
|
|
116
154
|
/** Optional self-check controller (`/status`, `/selfcheck`, stage B). */
|
package/dist/commands/parse.d.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Slash-command parsing for the Feishu surface. Pure: no I/O, no side effects.
|
|
3
|
-
*
|
|
4
|
-
* Eleven commands are owned by dsh-caduceus: `/new`, `/sessions`, `/rename`,
|
|
5
|
-
* `/resume`, `/adopt`, `/stop`, `/status`, `/persona`, `/help`. Everything else — unknown slash
|
|
6
|
-
* commands such as `/compact` and all ordinary user text — comes back as
|
|
7
|
-
* `{ kind: 'passthrough', raw }` so the caller can forward it unchanged to the
|
|
8
|
-
* dsh command registry or the agent (design doc §6 L3).
|
|
9
|
-
*
|
|
10
|
-
* @module dsh-caduceus/commands/parse
|
|
11
|
-
*/
|
|
12
1
|
/** Commands dsh-caduceus owns. Keep in sync with README's command table. */
|
|
13
2
|
export declare const COMMAND_NAMES: readonly ["new", "sessions", "rename", "resume", "adopt", "selfcheck", "doctor", "stop", "status", "persona", "help"];
|
|
14
3
|
export type CommandName = (typeof COMMAND_NAMES)[number];
|
package/dist/commands/parse.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module dsh-caduceus/commands/parse
|
|
11
11
|
*/
|
|
12
|
+
import { stripLeadingMentions } from '../mentions.js';
|
|
12
13
|
/** Commands dsh-caduceus owns. Keep in sync with README's command table. */
|
|
13
14
|
export const COMMAND_NAMES = ['new', 'sessions', 'rename', 'resume', 'adopt', 'selfcheck', 'doctor', 'stop', 'status', 'persona', 'help'];
|
|
14
15
|
/** Command metadata; `helpText()` and the README command table both derive from it. */
|
|
@@ -37,7 +38,10 @@ function usageResult(command, reason) {
|
|
|
37
38
|
* never rewrites a user's text.
|
|
38
39
|
*/
|
|
39
40
|
export function parseCommand(raw) {
|
|
40
|
-
|
|
41
|
+
// A topic/group message may arrive as "@_user_1 /stop" (Feishu's mention
|
|
42
|
+
// placeholder). Strip only the leading mentions before command detection;
|
|
43
|
+
// passthrough still forwards the ORIGINAL bytes.
|
|
44
|
+
const trimmed = stripLeadingMentions(raw).trim();
|
|
41
45
|
if (!trimmed.startsWith('/'))
|
|
42
46
|
return { kind: 'passthrough', raw };
|
|
43
47
|
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
|