dsh-caduceus 0.1.0 → 0.4.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.
@@ -13,10 +13,17 @@ 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 approval service the bridge needs. */
24
+ export interface ApprovalServiceLike {
25
+ setPolicy(agent: AgentLike, policy: ApprovalPolicy): void;
26
+ }
20
27
  /** Structural subset of a dsh session. */
21
28
  export interface SessionLike {
22
29
  readonly id: string;
@@ -86,6 +93,13 @@ export interface BridgeConfig {
86
93
  readonly groupSessionsPerUser: boolean;
87
94
  /** Agent preset mounted for caduceus sessions (default `standard`). */
88
95
  readonly agentPreset?: string;
96
+ /** Emoji reactions enabled (default true). */
97
+ readonly reactions?: boolean;
98
+ readonly reactionOnStart?: string;
99
+ readonly reactionOnDone?: string;
100
+ readonly reactionOnFail?: string;
101
+ /** Default true: group @ gate fails closed when bot open id is unknown. */
102
+ readonly gateRequireBotIdentity?: boolean;
89
103
  }
90
104
  /** Everything the bridge needs; unit tests provide fakes for every seam. */
91
105
  export interface BridgeOptions {
@@ -111,6 +125,14 @@ export interface BridgeOptions {
111
125
  readonly persona?: (action: 'show' | 'sync') => Promise<PersonaCommandResult>;
112
126
  /** Time source for elapsed rendering; defaults to the clock. */
113
127
  readonly now?: () => number;
128
+ /** Interactive approval/question cards (wired by the gateway). */
129
+ readonly interactive?: InteractiveRuntime;
130
+ /** Plugin root Cordis context; answerers register here once, not per agent. */
131
+ readonly rootCtx?: unknown;
132
+ /** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
133
+ readonly approvalProvider?: () => ApprovalServiceLike | undefined;
134
+ /** Desired session approval policy (default `ask`). */
135
+ readonly approvalPolicy?: ApprovalPolicy;
114
136
  /** `/doctor` data collector (wired by the gateway; not available in tests by default). */
115
137
  readonly doctorInputs?: (session: DoctorSessionInfo) => DoctorInputs | Promise<DoctorInputs>;
116
138
  /** Optional self-check controller (`/status`, `/selfcheck`, stage B). */
@@ -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];
@@ -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
- const trimmed = raw.trim();
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);
package/dist/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
- import type { BridgeConfig, BusyMode } from './bridge/types.js';
2
+ import type { ApprovalPolicy, BridgeConfig, BusyMode } from './bridge/types.js';
3
3
  /** Raw entry config as written in the profile. */
4
4
  export interface GatewayConfig {
5
5
  readonly appId?: string;
@@ -17,6 +17,24 @@ export interface GatewayConfig {
17
17
  readonly personaAutoSync?: boolean;
18
18
  /** Agent preset mounted for caduceus-created sessions (default `standard`). */
19
19
  readonly agentPreset?: string;
20
+ /** Send interactive approval cards (default true). */
21
+ readonly approvalCard?: boolean;
22
+ /** Send interactive question cards (default true). */
23
+ readonly questionCard?: boolean;
24
+ /** Interactive card timeout in ms (default 300000). */
25
+ readonly interactiveTimeoutMs?: number;
26
+ /** Session approval policy; IM humans are present, so default `ask`. */
27
+ readonly approvalPolicy?: ApprovalPolicy;
28
+ /** Emoji reactions on inbound/turn-end (default true). */
29
+ readonly reactions?: boolean;
30
+ /** Emoji added on accept (default `OnIt`). */
31
+ readonly reactionOnStart?: string;
32
+ /** Emoji added on completed turn (default `DONE`). */
33
+ readonly reactionOnDone?: string;
34
+ /** Emoji added on failed/interrupted turn (default `CROSS_MARK`). */
35
+ readonly reactionOnFail?: string;
36
+ /** Require the bot's own open id for the group @ gate (default true). */
37
+ readonly gateRequireBotIdentity?: boolean;
20
38
  }
21
39
  /** Schemastery schema for the `caduceus-gateway` entry. */
22
40
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
@@ -31,6 +49,15 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
31
49
  logLevel: z<"info" | "debug", "info" | "debug", "plain">;
32
50
  personaAutoSync: z<boolean, boolean, "plain">;
33
51
  agentPreset: z<string, string, "plain">;
52
+ approvalCard: z<boolean, boolean, "plain">;
53
+ questionCard: z<boolean, boolean, "plain">;
54
+ interactiveTimeoutMs: z<number, number, "plain">;
55
+ approvalPolicy: z<"ask" | "never", "ask" | "never", "plain">;
56
+ reactions: z<boolean, boolean, "plain">;
57
+ reactionOnStart: z<string, string, "plain">;
58
+ reactionOnDone: z<string, string, "plain">;
59
+ reactionOnFail: z<string, string, "plain">;
60
+ gateRequireBotIdentity: z<boolean, boolean, "plain">;
34
61
  }>>, Schemastery.ObjectT<NoInfer<{
35
62
  appId: z<string, string, "plain">;
36
63
  appSecret: z<string, string, "plain">;
@@ -43,6 +70,15 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
43
70
  logLevel: z<"info" | "debug", "info" | "debug", "plain">;
44
71
  personaAutoSync: z<boolean, boolean, "plain">;
45
72
  agentPreset: z<string, string, "plain">;
73
+ approvalCard: z<boolean, boolean, "plain">;
74
+ questionCard: z<boolean, boolean, "plain">;
75
+ interactiveTimeoutMs: z<number, number, "plain">;
76
+ approvalPolicy: z<"ask" | "never", "ask" | "never", "plain">;
77
+ reactions: z<boolean, boolean, "plain">;
78
+ reactionOnStart: z<string, string, "plain">;
79
+ reactionOnDone: z<string, string, "plain">;
80
+ reactionOnFail: z<string, string, "plain">;
81
+ gateRequireBotIdentity: z<boolean, boolean, "plain">;
46
82
  }>>, "plain">;
47
83
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
48
84
  export declare const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -56,6 +92,17 @@ export declare const DEFAULT_LOG_LEVEL: 'info' | 'debug';
56
92
  export declare const DEFAULT_PERSONA_AUTO_SYNC = true;
57
93
  /** Default agent preset for caduceus sessions. */
58
94
  export declare const DEFAULT_AGENT_PRESET = "standard";
95
+ /** Default message reactions. */
96
+ export declare const DEFAULT_REACTIONS = true;
97
+ export declare const DEFAULT_REACTION_ON_START = "OnIt";
98
+ export declare const DEFAULT_REACTION_ON_DONE = "DONE";
99
+ export declare const DEFAULT_REACTION_ON_FAIL = "CROSS_MARK";
100
+ /** Default session approval policy: ask, so dangerous actions prompt a card. */
101
+ export declare const DEFAULT_APPROVAL_POLICY: ApprovalPolicy;
102
+ /** Default interactive-card switches/timeout. */
103
+ export declare const DEFAULT_APPROVAL_CARD = true;
104
+ export declare const DEFAULT_QUESTION_CARD = true;
105
+ export declare const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300000;
59
106
  export interface ResolvedGatewayConfig {
60
107
  readonly credentials?: {
61
108
  readonly appId: string;
@@ -65,6 +112,10 @@ export interface ResolvedGatewayConfig {
65
112
  readonly logLevel: 'info' | 'debug';
66
113
  readonly personaAutoSync: boolean;
67
114
  readonly agentPreset: string;
115
+ readonly approvalCard: boolean;
116
+ readonly questionCard: boolean;
117
+ readonly interactiveTimeoutMs: number;
118
+ readonly approvalPolicy: ApprovalPolicy;
68
119
  }
69
120
  export interface ConfigEnv {
70
121
  readonly FEISHU_APP_ID?: string;
package/dist/config.js CHANGED
@@ -23,6 +23,15 @@ export const Config = z.object({
23
23
  logLevel: z.union([z.const('info'), z.const('debug')]).required(false),
24
24
  personaAutoSync: z.boolean().required(false),
25
25
  agentPreset: z.string().required(false),
26
+ approvalCard: z.boolean().required(false),
27
+ questionCard: z.boolean().required(false),
28
+ interactiveTimeoutMs: z.natural().min(1).required(false),
29
+ approvalPolicy: z.union([z.const('ask'), z.const('never')]).required(false),
30
+ reactions: z.boolean().required(false),
31
+ reactionOnStart: z.string().required(false),
32
+ reactionOnDone: z.string().required(false),
33
+ reactionOnFail: z.string().required(false),
34
+ gateRequireBotIdentity: z.boolean().required(false),
26
35
  });
27
36
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
28
37
  export const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -36,6 +45,17 @@ export const DEFAULT_LOG_LEVEL = 'info';
36
45
  export const DEFAULT_PERSONA_AUTO_SYNC = true;
37
46
  /** Default agent preset for caduceus sessions. */
38
47
  export const DEFAULT_AGENT_PRESET = 'standard';
48
+ /** Default message reactions. */
49
+ export const DEFAULT_REACTIONS = true;
50
+ export const DEFAULT_REACTION_ON_START = 'OnIt';
51
+ export const DEFAULT_REACTION_ON_DONE = 'DONE';
52
+ export const DEFAULT_REACTION_ON_FAIL = 'CROSS_MARK';
53
+ /** Default session approval policy: ask, so dangerous actions prompt a card. */
54
+ export const DEFAULT_APPROVAL_POLICY = 'ask';
55
+ /** Default interactive-card switches/timeout. */
56
+ export const DEFAULT_APPROVAL_CARD = true;
57
+ export const DEFAULT_QUESTION_CARD = true;
58
+ export const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300_000;
39
59
  function firstNonEmpty(...values) {
40
60
  for (const value of values) {
41
61
  if (value !== undefined && value.length > 0)
@@ -61,6 +81,11 @@ export function resolveGatewayConfig(config = {}, env = process.env, cwd = proce
61
81
  busyMode: config.busyMode ?? DEFAULT_BUSY_MODE,
62
82
  groupSessionsPerUser: config.groupSessionsPerUser ?? DEFAULT_GROUP_SESSIONS_PER_USER,
63
83
  agentPreset: config.agentPreset ?? DEFAULT_AGENT_PRESET,
84
+ reactions: config.reactions ?? DEFAULT_REACTIONS,
85
+ reactionOnStart: config.reactionOnStart ?? DEFAULT_REACTION_ON_START,
86
+ reactionOnDone: config.reactionOnDone ?? DEFAULT_REACTION_ON_DONE,
87
+ reactionOnFail: config.reactionOnFail ?? DEFAULT_REACTION_ON_FAIL,
88
+ gateRequireBotIdentity: config.gateRequireBotIdentity ?? true,
64
89
  };
65
90
  return {
66
91
  ...(appId === undefined || appSecret === undefined ? {} : { credentials: { appId, appSecret } }),
@@ -68,5 +93,9 @@ export function resolveGatewayConfig(config = {}, env = process.env, cwd = proce
68
93
  logLevel: config.logLevel ?? DEFAULT_LOG_LEVEL,
69
94
  personaAutoSync: config.personaAutoSync ?? DEFAULT_PERSONA_AUTO_SYNC,
70
95
  agentPreset: config.agentPreset ?? DEFAULT_AGENT_PRESET,
96
+ approvalCard: config.approvalCard ?? DEFAULT_APPROVAL_CARD,
97
+ questionCard: config.questionCard ?? DEFAULT_QUESTION_CARD,
98
+ interactiveTimeoutMs: config.interactiveTimeoutMs ?? DEFAULT_INTERACTIVE_TIMEOUT_MS,
99
+ approvalPolicy: config.approvalPolicy ?? DEFAULT_APPROVAL_POLICY,
71
100
  };
72
101
  }
package/dist/gate.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { LoggerLike } from './logger.js';
18
18
  import type { ChatType } from './transport/types.js';
19
19
  export type GateAcceptReason = 'p2p' | 'mentioned';
20
- export type GateDropReason = 'bot-sender' | 'not-allowed-chat' | 'not-allowed-user' | 'empty-text' | 'no-mention';
20
+ export type GateDropReason = 'bot-sender' | 'not-allowed-chat' | 'not-allowed-user' | 'empty-text' | 'no-mention' | 'bot-identity-unknown';
21
21
  export type GateDecision = {
22
22
  readonly accept: true;
23
23
  readonly reason: GateAcceptReason;
@@ -35,6 +35,8 @@ export interface GateInput {
35
35
  readonly text: string;
36
36
  readonly mentions?: readonly string[];
37
37
  readonly botOpenId?: string;
38
+ /** Default true: fail closed when the bot's own open id is unknown. */
39
+ readonly requireBotIdentity?: boolean;
38
40
  readonly allowedChats: readonly string[];
39
41
  readonly allowedUsers: readonly string[];
40
42
  }
package/dist/gate.js CHANGED
@@ -22,13 +22,14 @@ export function evaluateGate(input) {
22
22
  if (botOpenId !== undefined && botOpenId.length > 0) {
23
23
  if (mentions.includes(botOpenId))
24
24
  return { accept: true, reason: 'mentioned' };
25
+ return { accept: false, reason: 'no-mention' };
25
26
  }
26
- else if (mentions.length > 0) {
27
- // bot/v3/info unavailable: accept any mention rather than dropping every
28
- // group message; the line still records the decision for diagnosis.
27
+ // Identity unknown: fail closed by default (never let an arbitrary @ through).
28
+ if (mentions.length === 0)
29
+ return { accept: false, reason: 'no-mention' };
30
+ if (input.requireBotIdentity === false)
29
31
  return { accept: true, reason: 'mentioned' };
30
- }
31
- return { accept: false, reason: 'no-mention' };
32
+ return { accept: false, reason: 'bot-identity-unknown' };
32
33
  }
33
34
  function prefix(value, length = 12) {
34
35
  const text = (value ?? '').slice(0, length);
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import { adaptLogger, teeLoggers } from './logger.js';
23
23
  import { formatStartupLine } from './observability.js';
24
24
  import { autoSyncPersona } from './persona/auto-sync.js';
25
25
  import { runPersonaCommand } from './persona/command.js';
26
+ import { InteractiveRuntime } from './interactive/runtime.js';
26
27
  import { SelfChecker } from './selfcheck.js';
27
28
  import { hasSessionLog } from './session-files.js';
28
29
  import { SESSION_MAP_VERSION, SessionStore } from './session-key.js';
@@ -164,7 +165,21 @@ export function apply(ctx, config = {}) {
164
165
  logger,
165
166
  });
166
167
  const transport = new ReliableTransport(api, { logger });
167
- const bridge = new Bridge({
168
+ let bridge;
169
+ const interactive = new InteractiveRuntime({
170
+ config: {
171
+ approvalCard: resolved.approvalCard,
172
+ questionCard: resolved.questionCard,
173
+ timeoutMs: resolved.interactiveTimeoutMs,
174
+ },
175
+ logger,
176
+ sendCard: (sessionId, card) => bridge.sendInteractiveCard(sessionId, card),
177
+ updateCard: (messageId, card) => transport.updateCard(messageId, card),
178
+ });
179
+ transport.onCardAction?.((action) => {
180
+ void interactive.handleCardAction(action);
181
+ });
182
+ bridge = new Bridge({
168
183
  transport,
169
184
  sessionMap,
170
185
  agents: makeAgents(ctx),
@@ -175,6 +190,10 @@ export function apply(ctx, config = {}) {
175
190
  // Lazy per use: the preset registry can register AFTER this plugin mounts.
176
191
  agentPresetsProvider: () => ctx.get('agentPresets'),
177
192
  selfCheck,
193
+ interactive,
194
+ rootCtx: ctx,
195
+ approvalProvider: () => ctx.get('approval'),
196
+ approvalPolicy: resolved.approvalPolicy,
178
197
  sessionPresetLookup: (sessionId) => ctx.get('sessions')?.get(sessionId)?.header
179
198
  ?.agentPreset,
180
199
  doctorInputs: (session) => {
@@ -0,0 +1,9 @@
1
+ import { type InteractiveContext, type InteractiveHost } from './types.js';
2
+ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
3
+ export interface ApprovalRequestLike {
4
+ readonly toolName: string;
5
+ readonly reason?: string;
6
+ readonly signal?: AbortSignal;
7
+ }
8
+ export type ApprovalAnswerer = (request: ApprovalRequestLike, next: () => Promise<ApprovalOutcome>) => Promise<ApprovalOutcome>;
9
+ export declare function createApprovalAnswerer(host: InteractiveHost, contextFor: () => InteractiveContext | undefined): ApprovalAnswerer;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `approval/request` answerer: send an approval card, wait for a button click
3
+ * (or timeout/abort), and return only closed dsh approval outcomes.
4
+ *
5
+ * Fail-closed rules:
6
+ * - approvalCard disabled / no owner context -> `unavailable` (never prompt);
7
+ * - card send fails -> log ERROR and `cancelled`;
8
+ * - timeout or abort -> `cancelled`;
9
+ * - callback handler resolves the pending entry with the chosen outcome.
10
+ *
11
+ * @module dsh-caduceus/interactive/approval
12
+ */
13
+ import { approvalCard, approvalTerminalCard } from './cards.js';
14
+ import { logPrefix } from './types.js';
15
+ export function createApprovalAnswerer(host, contextFor) {
16
+ return async (request) => {
17
+ if (!host.config.approvalCard) {
18
+ host.logger.warn('interactive: approval skipped (approvalCard disabled)');
19
+ return 'unavailable';
20
+ }
21
+ const context = contextFor();
22
+ if (context === undefined) {
23
+ host.logger.warn('interactive: approval skipped (no session context)');
24
+ return 'unavailable';
25
+ }
26
+ let settle;
27
+ const outcome = new Promise((resolve) => {
28
+ settle = resolve;
29
+ });
30
+ const entry = host.pending.add({
31
+ kind: 'approval',
32
+ scopeKey: context.scopeKey,
33
+ chatId: context.chatId,
34
+ ownerOpenId: context.ownerOpenId,
35
+ sessionId: context.sessionId,
36
+ createdAt: host.clock.now(),
37
+ resolve: (value) => settle(value),
38
+ reject: () => settle('cancelled'),
39
+ meta: { toolName: request.toolName, message: request.reason },
40
+ });
41
+ host.logger.info(`interactive: approval request id=${entry.id} questions=0 scope=${context.scopeKey}`);
42
+ const card = approvalCard(entry.id, context.sessionId, request.toolName, request.reason);
43
+ let timer;
44
+ const onAbort = () => {
45
+ if (host.pending.delete(entry.id) === undefined)
46
+ return;
47
+ void host
48
+ .updateCard(entry.cardMessageId ?? '', approvalTerminalCard(request.toolName, '已取消'))
49
+ .catch(() => undefined);
50
+ settle('cancelled');
51
+ };
52
+ const cleanup = () => {
53
+ if (timer !== undefined)
54
+ host.clock.clearTimeout(timer);
55
+ request.signal?.removeEventListener('abort', onAbort);
56
+ };
57
+ entry.cleanup = cleanup;
58
+ host.logger.info(`interactive: approval card sending chat=${logPrefix(context.chatId)}`);
59
+ try {
60
+ const sent = await host.sendCard(context.sessionId, card);
61
+ entry.cardMessageId = sent.messageId;
62
+ host.logger.info(`interactive: approval card sent messageId=${logPrefix(entry.cardMessageId)} thread=${logPrefix(sent.threadId ?? '-', 8)}`);
63
+ }
64
+ catch (error) {
65
+ host.pending.delete(entry.id);
66
+ host.logger.error(`interactive: approval card send failed: ${error instanceof Error ? error.message : String(error)}`);
67
+ return 'cancelled';
68
+ }
69
+ timer = host.clock.setTimeout(() => {
70
+ if (host.pending.delete(entry.id) === undefined)
71
+ return;
72
+ host.logger.info(`interactive: approval timed out after ${host.config.timeoutMs}ms`);
73
+ void host
74
+ .updateCard(entry.cardMessageId ?? '', approvalTerminalCard(request.toolName, '已过期'))
75
+ .catch(() => undefined);
76
+ settle('cancelled');
77
+ }, host.config.timeoutMs);
78
+ if (request.signal?.aborted === true) {
79
+ onAbort();
80
+ return outcome;
81
+ }
82
+ request.signal?.addEventListener('abort', onAbort, { once: true });
83
+ return outcome;
84
+ };
85
+ }
@@ -0,0 +1,36 @@
1
+ import type { FeishuCard, FeishuCardElement } from '../transport/types.js';
2
+ export interface CardButton {
3
+ readonly label: string;
4
+ readonly type?: 'primary' | 'danger' | 'default';
5
+ readonly value: Record<string, unknown>;
6
+ }
7
+ /** A card with optional buttons. */
8
+ export declare function interactiveCard(title: string, lines: readonly string[], buttons?: readonly CardButton[], template?: string): FeishuCard;
9
+ /** Card from pre-built elements (forms, inputs, action rows). */
10
+ export declare function interactiveElementsCard(title: string, elements: readonly FeishuCardElement[], template?: string): FeishuCard;
11
+ export declare function approvalCard(requestId: string, sessionId: string, toolName: string, reason: string | undefined): FeishuCard;
12
+ export declare function approvalTerminalCard(toolName: string, outcome: string): FeishuCard;
13
+ export interface QuestionCardInput {
14
+ readonly requestId: string;
15
+ readonly sessionId: string;
16
+ /** Extra warning appended to the card (e.g. the empty-custom hint). */
17
+ readonly hint?: string;
18
+ readonly questions: readonly {
19
+ readonly id: string;
20
+ readonly question: string;
21
+ readonly header?: string;
22
+ readonly detail?: string;
23
+ readonly options?: readonly {
24
+ readonly label: string;
25
+ readonly description?: string;
26
+ }[];
27
+ readonly multiSelect?: boolean;
28
+ }[];
29
+ }
30
+ export interface QuestionCardResult {
31
+ readonly card: FeishuCard;
32
+ readonly buttons: boolean;
33
+ }
34
+ /** Buttons only for exactly one single-select question with 1..4 options. */
35
+ export declare function questionCard(input: QuestionCardInput): QuestionCardResult;
36
+ export declare function questionTerminalCard(question: string, answer: string): FeishuCard;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Interactive Feishu card builders (approval / question / terminal states).
3
+ *
4
+ * @module dsh-caduceus/interactive/cards
5
+ */
6
+ import { buildCard } from '../render/cards.js';
7
+ function div(content) {
8
+ return { tag: 'div', text: { tag: 'lark_md', content } };
9
+ }
10
+ function actionRow(buttons) {
11
+ return {
12
+ tag: 'action',
13
+ actions: buttons.map((button) => ({
14
+ tag: 'button',
15
+ text: { tag: 'plain_text', content: button.label },
16
+ type: button.type ?? 'default',
17
+ value: button.value,
18
+ })),
19
+ };
20
+ }
21
+ /** A card with optional buttons. */
22
+ export function interactiveCard(title, lines, buttons = [], template = 'orange') {
23
+ const elements = lines.map(div);
24
+ if (buttons.length > 0)
25
+ elements.push(actionRow(buttons));
26
+ return buildCard({ title, template, elements });
27
+ }
28
+ /** Card from pre-built elements (forms, inputs, action rows). */
29
+ export function interactiveElementsCard(title, elements, template = 'orange') {
30
+ return buildCard({ title, template, elements });
31
+ }
32
+ export function approvalCard(requestId, sessionId, toolName, reason) {
33
+ return interactiveCard(`审批请求 · ${toolName}`, [
34
+ reason === undefined || reason.trim().length === 0 ? '(无说明)' : reason,
35
+ ], [
36
+ {
37
+ label: '允许一次',
38
+ type: 'primary',
39
+ value: { kind: 'approval', id: requestId, sessionId, choice: 'allow' },
40
+ },
41
+ {
42
+ label: '拒绝',
43
+ type: 'danger',
44
+ value: { kind: 'approval', id: requestId, sessionId, choice: 'reject' },
45
+ },
46
+ ]);
47
+ }
48
+ export function approvalTerminalCard(toolName, outcome) {
49
+ return interactiveCard(`审批请求 · ${toolName}`, [outcome], [], 'grey');
50
+ }
51
+ /** Buttons only for exactly one single-select question with 1..4 options. */
52
+ export function questionCard(input) {
53
+ const questions = input.questions;
54
+ const lines = [];
55
+ for (const question of questions) {
56
+ const heading = question.header === undefined ? question.question : `${question.header}:${question.question}`;
57
+ lines.push(`**${heading}**`);
58
+ if (question.detail !== undefined && question.detail.trim().length > 0)
59
+ lines.push(question.detail);
60
+ if (question.multiSelect === true)
61
+ lines.push('_(多选暂不支持,仅能选一项)_');
62
+ for (const option of question.options ?? []) {
63
+ lines.push(`- ${option.label}${option.description === undefined ? '' : ` — ${option.description}`}`);
64
+ }
65
+ }
66
+ if (input.hint !== undefined && input.hint.length > 0)
67
+ lines.push(`**${input.hint}**`);
68
+ const only = questions.length === 1 ? questions[0] : undefined;
69
+ const options = only?.options ?? [];
70
+ const buttons = only !== undefined &&
71
+ options.length > 0 &&
72
+ options.length <= 4;
73
+ const elements = lines.map(div);
74
+ if (buttons) {
75
+ elements.push(actionRow(options.map((option) => ({
76
+ label: option.label,
77
+ value: {
78
+ kind: 'question',
79
+ id: input.requestId,
80
+ sessionId: input.sessionId,
81
+ questionId: only.id,
82
+ label: option.label,
83
+ },
84
+ }))));
85
+ }
86
+ if (only === undefined) {
87
+ lines.push('_本版暂不支持在卡片内输入文字,请直接回复文本作答。_');
88
+ return { card: interactiveCard('问题', lines), buttons: false };
89
+ }
90
+ elements.push(div('或自己填:'));
91
+ elements.push({
92
+ tag: 'form',
93
+ name: 'question_form',
94
+ elements: [
95
+ {
96
+ tag: 'input',
97
+ name: 'other',
98
+ placeholder: { tag: 'plain_text', content: '输入自定义答案' },
99
+ max_length: 200,
100
+ },
101
+ {
102
+ tag: 'button',
103
+ name: 'submit',
104
+ type: 'primary',
105
+ action_type: 'form_submit',
106
+ text: { tag: 'plain_text', content: '提交' },
107
+ value: {
108
+ kind: 'question',
109
+ id: input.requestId,
110
+ sessionId: input.sessionId,
111
+ questionId: only.id,
112
+ form: true,
113
+ },
114
+ },
115
+ ],
116
+ });
117
+ const title = only.header === undefined ? `问题 · ${only.question}` : `问题 · ${only.header}`;
118
+ return {
119
+ card: interactiveElementsCard(title, elements),
120
+ buttons,
121
+ };
122
+ }
123
+ export function questionTerminalCard(question, answer) {
124
+ return interactiveCard(`问题 · ${question}`, [answer], [], 'grey');
125
+ }
@@ -0,0 +1,29 @@
1
+ export type PendingKind = 'approval' | 'question';
2
+ export interface PendingEntry {
3
+ readonly id: string;
4
+ readonly kind: PendingKind;
5
+ readonly scopeKey: string;
6
+ readonly chatId: string;
7
+ readonly ownerOpenId: string;
8
+ readonly sessionId: string;
9
+ readonly createdAt: number;
10
+ readonly resolve: (value: unknown) => void;
11
+ readonly reject: (error: unknown) => void;
12
+ /** Card message id assigned after a successful send. */
13
+ cardMessageId?: string;
14
+ /** Extra presentation data (tool name / questions). */
15
+ readonly meta?: Record<string, unknown>;
16
+ /** Clears timers/signal listeners when the runtime settles the entry. */
17
+ cleanup?: () => void;
18
+ }
19
+ export type NewPendingEntry = Omit<PendingEntry, 'id'> & {
20
+ readonly id?: string;
21
+ };
22
+ export declare class PendingRegistry {
23
+ #private;
24
+ add(entry: NewPendingEntry): PendingEntry;
25
+ get(id: string): PendingEntry | undefined;
26
+ delete(id: string): PendingEntry | undefined;
27
+ get size(): number;
28
+ clear(): void;
29
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * In-memory pending interactive requests (one per approval/question card).
3
+ *
4
+ * Entries are keyed by a random id embedded in every button value. They carry
5
+ * the scope owner so a click from anyone else can be ignored. The registry is
6
+ * intentionally dumb: the runtime decides when an entry settles, patches the
7
+ * card, and clears timers through `cleanup`.
8
+ *
9
+ * @module dsh-caduceus/interactive/pending
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ export class PendingRegistry {
13
+ #entries = new Map();
14
+ add(entry) {
15
+ const id = entry.id ?? randomUUID();
16
+ const value = { ...entry, id };
17
+ this.#entries.set(id, value);
18
+ return value;
19
+ }
20
+ get(id) {
21
+ return this.#entries.get(id);
22
+ }
23
+ delete(id) {
24
+ const value = this.#entries.get(id);
25
+ if (value === undefined)
26
+ return undefined;
27
+ this.#entries.delete(id);
28
+ value.cleanup?.();
29
+ return value;
30
+ }
31
+ get size() {
32
+ return this.#entries.size;
33
+ }
34
+ clear() {
35
+ for (const entry of this.#entries.values())
36
+ entry.cleanup?.();
37
+ this.#entries.clear();
38
+ }
39
+ }
@@ -0,0 +1,25 @@
1
+ import { type InteractiveContext, type InteractiveHost } from './types.js';
2
+ export interface QuestionItemLike {
3
+ readonly id: string;
4
+ readonly question: string;
5
+ readonly header?: string;
6
+ readonly detail?: string;
7
+ readonly options?: readonly {
8
+ readonly label: string;
9
+ readonly description?: string;
10
+ }[];
11
+ readonly multiSelect?: boolean;
12
+ }
13
+ export interface QuestionRequestLike {
14
+ readonly questions: readonly QuestionItemLike[];
15
+ readonly signal?: AbortSignal;
16
+ }
17
+ export interface QuestionAnswerLike {
18
+ readonly answers: readonly {
19
+ readonly id: string;
20
+ readonly selected: readonly string[];
21
+ readonly custom?: string;
22
+ }[];
23
+ }
24
+ export type QuestionAnswerer = (request: QuestionRequestLike, next: () => Promise<QuestionAnswerLike>) => Promise<QuestionAnswerLike>;
25
+ export declare function createQuestionAnswerer(host: InteractiveHost, contextFor: () => InteractiveContext | undefined): QuestionAnswerer;