@steerable/agent-shell 0.6.25 → 0.6.26

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.
@@ -1,32 +1,52 @@
1
1
  /**
2
- * 回合结束后生成 3 条「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
2
+ * 回合结束后生成「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
3
3
  *
4
- * 主路径走一次短 LLM 调用,根据本轮用户请求 + 助手回复写成用户口吻的短句。
5
- * 失败时用启发式兜底(PPT / 计划 / 代码 / 通用),永不抛错。
4
+ * 主路径走一次短 LLM 调用。优先消化用户/工作区技能和助手回复里已经写明的
5
+ * 下一步(编号列表、脚本调用、推荐后续),条数跟真实下一步走,不凑 3 条。
6
+ * 没有可执行下一步时,才用启发式兜底(PPT / 计划 / 代码 / 通用)。永不抛错。
6
7
  *
7
- * 调用方应该 fire-and-forget:先把兜底三条推上 UI,LLM 成功后再替换。
8
+ * 调用方应该 fire-and-forget:先把兜底推上 UI,LLM 成功后再替换。
8
9
  * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
9
10
  */
10
- export declare const SUGGESTED_REPLY_COUNT = 3;
11
+ export declare const SUGGESTED_REPLY_MAX = 8;
12
+ /** 去掉脚本调用、绝对路径,留下用户能点的短标题。 */
13
+ export declare function stripSkillInvocation(raw: string): string;
11
14
  /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
12
15
  export declare function cleanSuggestedReply(raw: string): string;
13
16
  /**
14
- * 从模型原文抽出最多 3 条建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
17
+ * 从技能正文抽出「下一步 / 后续 / Next steps」节里的列表项。
18
+ * 兼容用户自建技能:标题不固定,脚本调用行只保留标题。
19
+ */
20
+ export declare function extractSkillNextSteps(content: string): string[];
21
+ /**
22
+ * 从助手回复抽出已列出的下一步。先看「下一步」类标题,再看末尾带脚本调用的编号列表。
23
+ */
24
+ export declare function extractListedNextSteps(text: string): string[];
25
+ /**
26
+ * 从模型原文抽出建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
27
+ * 最多 {@link SUGGESTED_REPLY_MAX} 条。
15
28
  */
16
29
  export declare function parseSuggestedReplies(raw: string): string[];
30
+ export interface FallbackSuggestedRepliesOptions {
31
+ /** 用户/工作区技能正文(可多份)。会从中抽出「下一步」节。 */
32
+ skillContents?: string[];
33
+ }
17
34
  /**
18
- * 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
19
- * 始终返回正好 3 条(输入全空时仍给通用三条)。
35
+ * 不调模型的建议。技能或回复里已有下一步时原样采用(条数不固定),
36
+ * 否则回落到启发式句子。
20
37
  */
21
- export declare function fallbackSuggestedReplies(userText: string, assistantText: string): string[];
38
+ export declare function fallbackSuggestedReplies(userText: string, assistantText: string, opts?: FallbackSuggestedRepliesOptions): string[];
22
39
  export interface GenerateSuggestedRepliesResult {
23
40
  suggestions: string[];
24
- /** true = LLM 失败或输出不合格,suggestions 来自启发式兜底。 */
41
+ /** true = LLM 失败或输出不合格,且没有从技能/回复抽出下一步,suggestions 来自启发式兜底。 */
25
42
  usedFallback: boolean;
26
43
  }
44
+ export interface GenerateSuggestedRepliesOptions {
45
+ perAttemptTimeoutMs?: number;
46
+ /** 用户/工作区技能正文。生成器只抽「下一步」节,不依赖内置技能。 */
47
+ skillContents?: string[];
48
+ }
27
49
  /**
28
- * 生成本轮 3 条追问建议。永不抛错,始终返回 3 条。
50
+ * 生成本轮追问建议。永不抛错;有技能/回复下一步时按实际条数返回。
29
51
  */
