@chenglu.she/sandy 1.0.5 → 1.0.6
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 +1 -1
- package/package.json +1 -1
- package/src/ask-question.ts +91 -38
- package/src/ask-waiters.ts +44 -0
- package/src/cursor-agent.ts +30 -39
- package/src/feishu-tools.ts +72 -0
- package/src/feishu.ts +25 -10
- package/src/index.ts +47 -7
- package/templates/sandy.mdc +1 -1
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ bash scripts/sandy-ctl.sh logs
|
|
|
115
115
|
| 同会话 | `Agent.resume` 多轮;映射在 cwd 的 `.data/sessions.json` |
|
|
116
116
|
| `/new` `/reset` `重置` `新对话` | 清空会话,下次新建 Agent |
|
|
117
117
|
| 连续消息 | 按会话排队;排队 `OneSecond`,处理中 `OnIt` |
|
|
118
|
-
| `askQuestion` |
|
|
118
|
+
| `askQuestion` | 走 `feishu_ask_question`:飞书先发题目正文,再发可点选卡片;点选或回编号后续跑 |
|
|
119
119
|
| 用户发文件/图 | 下载到 `AGENT_CWD/.data/feishu-inbox/…`,路径写入 prompt |
|
|
120
120
|
| Agent 发回文件 | 工具 `feishu_send_file`(本地路径 → 飞书回复) |
|
|
121
121
|
| 飞书文档 | `feishu_doc_read` / `feishu_doc_create` / `feishu_doc_append` |
|
package/package.json
CHANGED
package/src/ask-question.ts
CHANGED
|
@@ -23,28 +23,41 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function parseOptions(raw: unknown): AskOption[] {
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
if (Array.isArray(raw)) {
|
|
27
|
+
return raw
|
|
28
|
+
.map((item, index) => {
|
|
29
|
+
if (typeof item === "string" && item.trim()) {
|
|
30
|
+
return { id: `opt_${index + 1}`, label: item.trim() };
|
|
31
|
+
}
|
|
32
|
+
const obj = asRecord(item);
|
|
33
|
+
if (!obj) return undefined;
|
|
34
|
+
const id =
|
|
35
|
+
typeof obj.id === "string"
|
|
36
|
+
? obj.id
|
|
37
|
+
: typeof obj.value === "string"
|
|
38
|
+
? obj.value
|
|
39
|
+
: `opt_${index + 1}`;
|
|
40
|
+
const label =
|
|
41
|
+
typeof obj.label === "string"
|
|
42
|
+
? obj.label
|
|
43
|
+
: typeof obj.text === "string"
|
|
44
|
+
? obj.text
|
|
45
|
+
: typeof obj.title === "string"
|
|
46
|
+
? obj.title
|
|
47
|
+
: typeof obj.description === "string"
|
|
48
|
+
? obj.description
|
|
49
|
+
: String(id);
|
|
50
|
+
return { id, label };
|
|
51
|
+
})
|
|
52
|
+
.filter((x): x is AskOption => Boolean(x));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const obj = asRecord(raw);
|
|
56
|
+
if (!obj) return [];
|
|
57
|
+
return Object.entries(obj).map(([id, label], index) => ({
|
|
58
|
+
id: id || `opt_${index + 1}`,
|
|
59
|
+
label: typeof label === "string" && label.trim() ? label : String(id),
|
|
60
|
+
}));
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
function parseQuestions(raw: unknown): AskQuestion[] {
|
|
@@ -66,9 +79,10 @@ function parseQuestions(raw: unknown): AskQuestion[] {
|
|
|
66
79
|
? obj.question
|
|
67
80
|
: typeof obj.text === "string"
|
|
68
81
|
? obj.text
|
|
69
|
-
:
|
|
82
|
+
: typeof obj.header === "string"
|
|
83
|
+
? obj.header
|
|
84
|
+
: `问题 ${index + 1}`;
|
|
70
85
|
const options = parseOptions(obj.options ?? obj.choices);
|
|
71
|
-
if (options.length === 0) return undefined;
|
|
72
86
|
return {
|
|
73
87
|
id,
|
|
74
88
|
prompt,
|
|
@@ -81,7 +95,7 @@ function parseQuestions(raw: unknown): AskQuestion[] {
|
|
|
81
95
|
|
|
82
96
|
/** Defensive parse of askQuestion tool args / tool_use input. */
|
|
83
97
|
export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefined {
|
|
84
|
-
const root = asRecord(args);
|
|
98
|
+
const root = asRecord(coerceJson(args));
|
|
85
99
|
if (!root) return undefined;
|
|
86
100
|
|
|
87
101
|
const nested =
|
|
@@ -91,22 +105,22 @@ export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefin
|
|
|
91
105
|
root;
|
|
92
106
|
|
|
93
107
|
const questions = parseQuestions(
|
|
94
|
-
nested.questions ?? nested.question_list ?? nested.items,
|
|
108
|
+
coerceJson(nested.questions ?? nested.question_list ?? nested.items),
|
|
95
109
|
);
|
|
96
110
|
if (questions.length === 0) {
|
|
97
|
-
// Single-question flattened shape
|
|
98
111
|
const options = parseOptions(nested.options ?? nested.choices);
|
|
99
|
-
|
|
112
|
+
const prompt =
|
|
113
|
+
typeof nested.prompt === "string"
|
|
114
|
+
? nested.prompt
|
|
115
|
+
: typeof nested.question === "string"
|
|
116
|
+
? nested.question
|
|
117
|
+
: typeof nested.title === "string"
|
|
118
|
+
? nested.title
|
|
119
|
+
: "";
|
|
120
|
+
if (options.length > 0 || prompt) {
|
|
100
121
|
questions.push({
|
|
101
122
|
id: typeof nested.id === "string" ? nested.id : "q1",
|
|
102
|
-
prompt:
|
|
103
|
-
typeof nested.prompt === "string"
|
|
104
|
-
? nested.prompt
|
|
105
|
-
: typeof nested.question === "string"
|
|
106
|
-
? nested.question
|
|
107
|
-
: typeof nested.title === "string"
|
|
108
|
-
? nested.title
|
|
109
|
-
: "请选择",
|
|
123
|
+
prompt: prompt || "请选择",
|
|
110
124
|
options,
|
|
111
125
|
allowMultiple: Boolean(nested.allowMultiple ?? nested.allow_multiple),
|
|
112
126
|
});
|
|
@@ -121,10 +135,49 @@ export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefin
|
|
|
121
135
|
};
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
|
|
138
|
+
function coerceJson(raw: unknown): unknown {
|
|
139
|
+
if (typeof raw !== "string") return raw;
|
|
140
|
+
const trimmed = raw.trim();
|
|
141
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(trimmed);
|
|
144
|
+
} catch {
|
|
145
|
+
return raw;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Built-in Cursor AskQuestion only — not our Feishu custom tool. */
|
|
150
|
+
export function isBuiltinAskQuestionToolName(name: string | undefined): boolean {
|
|
125
151
|
if (!name) return false;
|
|
126
152
|
const n = name.replace(/[_-]/g, "").toLowerCase();
|
|
127
|
-
return n === "askquestion"
|
|
153
|
+
return n === "askquestion";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isAskQuestionToolName(name: string | undefined): boolean {
|
|
157
|
+
return isBuiltinAskQuestionToolName(name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Last-resort ask payload when the tool fired but args did not parse. */
|
|
161
|
+
export function fallbackAskFromRaw(args: unknown): ParsedAskQuestion {
|
|
162
|
+
const parsed = parseAskQuestionArgs(args);
|
|
163
|
+
if (parsed) return parsed;
|
|
164
|
+
const dump =
|
|
165
|
+
typeof args === "string"
|
|
166
|
+
? args
|
|
167
|
+
: args == null
|
|
168
|
+
? ""
|
|
169
|
+
: JSON.stringify(args, null, 2);
|
|
170
|
+
return {
|
|
171
|
+
title: "需要你的回复",
|
|
172
|
+
questions: [
|
|
173
|
+
{
|
|
174
|
+
id: "q1",
|
|
175
|
+
prompt: dump.trim() || "Agent 发起了询问,但题目解析失败。请直接回复你的选择或补充说明。",
|
|
176
|
+
options: [],
|
|
177
|
+
allowMultiple: false,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
};
|
|
128
181
|
}
|
|
129
182
|
|
|
130
183
|
/** Format selected answers as a follow-up user message for Agent.send. */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type AskAnswer = {
|
|
2
|
+
questionId: string;
|
|
3
|
+
selectedOptionIds: string[];
|
|
4
|
+
freeformText?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type Waiter = {
|
|
8
|
+
resolve: (answers: AskAnswer[]) => void;
|
|
9
|
+
reject: (err: Error) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const waiters = new Map<string, Waiter>();
|
|
13
|
+
|
|
14
|
+
export function hasAskWaiter(sessionKey: string): boolean {
|
|
15
|
+
return waiters.has(sessionKey);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function waitForAskAnswers(sessionKey: string): Promise<AskAnswer[]> {
|
|
19
|
+
const existing = waiters.get(sessionKey);
|
|
20
|
+
if (existing) {
|
|
21
|
+
existing.reject(new Error("replaced by a new question round"));
|
|
22
|
+
waiters.delete(sessionKey);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
waiters.set(sessionKey, { resolve, reject });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveAskWaiter(sessionKey: string, answers: AskAnswer[]): boolean {
|
|
31
|
+
const waiter = waiters.get(sessionKey);
|
|
32
|
+
if (!waiter) return false;
|
|
33
|
+
waiters.delete(sessionKey);
|
|
34
|
+
waiter.resolve(answers);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cancelAskWaiter(sessionKey: string, reason: string): boolean {
|
|
39
|
+
const waiter = waiters.get(sessionKey);
|
|
40
|
+
if (!waiter) return false;
|
|
41
|
+
waiters.delete(sessionKey);
|
|
42
|
+
waiter.reject(new Error(reason));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
package/src/cursor-agent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Agent, CursorAgentError, type SDKCustomTool } from "@cursor/sdk";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
fallbackAskFromRaw,
|
|
4
|
+
isBuiltinAskQuestionToolName,
|
|
4
5
|
parseAskQuestionArgs,
|
|
5
6
|
type ParsedAskQuestion,
|
|
6
7
|
} from "./ask-question.js";
|
|
@@ -45,29 +46,24 @@ export async function runCursorAgent(
|
|
|
45
46
|
const existing = sessionStore.get(sessionKey);
|
|
46
47
|
let agent;
|
|
47
48
|
const customTools = options?.customTools;
|
|
49
|
+
const agentOptions = {
|
|
50
|
+
apiKey: config.cursorApiKey,
|
|
51
|
+
model: { id: config.cursorModel },
|
|
52
|
+
name: config.agentName,
|
|
53
|
+
// Built-in AskQuestion is auto-declined in the SDK and never reaches Feishu.
|
|
54
|
+
disallowedTools: ["askQuestion"],
|
|
55
|
+
local: {
|
|
56
|
+
...localAgentOptions(),
|
|
57
|
+
...(customTools ? { customTools } : {}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
48
60
|
|
|
49
61
|
try {
|
|
50
62
|
if (existing?.agentId) {
|
|
51
|
-
agent = await Agent.resume(existing.agentId,
|
|
52
|
-
apiKey: config.cursorApiKey,
|
|
53
|
-
model: { id: config.cursorModel },
|
|
54
|
-
name: config.agentName,
|
|
55
|
-
local: {
|
|
56
|
-
...localAgentOptions(),
|
|
57
|
-
...(customTools ? { customTools } : {}),
|
|
58
|
-
},
|
|
59
|
-
});
|
|
63
|
+
agent = await Agent.resume(existing.agentId, agentOptions);
|
|
60
64
|
console.log(`[cursor] resumed agent=${existing.agentId} session=${sessionKey}`);
|
|
61
65
|
} else {
|
|
62
|
-
agent = await Agent.create(
|
|
63
|
-
apiKey: config.cursorApiKey,
|
|
64
|
-
model: { id: config.cursorModel },
|
|
65
|
-
name: config.agentName,
|
|
66
|
-
local: {
|
|
67
|
-
...localAgentOptions(),
|
|
68
|
-
...(customTools ? { customTools } : {}),
|
|
69
|
-
},
|
|
70
|
-
});
|
|
66
|
+
agent = await Agent.create(agentOptions);
|
|
71
67
|
sessionStore.set(sessionKey, agent.agentId);
|
|
72
68
|
console.log(`[cursor] created agent=${agent.agentId} session=${sessionKey}`);
|
|
73
69
|
}
|
|
@@ -88,31 +84,26 @@ export async function runCursorAgent(
|
|
|
88
84
|
if (block.type === "text" && block.text) {
|
|
89
85
|
partialText += block.text;
|
|
90
86
|
}
|
|
91
|
-
if (block.type === "tool_use" &&
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`[cursor] askQuestion via tool_use questions=${parsed.questions.length}`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
87
|
+
if (block.type === "tool_use" && isBuiltinAskQuestionToolName(block.name)) {
|
|
88
|
+
pendingAsk = parseAskQuestionArgs(block.input) ?? fallbackAskFromRaw(block.input);
|
|
89
|
+
sawAskQuestion = true;
|
|
90
|
+
console.log(
|
|
91
|
+
`[cursor] askQuestion via tool_use questions=${pendingAsk.questions.length}`,
|
|
92
|
+
);
|
|
100
93
|
}
|
|
101
94
|
}
|
|
102
95
|
}
|
|
103
96
|
|
|
104
|
-
if (event.type === "tool_call" &&
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
);
|
|
112
|
-
} else {
|
|
97
|
+
if (event.type === "tool_call" && isBuiltinAskQuestionToolName(event.name)) {
|
|
98
|
+
pendingAsk = parseAskQuestionArgs(event.args) ?? fallbackAskFromRaw(event.args);
|
|
99
|
+
sawAskQuestion = true;
|
|
100
|
+
console.log(
|
|
101
|
+
`[cursor] askQuestion tool_call status=${event.status} questions=${pendingAsk.questions.length}`,
|
|
102
|
+
);
|
|
103
|
+
if (!parseAskQuestionArgs(event.args)) {
|
|
113
104
|
console.warn(
|
|
114
|
-
"[cursor] askQuestion
|
|
115
|
-
JSON.stringify(event.args)?.slice(0,
|
|
105
|
+
"[cursor] askQuestion args used fallback:",
|
|
106
|
+
JSON.stringify(event.args)?.slice(0, 800),
|
|
116
107
|
);
|
|
117
108
|
}
|
|
118
109
|
|
package/src/feishu-tools.ts
CHANGED
|
@@ -3,6 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
4
4
|
import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
|
|
5
5
|
import { config } from "./config.js";
|
|
6
|
+
import {
|
|
7
|
+
fallbackAskFromRaw,
|
|
8
|
+
parseAskQuestionArgs,
|
|
9
|
+
type ParsedAskQuestion,
|
|
10
|
+
} from "./ask-question.js";
|
|
11
|
+
import type { AskAnswer } from "./ask-waiters.js";
|
|
6
12
|
import {
|
|
7
13
|
appendMarkdownToDocument,
|
|
8
14
|
createDocument,
|
|
@@ -35,6 +41,7 @@ export type FeishuToolContext = {
|
|
|
35
41
|
client: Lark.Client;
|
|
36
42
|
replyToMessageId: string;
|
|
37
43
|
chatId: string;
|
|
44
|
+
onAskQuestion?: (ask: ParsedAskQuestion) => Promise<AskAnswer[]>;
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
/** In-process tools exposed to the Cursor agent as custom-user-tools. */
|
|
@@ -42,6 +49,71 @@ export function buildFeishuCustomTools(
|
|
|
42
49
|
ctx: FeishuToolContext,
|
|
43
50
|
): Record<string, SDKCustomTool> {
|
|
44
51
|
return {
|
|
52
|
+
feishu_ask_question: {
|
|
53
|
+
description:
|
|
54
|
+
"Ask the Feishu user one or more questions and wait until they reply. " +
|
|
55
|
+
"Always use this when you need a decision (auth, image, plan, env, etc.). " +
|
|
56
|
+
"The user cannot see Cursor's AskQuestion UI — never write “等你回上面的题” " +
|
|
57
|
+
"without calling this tool. Include the full prompt and options on every question.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
title: { type: "string", description: "Optional card title" },
|
|
62
|
+
questions: {
|
|
63
|
+
type: "array",
|
|
64
|
+
description: "Questions to show in Feishu",
|
|
65
|
+
items: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
id: { type: "string" },
|
|
69
|
+
prompt: { type: "string", description: "Question text shown to the user" },
|
|
70
|
+
options: {
|
|
71
|
+
type: "array",
|
|
72
|
+
items: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
id: { type: "string" },
|
|
76
|
+
label: { type: "string" },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
allowMultiple: { type: "boolean" },
|
|
81
|
+
},
|
|
82
|
+
required: ["prompt"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ["questions"],
|
|
87
|
+
},
|
|
88
|
+
async execute(args) {
|
|
89
|
+
try {
|
|
90
|
+
if (!ctx.onAskQuestion) {
|
|
91
|
+
throw new Error("onAskQuestion is not configured");
|
|
92
|
+
}
|
|
93
|
+
const parsed =
|
|
94
|
+
parseAskQuestionArgs(args) ?? fallbackAskFromRaw(args);
|
|
95
|
+
const answers = await ctx.onAskQuestion(parsed);
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: JSON.stringify({ ok: true, answers }),
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: `feishu_ask_question cancelled: ${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
isError: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
},
|
|
45
117
|
feishu_send_file: {
|
|
46
118
|
description:
|
|
47
119
|
"Send a local file or image to the current Feishu chat as a reply. " +
|
package/src/feishu.ts
CHANGED
|
@@ -287,7 +287,7 @@ export function buildAskQuestionCard(
|
|
|
287
287
|
} satisfies CardActionValue,
|
|
288
288
|
})),
|
|
289
289
|
});
|
|
290
|
-
} else {
|
|
290
|
+
} else if (q.options.length > 0) {
|
|
291
291
|
const lines = q.options.map((opt, oi) => `${oi + 1}. ${opt.label}`);
|
|
292
292
|
elements.push({
|
|
293
293
|
tag: "markdown",
|
|
@@ -297,12 +297,14 @@ export function buildAskQuestionCard(
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
if (!useButtons) {
|
|
300
|
+
const hasOptions = ask.questions.some((q) => q.options.length > 0);
|
|
300
301
|
elements.push({
|
|
301
302
|
tag: "markdown",
|
|
302
|
-
content:
|
|
303
|
-
ask.questions.length === 1
|
|
303
|
+
content: hasOptions
|
|
304
|
+
? ask.questions.length === 1
|
|
304
305
|
? "请直接回复选项编号(多选如 `1,3`),或回复选项原文。"
|
|
305
|
-
: "请按题号回复,例如:`1:2; 2:1
|
|
306
|
+
: "请按题号回复,例如:`1:2; 2:1`(题号:选项编号)。也可直接用文字说明。"
|
|
307
|
+
: "请直接回复本题答案。",
|
|
306
308
|
});
|
|
307
309
|
}
|
|
308
310
|
|
|
@@ -318,19 +320,26 @@ export function buildAskQuestionCard(
|
|
|
318
320
|
}
|
|
319
321
|
|
|
320
322
|
export function formatAskQuestionFallbackText(ask: ParsedAskQuestion): string {
|
|
321
|
-
const lines = [
|
|
323
|
+
const lines = [`【需要你选择】${ask.title?.trim() || ""}`.trim()];
|
|
322
324
|
for (const [qi, q] of ask.questions.entries()) {
|
|
323
325
|
lines.push("");
|
|
324
326
|
lines.push(`${qi + 1}. ${q.prompt}${q.allowMultiple ? "(可多选)" : ""}`);
|
|
327
|
+
if (q.options.length === 0) {
|
|
328
|
+
lines.push(" (请直接回复)");
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
325
331
|
for (const [oi, opt] of q.options.entries()) {
|
|
326
332
|
lines.push(` ${oi + 1}) ${opt.label}`);
|
|
327
333
|
}
|
|
328
334
|
}
|
|
329
335
|
lines.push("");
|
|
336
|
+
const hasOptions = ask.questions.some((q) => q.options.length > 0);
|
|
330
337
|
lines.push(
|
|
331
|
-
|
|
332
|
-
?
|
|
333
|
-
|
|
338
|
+
hasOptions
|
|
339
|
+
? ask.questions.length === 1
|
|
340
|
+
? "回复编号继续(多选如 1,3),也可点卡片按钮。"
|
|
341
|
+
: "回复格式如 1:2; 2:1,或直接用文字说明。"
|
|
342
|
+
: "请直接回复答案。",
|
|
334
343
|
);
|
|
335
344
|
return lines.join("\n");
|
|
336
345
|
}
|
|
@@ -341,6 +350,13 @@ export async function replyAskQuestionCard(
|
|
|
341
350
|
sessionKey: string,
|
|
342
351
|
ask: ParsedAskQuestion,
|
|
343
352
|
): Promise<void> {
|
|
353
|
+
// Always send readable text first. Cursor's built-in AskQuestion UI never
|
|
354
|
+
// reaches Feishu; if the interactive card fails, the user still sees the
|
|
355
|
+
// questions and how to reply.
|
|
356
|
+
await replyText(client, messageId, formatAskQuestionFallbackText(ask), {
|
|
357
|
+
preferMarkdown: true,
|
|
358
|
+
});
|
|
359
|
+
|
|
344
360
|
const card = buildAskQuestionCard(sessionKey, ask);
|
|
345
361
|
try {
|
|
346
362
|
await client.im.v1.message.reply({
|
|
@@ -351,8 +367,7 @@ export async function replyAskQuestionCard(
|
|
|
351
367
|
},
|
|
352
368
|
});
|
|
353
369
|
} catch (err) {
|
|
354
|
-
console.warn("[feishu] interactive card failed
|
|
355
|
-
await replyText(client, messageId, formatAskQuestionFallbackText(ask));
|
|
370
|
+
console.warn("[feishu] interactive ask card failed (text already sent):", err);
|
|
356
371
|
}
|
|
357
372
|
}
|
|
358
373
|
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,15 @@ import path from "node:path";
|
|
|
2
2
|
import {
|
|
3
3
|
formatAnswerPrompt,
|
|
4
4
|
parseTextAnswer,
|
|
5
|
+
type AskQuestion,
|
|
5
6
|
} from "./ask-question.js";
|
|
7
|
+
import {
|
|
8
|
+
cancelAskWaiter,
|
|
9
|
+
hasAskWaiter,
|
|
10
|
+
resolveAskWaiter,
|
|
11
|
+
waitForAskAnswers,
|
|
12
|
+
type AskAnswer,
|
|
13
|
+
} from "./ask-waiters.js";
|
|
6
14
|
import {
|
|
7
15
|
isResetCommand,
|
|
8
16
|
resetSession,
|
|
@@ -138,10 +146,6 @@ async function deliverOutcome(
|
|
|
138
146
|
createdAt: new Date().toISOString(),
|
|
139
147
|
});
|
|
140
148
|
|
|
141
|
-
if (outcome.partialText) {
|
|
142
|
-
await replyAgentText(client, replyToMessageId, outcome.partialText);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
149
|
await replyAskQuestionCard(client, replyToMessageId, sessionKey, outcome.ask);
|
|
146
150
|
console.log(
|
|
147
151
|
`[ask] waiting for selection session=${sessionKey} questions=${outcome.ask.questions.length}`,
|
|
@@ -160,12 +164,32 @@ const sessionQueue = createSessionQueueManager(client, async (job: QueueJob) =>
|
|
|
160
164
|
client,
|
|
161
165
|
replyToMessageId: job.messageId,
|
|
162
166
|
chatId: job.chatId,
|
|
167
|
+
onAskQuestion: async (ask) => {
|
|
168
|
+
pendingStore.set(sessionKey, {
|
|
169
|
+
agentId: sessionStore.get(sessionKey)?.agentId ?? "",
|
|
170
|
+
chatId: job.chatId,
|
|
171
|
+
replyToMessageId: job.messageId,
|
|
172
|
+
title: ask.title,
|
|
173
|
+
questions: ask.questions,
|
|
174
|
+
createdAt: new Date().toISOString(),
|
|
175
|
+
});
|
|
176
|
+
await replyAskQuestionCard(client, job.messageId, sessionKey, ask);
|
|
177
|
+
console.log(
|
|
178
|
+
`[ask] waiting (feishu_ask_question) session=${sessionKey} questions=${ask.questions.length}`,
|
|
179
|
+
);
|
|
180
|
+
try {
|
|
181
|
+
return await waitForAskAnswers(sessionKey);
|
|
182
|
+
} finally {
|
|
183
|
+
pendingStore.delete(sessionKey);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
163
186
|
});
|
|
164
187
|
const outcome = await runCursorAgent(sessionStore, sessionKey, job.prompt, {
|
|
165
188
|
customTools,
|
|
166
189
|
});
|
|
167
190
|
await deliverOutcome(sessionKey, job.messageId, job.chatId, outcome);
|
|
168
191
|
} catch (err) {
|
|
192
|
+
cancelAskWaiter(sessionKey, "agent failed");
|
|
169
193
|
const message = err instanceof Error ? err.message : String(err);
|
|
170
194
|
console.error("[handle] agent failed:", err);
|
|
171
195
|
await replyText(client, job.messageId, `处理失败:${message}`);
|
|
@@ -185,11 +209,24 @@ function enqueuePrompt(
|
|
|
185
209
|
});
|
|
186
210
|
}
|
|
187
211
|
|
|
212
|
+
function freeformAnswers(questions: AskQuestion[], text: string): AskAnswer[] {
|
|
213
|
+
return questions.map((q) => ({
|
|
214
|
+
questionId: q.id,
|
|
215
|
+
selectedOptionIds: [],
|
|
216
|
+
freeformText: text,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
188
220
|
async function continueWithAnswers(
|
|
189
221
|
sessionKey: string,
|
|
190
222
|
replyToMessageId: string,
|
|
191
|
-
answers:
|
|
223
|
+
answers: AskAnswer[],
|
|
192
224
|
): Promise<void> {
|
|
225
|
+
if (resolveAskWaiter(sessionKey, answers)) {
|
|
226
|
+
console.log(`[ask] resolved in-run waiter session=${sessionKey}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
193
230
|
const pending = pendingStore.get(sessionKey);
|
|
194
231
|
if (!pending) {
|
|
195
232
|
await replyText(client, replyToMessageId, "没有待回答的选择题,直接发消息即可。");
|
|
@@ -251,6 +288,7 @@ async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
|
|
|
251
288
|
}
|
|
252
289
|
|
|
253
290
|
if (isResetCommand(text)) {
|
|
291
|
+
cancelAskWaiter(sessionKey, "user reset");
|
|
254
292
|
resetSession(sessionStore, sessionKey);
|
|
255
293
|
pendingStore.delete(sessionKey);
|
|
256
294
|
await sessionQueue.clear(sessionKey);
|
|
@@ -260,12 +298,13 @@ async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
|
|
|
260
298
|
|
|
261
299
|
const pending = pendingStore.get(sessionKey);
|
|
262
300
|
if (pending && text && !attachmentPrompt) {
|
|
263
|
-
const answers =
|
|
301
|
+
const answers =
|
|
302
|
+
parseTextAnswer(pending.questions, text) ??
|
|
303
|
+
(hasAskWaiter(sessionKey) ? freeformAnswers(pending.questions, text) : undefined);
|
|
264
304
|
if (answers) {
|
|
265
305
|
await continueWithAnswers(sessionKey, msg.messageId, answers);
|
|
266
306
|
return;
|
|
267
307
|
}
|
|
268
|
-
// Not a valid answer — treat as normal new prompt, drop pending.
|
|
269
308
|
console.log(`[ask] clearing pending; treating as new prompt session=${sessionKey}`);
|
|
270
309
|
pendingStore.delete(sessionKey);
|
|
271
310
|
}
|
|
@@ -291,6 +330,7 @@ async function handleCardAction(data: unknown) {
|
|
|
291
330
|
const q = pending.questions.find((qq) => qq.id === value.qid);
|
|
292
331
|
if (!q || !q.options.some((o) => o.id === value.oid)) {
|
|
293
332
|
await replyText(client, replyTo, "选项无效或已过期,请重新提问。");
|
|
333
|
+
cancelAskWaiter(sessionKey, "invalid option");
|
|
294
334
|
pendingStore.delete(sessionKey);
|
|
295
335
|
return;
|
|
296
336
|
}
|
package/templates/sandy.mdc
CHANGED
|
@@ -10,7 +10,7 @@ alwaysApply: true
|
|
|
10
10
|
## 职责
|
|
11
11
|
|
|
12
12
|
- 协助写代码、排障、运维与部署
|
|
13
|
-
-
|
|
13
|
+
- 在飞书里回答简洁、可执行;需要对方做选择时,必须调用 `feishu_ask_question`(题目和选项会发到飞书)。不要只写「等你回上面的题」——飞书用户看不到 Cursor 里的询问框。
|
|
14
14
|
- 可在 `AGENT_CWD/.cursor/rules/` 下创建或更新规则,沉淀领域约定与协作习惯
|
|
15
15
|
- 文件修改范围以 `config.yaml` 里的 `agent.cwd` / `agent.dirs` 白名单为准
|
|
16
16
|
- 可使用本机 shell(SSH / git / 项目脚本),除非 `agent.sandbox=true`
|