30
- export declare function generateSuggestedReplies(userText: string, assistantText: string, opts?: {
31
- perAttemptTimeoutMs?: number;
32
- }): Promise<GenerateSuggestedRepliesResult>;
52
+ export declare function generateSuggestedReplies(userText: string, assistantText: string, opts?: GenerateSuggestedRepliesOptions): Promise<GenerateSuggestedRepliesResult>;
@@ -1,26 +1,40 @@
1
1
  /**
2
- * 回合结束后生成 3 条「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
2
+ * 回合结束后生成「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
3
3
  *
4
- * 主路径走一次短 LLM 调用,根据本轮用户请求 + 助手回复写成用户口吻的短句。
5
- * 失败时用启发式兜底(PPT / 计划 / 代码 / 通用),永不抛错。
4
+ * 主路径走一次短 LLM 调用。优先消化用户/工作区技能和助手回复里已经写明的
5
+ * 下一步(编号列表、脚本调用、推荐后续),条数跟真实下一步走,不凑 3 条。
6
+ * 没有可执行下一步时,才用启发式兜底(PPT / 计划 / 代码 / 通用)。永不抛错。
6
7
  *
7
- * 调用方应该 fire-and-forget:先把兜底三条推上 UI,LLM 成功后再替换。
8
+ * 调用方应该 fire-and-forget:先把兜底推上 UI,LLM 成功后再替换。
8
9
  * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
9
10
  */
10
11
  import { llmService } from '../llm/index.js';
11
- export const SUGGESTED_REPLY_COUNT = 3;
12
- const MAX_SUGGESTION_CHARS = 36;
12
+ export const SUGGESTED_REPLY_MAX = 8;
13
+ const MAX_SUGGESTION_CHARS = 48;
13
14
  const USER_TEXT_LIMIT = 500;
14
- const ASSISTANT_TEXT_LIMIT = 1600;
15
- const SYSTEM_PROMPT = `你是对话追问建议助手。根据用户上一轮请求和助手刚刚完成的回复,生成 3 条用户可以点一下就发出去的下一轮输入。
15
+ const ASSISTANT_HEAD_LIMIT = 800;
16
+ const ASSISTANT_TAIL_LIMIT = 2000;
17
+ const SKILL_EXCERPT_LIMIT = 4000;
18
+ const SYSTEM_PROMPT = `你是对话追问建议助手。根据用户上一轮请求、助手刚刚完成的回复,以及用户技能里写明的下一步,生成用户点一下就能发出去的下一轮输入。
16
19
 
17
20
  要求:
18
- 1. 正好 3 条,彼此不重复
19
- 2. 每条 8-28 个字,用用户口吻,像用户会打的话
20
- 3. 紧扣刚刚完成的工作:改内容、改样式、继续深入、换方向;不要空泛的「再详细说说」
21
- 4. 如果助手已经邀请后续操作(例如「如需修改内容或调整样式」),把那些邀请写成具体可执行的短句
22
- 5. 不要编号、不要引号、不要解释
23
- 6. 只输出 JSON 字符串数组,例如 ["调整封面配色","把个人简介写得更具体","再加一页项目案例"]`;
21
+ 1. 条数不固定:有几条真实的下一步就给几条,最少 1 条,最多 8 条。不要为了凑数编造,也不要无故压成 3 条。
22
+ 2. 优先使用「用户技能中的下一步」和助手回复末尾已经列出的后续动作(编号列表、脚本调用、推荐后续)。把它们改写成用户口吻的短指令,保留层位、对象、参数等关键信息,不要丢掉技能里的具体动作。
23
+ 3. 助手本轮已经做完的步骤不要再建议;技能里写了但还没做的后续必须出现。
24
+ 4. 每条 6-40 个字,像用户会打的话。不要空泛的「再详细说说」「告诉我下一步怎么做」。
25
+ 5. 不要编号、不要引号、不要解释、不要附带命令行或绝对路径。
26
+ 6. 只输出 JSON 字符串数组,例如 ["画接底层的井密度交会图","统计这口井的有效厚度","补一张气层厚度等值线"]`;
27
+ const NEXT_STEP_WORDS = '下一步(?:动作|操作|工作)?|后续(?:步骤|工作|动作|操作|可做)?|建议(?:的)?(?:下一步|继续|操作)?|可以继续|推荐(?:的)?(?:后续|操作|下一步)?|next\\s*steps?';
28
+ /** 整行即「下一步」类标题:可带 #/** 前缀、括号补充说明(如「后续动作(可点选)」)、结尾冒号。 */
29
+ const NEXT_STEP_HEADING_RE = new RegExp(`^(?:#{1,6}\\s*)?(?:\\*\\*)?(?:${NEXT_STEP_WORDS})(?:\\s*[((][^))]*[))])?\\s*(?:\\*\\*)?\\s*[::]?$`, 'i');
30
+ /**
31
+ * 短引导行,如「完成后可以继续:」。必须以关键词加冒号收尾且整行够短——
32
+ * 否则「以上是我的建议」这类普通句尾会被当成标题,把后面无关的列表抽成建议。
33
+ */
34
+ const NEXT_STEP_LEAD_IN_RE = new RegExp(`(?:${NEXT_STEP_WORDS})\\s*[::]$`, 'i');
35
+ const NEXT_STEP_LEAD_IN_MAX_CHARS = 16;
36
+ // 列表标记后必须有空白,否则 PowerShell 参数(-Action)会被当成列表项。
37
+ const LIST_ITEM_RE = /^(?:\d+[.)、]|[-*•])\s+(\S.*)$/;
24
38
  function withTimeout(promise, timeoutMs, label) {
25
39
  if (timeoutMs <= 0)
26
40
  return promise;
@@ -43,9 +57,54 @@ function clip(text, limit) {
43
57
  return chars.join('');
44
58
  return `${chars.slice(0, limit).join('')}…`;
45
59
  }
60
+ function clipHeadTail(text, headLimit, tailLimit) {
61
+ const chars = Array.from((text ?? '').trim());
62
+ if (chars.length <= headLimit + tailLimit)
63
+ return chars.join('');
64
+ return `${chars.slice(0, headLimit).join('')}…\n…${chars.slice(-tailLimit).join('')}`;
65
+ }
66
+ function isNextStepHeading(line) {
67
+ return NEXT_STEP_HEADING_RE.test(line.trim());
68
+ }
69
+ function isNextStepLeadIn(line) {
70
+ const trimmed = line.trim();
71
+ if (isNextStepHeading(trimmed))
72
+ return true;
73
+ return (Array.from(trimmed).length <= NEXT_STEP_LEAD_IN_MAX_CHARS &&
74
+ NEXT_STEP_LEAD_IN_RE.test(trimmed));
75
+ }
76
+ function isMarkdownHeading(line) {
77
+ return /^\s*#{1,6}\s+\S/.test(line);
78
+ }
79
+ /** 列表项正文;不是列表项返回 null。 */
80
+ function listItemBody(line) {
81
+ const matched = line.trim().match(LIST_ITEM_RE);
82
+ return matched ? matched[1].trim() : null;
83
+ }
84
+ function isListItem(line) {
85
+ return listItemBody(line) !== null;
86
+ }
87
+ function hasScriptInvocation(text) {
88
+ return (/(?:^|\s)[&|]\s+/.test(text) ||
89
+ /\.(?:ps1|py|js|mjs|sh|bat|cmd)\b/i.test(text) ||
90
+ /\{scripts\}/i.test(text) ||
91
+ /(?:^|[\s`])(?:powershell|pwsh)\b/i.test(text) ||
92
+ /[A-Za-z]:\\/.test(text) ||
93
+ /[/\\]skills[/\\]/.test(text));
94
+ }
95
+ /** 去掉脚本调用、绝对路径,留下用户能点的短标题。 */
96
+ export function stripSkillInvocation(raw) {
97
+ let text = (raw ?? '').trim();
98
+ text = text.replace(/`([^`]+)`/g, '$1');
99
+ text = text.replace(/\s+[&|]\s+.+$/s, '');
100
+ text = text.replace(/\s+[A-Za-z]:\\[^\s].*$/, '');
101
+ text = text.replace(/\s+(?:powershell(?:\.exe)?|pwsh)\b.*$/i, '');
102
+ text = text.replace(/\s+\{scripts\}.*$/i, '');
103
+ return text.trim();
104
+ }
46
105
  /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
47
106
  export function cleanSuggestedReply(raw) {
48
- let text = (raw ?? '').trim();
107
+ let text = stripSkillInvocation(raw ?? '');
49
108
  text = text.replace(/^[\s"'“”‘’「」『』《》【】]+|[\s"'“”‘’「」『』《》【】。.!!??,,;;::]+$/g, '');
50
109
  text = text.replace(/^(?:\d+[\.\)、]|[-*•])\s*/, '');
51
110
  text = text.replace(/\s+/g, ' ').trim();
@@ -57,7 +116,7 @@ export function cleanSuggestedReply(raw) {
57
116
  }
58
117
  return text;
59
118
  }
60
- function uniqueThree(candidates, extras = []) {
119
+ function uniqueSuggestions(candidates, extras = []) {
61
120
  const out = [];
62
121
  const seen = new Set();
63
122
  for (const raw of [...candidates, ...extras]) {
@@ -66,13 +125,132 @@ function uniqueThree(candidates, extras = []) {
66
125
  continue;
67
126
  seen.add(cleaned);
68
127
  out.push(cleaned);
69
- if (out.length === SUGGESTED_REPLY_COUNT)
128
+ if (out.length === SUGGESTED_REPLY_MAX)
70
129
  break;
71
130
  }
72
131
  return out;
73
132
  }
133
+ function collectListAfter(lines, start) {
134
+ const items = [];
135
+ for (let i = start; i < lines.length; i += 1) {
136
+ const trimmed = lines[i].trim();
137
+ // 列表项之间常有空行,空行不终止收集。
138
+ if (!trimmed)
139
+ continue;
140
+ if (isMarkdownHeading(trimmed) || isNextStepHeading(trimmed))
141
+ break;
142
+ const body = listItemBody(trimmed);
143
+ if (body === null) {
144
+ if (items.length > 0)
145
+ break;
146
+ continue;
147
+ }
148
+ const title = stripSkillInvocation(body);
149
+ if (title)
150
+ items.push(title);
151
+ }
152
+ return items;
153
+ }
74
154
  /**
75
- * 从模型原文抽出最多 3 条建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
155
+ * 从技能正文抽出「下一步 / 后续 / Next steps」节里的列表项。
156
+ * 兼容用户自建技能:标题不固定,脚本调用行只保留标题。
157
+ */
158
+ export function extractSkillNextSteps(content) {
159
+ const lines = (content ?? '').split(/\r?\n/);
160
+ const collected = [];
161
+ for (let i = 0; i < lines.length; i += 1) {
162
+ if (!isNextStepLeadIn(lines[i]))
163
+ continue;
164
+ collected.push(...collectListAfter(lines, i + 1));
165
+ }
166
+ return uniqueSuggestions(collected);
167
+ }
168
+ function extractHeadingNextSteps(text) {
169
+ const lines = (text ?? '').split(/\r?\n/);
170
+ let lastLeadIn = -1;
171
+ for (let i = 0; i < lines.length; i += 1) {
172
+ if (isNextStepLeadIn(lines[i]))
173
+ lastLeadIn = i;
174
+ }
175
+ if (lastLeadIn < 0)
176
+ return [];
177
+ return uniqueSuggestions(collectListAfter(lines, lastLeadIn + 1));
178
+ }
179
+ function extractTrailingScriptedList(text) {
180
+ const lines = (text ?? '').split(/\r?\n/);
181
+ let best = [];
182
+ let i = 0;
183
+ while (i < lines.length) {
184
+ if (!isListItem(lines[i])) {
185
+ i += 1;
186
+ continue;
187
+ }
188
+ const rawItems = [];
189
+ const titles = [];
190
+ while (i < lines.length) {
191
+ const trimmed = lines[i].trim();
192
+ if (!trimmed) {
193
+ i += 1;
194
+ continue;
195
+ }
196
+ const body = listItemBody(trimmed);
197
+ if (body === null)
198
+ break;
199
+ rawItems.push(body);
200
+ const title = stripSkillInvocation(body);
201
+ if (title)
202
+ titles.push(title);
203
+ i += 1;
204
+ }
205
+ if (titles.length >= 2 && rawItems.some((item) => hasScriptInvocation(item))) {
206
+ best = titles;
207
+ }
208
+ }
209
+ return uniqueSuggestions(best);
210
+ }
211
+ /**
212
+ * 从助手回复抽出已列出的下一步。先看「下一步」类标题,再看末尾带脚本调用的编号列表。
213
+ */
214
+ export function extractListedNextSteps(text) {
215
+ const fromHeading = extractHeadingNextSteps(text);
216
+ if (fromHeading.length > 0)
217
+ return fromHeading;
218
+ return extractTrailingScriptedList(text);
219
+ }
220
+ function skillTitle(content) {
221
+ const first = (content.split(/\r?\n/, 1)[0] ?? '').trim();
222
+ const matched = first.match(/^#{1,6}\s+(.*)$/);
223
+ return matched ? matched[1].trim() : '';
224
+ }
225
+ /**
226
+ * 各技能「下一步」节的合并结果,本轮点到名的技能排在前面。用户可能装了很多
227
+ * 技能,与本轮无关的后续不该把相关的那几条挤出 {@link SUGGESTED_REPLY_MAX}。
228
+ */
229
+ function extractFromSkillContents(skillContents, turnText = '') {
230
+ if (!skillContents || skillContents.length === 0)
231
+ return [];
232
+ const ranked = skillContents
233
+ .map((content, index) => {
234
+ const title = skillTitle(content);
235
+ return { content, index, mentioned: title.length >= 2 && turnText.includes(title) };
236
+ })
237
+ .sort((a, b) => Number(b.mentioned) - Number(a.mentioned) || a.index - b.index);
238
+ const collected = [];
239
+ for (const entry of ranked) {
240
+ collected.push(...extractSkillNextSteps(entry.content));
241
+ }
242
+ return uniqueSuggestions(collected);
243
+ }
244
+ /** 本轮可用的下一步:助手回复里已列出的,加上用户技能写明的。 */
245
+ function collectNextSteps(userText, assistantText, skillContents) {
246
+ return uniqueSuggestions([
247
+ ...extractListedNextSteps(assistantText),
248
+ ...extractFromSkillContents(skillContents, `${userText}\n${assistantText}`),
249
+ ]);
250
+ }
251
+ /**
252
+ * 从模型原文抽出建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
253
+ * 最多 {@link SUGGESTED_REPLY_MAX} 条。
76
254
  */
77
255
  export function parseSuggestedReplies(raw) {
78
256
  const trimmed = (raw ?? '').trim();
@@ -86,7 +264,7 @@ export function parseSuggestedReplies(raw) {
86
264
  try {
87
265
  const parsed = JSON.parse(candidate.slice(start, end + 1));
88
266
  if (Array.isArray(parsed)) {
89
- return uniqueThree(parsed.filter((item) => typeof item === 'string'));
267
+ return uniqueSuggestions(parsed.filter((item) => typeof item === 'string'));
90
268
  }
91
269
  }
92
270
  catch {
@@ -97,64 +275,109 @@ export function parseSuggestedReplies(raw) {
97
275
  .split(/\r?\n/)
98
276
  .map((line) => line.trim())
99
277
  .filter((line) => line.length > 0 && !/^```/.test(line));
100
- const listLines = lines.filter((line) => /^(?:\d+[\.\)、]|[-*•])\s+/.test(line));
101
- return uniqueThree(listLines.length > 0 ? listLines : lines);
278
+ const listLines = lines.filter((line) => isListItem(line));
279
+ return uniqueSuggestions(listLines.length > 0 ? listLines : lines);
102
280
  }
103
281
  const PPT_FALLBACK = ['调整封面标题和配色', '把某一页内容写得更具体', '再加一页项目案例'];
104
282
  const PLAN_FALLBACK = ['按这个计划开始执行', '先改第 2 步再执行', '把计划写得更细一点'];
105
283
  const CODE_FALLBACK = ['解释这段实现的思路', '帮我补上测试', '再优化一下可读性'];
106
284
  const GENERIC_FALLBACK = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
107
285
  const PPT_OFFER_FALLBACK = ['调整幻灯片的内容和文案', '调整配色和版式', '再加一页补充材料'];
108
- /**
109
- * 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
110
- * 始终返回正好 3 条(输入全空时仍给通用三条)。
111
- */
112
- export function fallbackSuggestedReplies(userText, assistantText) {
286
+ /** 没有下一步可用时的句子:PPT / 计划 / 代码走专用三条,其它走通用三条。 */
287
+ function heuristicSuggestions(userText, assistantText) {
113
288
  const blob = `${userText}\n${assistantText}`;
114
289
  if (/\.pptx\b|幻灯片|演示文稿|\bppt\b/i.test(blob)) { // shell-neutral:allow — Office 扩展名 'ppt'/'.pptx',不是产品品牌
115
290
  if (/修改内容|调整样式/.test(assistantText)) {
116
- return uniqueThree(PPT_OFFER_FALLBACK, PPT_FALLBACK);
291
+ return uniqueSuggestions(PPT_OFFER_FALLBACK);
117
292
  }
118
- return uniqueThree(PPT_FALLBACK, GENERIC_FALLBACK);
293
+ return uniqueSuggestions(PPT_FALLBACK);
119
294
  }
120
295
  if (/标准工作流程|待办清单|\bTODO\b|先制定计划/.test(blob) || /^\s*计划已/.test(assistantText)) {
121
- return uniqueThree(PLAN_FALLBACK, GENERIC_FALLBACK);
296
+ return uniqueSuggestions(PLAN_FALLBACK);
122
297
  }
123
298
  if (/```|单元测试|函数实现|补测试/.test(blob)) {
124
- return uniqueThree(CODE_FALLBACK, GENERIC_FALLBACK);
299
+ return uniqueSuggestions(CODE_FALLBACK);
300
+ }
301
+ return uniqueSuggestions(GENERIC_FALLBACK);
302
+ }
303
+ /**
304
+ * 不调模型的建议。技能或回复里已有下一步时原样采用(条数不固定),
305
+ * 否则回落到启发式句子。
306
+ */
307
+ export function fallbackSuggestedReplies(userText, assistantText, opts = {}) {
308
+ const extracted = collectNextSteps(userText, assistantText, opts.skillContents);
309
+ if (extracted.length > 0)
310
+ return extracted;
311
+ return heuristicSuggestions(userText, assistantText);
312
+ }
313
+ /**
314
+ * 提示词里「用户技能中的下一步」一节。只收真的写了下一步的技能——
315
+ * 把其余技能的正文片段塞进来只会稀释上下文,且与小节标题不符。
316
+ */
317
+ function buildSkillExcerpt(skillContents) {
318
+ if (!skillContents || skillContents.length === 0)
319
+ return '';
320
+ const parts = [];
321
+ let used = 0;
322
+ for (const content of skillContents) {
323
+ const steps = extractSkillNextSteps(content);
324
+ if (steps.length === 0)
325
+ continue;
326
+ const title = skillTitle(content);
327
+ const body = steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
328
+ const chunk = title ? `【${title}】\n${body}` : body;
329
+ const next = used + chunk.length;
330
+ if (next > SKILL_EXCERPT_LIMIT && parts.length > 0)
331
+ break;
332
+ parts.push(chunk);
333
+ used = next;
125
334
  }
126
- return uniqueThree(GENERIC_FALLBACK);
335
+ return parts.join('\n\n');
127
336
  }
128
337
  /**
129
- * 生成本轮 3 条追问建议。永不抛错,始终返回 3 条。
338
+ * 生成本轮追问建议。永不抛错;有技能/回复下一步时按实际条数返回。
130
339
  */
131
340
  export async function generateSuggestedReplies(userText, assistantText, opts = {}) {
132
- const fallback = fallbackSuggestedReplies(userText, assistantText);
341
+ const extracted = collectNextSteps(userText, assistantText, opts.skillContents);
133
342
  const user = clip(userText, USER_TEXT_LIMIT);
134
- const assistant = clip(assistantText, ASSISTANT_TEXT_LIMIT);
135
- if (!user && !assistant) {
136
- return { suggestions: fallback, usedFallback: true };
343
+ const assistant = clipHeadTail(assistantText, ASSISTANT_HEAD_LIMIT, ASSISTANT_TAIL_LIMIT);
344
+ const skillExcerpt = buildSkillExcerpt(opts.skillContents);
345
+ if (!user && !assistant && extracted.length === 0) {
346
+ return { suggestions: heuristicSuggestions(userText, assistantText), usedFallback: true };
137
347
  }
138
348
  const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 12_000;
139
349
  try {
350
+ const skillBlock = skillExcerpt
351
+ ? `\n\n用户技能中的下一步:\n${skillExcerpt}`
352
+ : '';
353
+ const extractedBlock = extracted.length > 0
354
+ ? `\n\n已从回复或技能抽出的候选(请改写成用户口吻,保留这些动作,不要压成 3 条):\n${extracted.map((item, index) => `${index + 1}. ${item}`).join('\n')}`
355
+ : '';
140
356
  const result = await withTimeout(llmService.generate({
141
357
  messages: [
142
358
  { role: 'system', content: SYSTEM_PROMPT },
143
359
  {
144
360
  role: 'user',
145
- content: `用户请求:\n${user || '(空)'}\n\n助手回复:\n${assistant || '(空)'}`,
361
+ content: `用户请求:\n${user || '(空)'}\n\n助手回复:\n${assistant || '(空)'}${skillBlock}${extractedBlock}`,
146
362
  },
147
363
  ],
148
364
  temperature: 0.6,
149
365
  }), perAttemptTimeoutMs, 'ai-suggestions');
150
366
  const parsed = parseSuggestedReplies(result.content ?? '');
151
- const suggestions = uniqueThree(parsed, fallback);
152
- if (suggestions.length === SUGGESTED_REPLY_COUNT && parsed.length > 0) {
153
- return { suggestions, usedFallback: false };
367
+ if (parsed.length > 0) {
368
+ const suggestions = extracted.length > parsed.length
369
+ ? uniqueSuggestions(parsed, extracted)
370
+ : uniqueSuggestions(parsed);
371
+ if (suggestions.length > 0) {
372
+ return { suggestions, usedFallback: false };
373
+ }
154
374
  }
155
375
  }
156
376
  catch (err) {
157
377
  console.warn('[ai-suggestions] LLM 追问建议失败,用启发式兜底', err);
158
378
  }
159
- return { suggestions: fallback, usedFallback: true };
379
+ if (extracted.length > 0) {
380
+ return { suggestions: extracted, usedFallback: false };
381
+ }
382
+ return { suggestions: heuristicSuggestions(userText, assistantText), usedFallback: true };
160
383
  }
@@ -174,11 +174,13 @@ export declare class LocalBackendRouter {
174
174
  */
175
175
  private handleCoreLoopTurn;
176
176
  /**
177
- * 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
178
- * 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
179
- * 之后渲染端已经不再监听这条流。
177
+ * 回合结束后生成下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
178
+ * 再读用户/工作区技能正文,fire-and-forget 跑 LLM,成功则替换。
179
+ * 走 broadcast 而不是 SSE:`[DONE]` 之后渲染端已经不再监听这条流。
180
180
  */
181
181
  private runSuggestedRepliesInBackground;
182
+ /** 用户/工作区技能正文,供追问建议抽出「下一步」。内置技能不参与。 */
183
+ private loadUserSkillSuggestionTexts;
182
184
  private publishSuggestedReplies;
183
185
  /**
184
186
  * 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
@@ -3205,9 +3205,9 @@ export class LocalBackendRouter {
3205
3205
  return { status: 200 };
3206
3206
  }
3207
3207
  /**
3208
- * 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
3209
- * 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
3210
- * 之后渲染端已经不再监听这条流。
3208
+ * 回合结束后生成下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
3209
+ * 再读用户/工作区技能正文,fire-and-forget 跑 LLM,成功则替换。
3210
+ * 走 broadcast 而不是 SSE:`[DONE]` 之后渲染端已经不再监听这条流。
3211
3211
  */
3212
3212
  runSuggestedRepliesInBackground(args) {
3213
3213
  const { chatId, messageId, userText, assistantText, completionStatus } = args;
@@ -3219,13 +3219,21 @@ export class LocalBackendRouter {
3219
3219
  this.publishSuggestedReplies(chatId, messageId, fallback);
3220
3220
  void (async () => {
3221
3221
  try {
3222
+ const skillContents = await this.loadUserSkillSuggestionTexts();
3223
+ const enriched = fallbackSuggestedReplies(userText, assistantText, { skillContents });
3224
+ if (enriched.length !== fallback.length ||
3225
+ enriched.some((item, i) => item !== fallback[i])) {
3226
+ this.publishSuggestedReplies(chatId, messageId, enriched);
3227
+ }
3222
3228
  const result = await generateSuggestedReplies(userText, assistantText, {
3223
3229
  perAttemptTimeoutMs: 60_000,
3230
+ skillContents,
3224
3231
  });
3225
3232
  if (result.usedFallback)
3226
3233
  return;
3227
- const same = result.suggestions.length === fallback.length &&
3228
- result.suggestions.every((item, i) => item === fallback[i]);
3234
+ const baseline = enriched.length > 0 ? enriched : fallback;
3235
+ const same = result.suggestions.length === baseline.length &&
3236
+ result.suggestions.every((item, i) => item === baseline[i]);
3229
3237
  if (same)
3230
3238
  return;
3231
3239
  this.publishSuggestedReplies(chatId, messageId, result.suggestions);
@@ -3235,6 +3243,26 @@ export class LocalBackendRouter {
3235
3243
  }
3236
3244
  })();
3237
3245
  }
3246
+ /** 用户/工作区技能正文,供追问建议抽出「下一步」。内置技能不参与。 */
3247
+ async loadUserSkillSuggestionTexts() {
3248
+ try {
3249
+ const modules = await loadSkills({ ignoreConditions: true });
3250
+ return modules
3251
+ .filter((module) => classifySkillOrigin(module.skillsDir) !== 'builtin')
3252
+ .map((module) => {
3253
+ const title = (module.displayName || module.name || module.dirName || '').trim();
3254
+ const body = (module.content ?? '').trim();
3255
+ if (!body)
3256
+ return '';
3257
+ return title ? `# ${title}\n${body}` : body;
3258
+ })
3259
+ .filter(Boolean);
3260
+ }
3261
+ catch (err) {
3262
+ console.warn('[local-backend] suggested-replies skill load failed', err);
3263
+ return [];
3264
+ }
3265
+ }
3238
3266
  async publishSuggestedReplies(chatId, messageId, suggestions) {
3239
3267
  if (suggestions.length === 0)
3240
3268
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.25",
3
+ "version": "0.6.26",
4
4
  "description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -49,9 +49,9 @@
49
49
  "electron-store": "^8.1.0",
50
50
  "he": "^1.2.0",
51
51
  "node-pty": "^1.1.0",
52
- "@steerable/agent-harness": "0.6.25",
53
- "@steerable/agent-protocol": "0.6.25",
54
- "@steerable/pack-sdk": "0.6.25"
52
+ "@steerable/agent-harness": "0.6.26",
53
+ "@steerable/agent-protocol": "0.6.26",
54
+ "@steerable/pack-sdk": "0.6.26"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/better-sqlite3": "^7.6.13",