@steerable/agent-shell 0.6.35 → 0.6.37

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.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * electron-updater 适配。只在桌面主进程启用更新时加载。
3
+ */
4
+ import type { AppUpdaterPort } from './app-update.js';
5
+ export declare function createElectronAppUpdater(): AppUpdaterPort;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * electron-updater 适配。只在桌面主进程启用更新时加载。
3
+ */
4
+ import log from 'electron-log';
5
+ import electronUpdater from 'electron-updater';
6
+ export function createElectronAppUpdater() {
7
+ const { autoUpdater } = electronUpdater;
8
+ autoUpdater.logger = log;
9
+ return {
10
+ get autoDownload() {
11
+ return autoUpdater.autoDownload;
12
+ },
13
+ set autoDownload(value) {
14
+ autoUpdater.autoDownload = value;
15
+ },
16
+ get autoInstallOnAppQuit() {
17
+ return autoUpdater.autoInstallOnAppQuit;
18
+ },
19
+ set autoInstallOnAppQuit(value) {
20
+ autoUpdater.autoInstallOnAppQuit = value;
21
+ },
22
+ get allowDowngrade() {
23
+ return autoUpdater.allowDowngrade;
24
+ },
25
+ set allowDowngrade(value) {
26
+ autoUpdater.allowDowngrade = value;
27
+ },
28
+ setFeedURL(options) {
29
+ autoUpdater.setFeedURL(options);
30
+ },
31
+ async checkForUpdates() {
32
+ const result = await autoUpdater.checkForUpdates();
33
+ if (!result)
34
+ return null;
35
+ return {
36
+ updateInfo: { version: result.updateInfo.version },
37
+ isUpdateAvailable: result.isUpdateAvailable,
38
+ };
39
+ },
40
+ quitAndInstall(isSilent, isForceRunAfter) {
41
+ autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
42
+ },
43
+ on(event, listener) {
44
+ if (event === 'download-progress') {
45
+ autoUpdater.on('download-progress', listener);
46
+ }
47
+ else if (event === 'update-downloaded') {
48
+ autoUpdater.on('update-downloaded', listener);
49
+ }
50
+ else {
51
+ autoUpdater.on('error', listener);
52
+ }
53
+ },
54
+ off(event, listener) {
55
+ if (event === 'download-progress') {
56
+ autoUpdater.off('download-progress', listener);
57
+ }
58
+ else if (event === 'update-downloaded') {
59
+ autoUpdater.off('update-downloaded', listener);
60
+ }
61
+ else {
62
+ autoUpdater.off('error', listener);
63
+ }
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 桌面自动更新。产品经 `ProductConfig.updates.feedUrl` 注入 generic 通道;
3
+ * 本模块只编排检查、下载和安装,具体传输交给 `AppUpdaterPort`
4
+ * (生产环境是 electron-updater)。
5
+ */
6
+ export declare const APP_UPDATE_CHECK_INTERVAL_MS: number;
7
+ export type AppUpdatePhase = 'disabled' | 'idle' | 'checking' | 'downloading' | 'ready' | 'installing' | 'error';
8
+ export interface AppUpdateState {
9
+ phase: AppUpdatePhase;
10
+ version?: string;
11
+ percent?: number;
12
+ message?: string;
13
+ }
14
+ export interface AppUpdateCheckResult {
15
+ updateInfo: {
16
+ version: string;
17
+ };
18
+ isUpdateAvailable: boolean;
19
+ }
20
+ export interface AppUpdaterPort {
21
+ autoDownload: boolean;
22
+ autoInstallOnAppQuit: boolean;
23
+ allowDowngrade: boolean;
24
+ setFeedURL(options: {
25
+ provider: 'generic';
26
+ url: string;
27
+ }): void;
28
+ checkForUpdates(): Promise<AppUpdateCheckResult | null>;
29
+ quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
30
+ on(event: 'download-progress', listener: (progress: {
31
+ percent: number;
32
+ }) => void): void;
33
+ on(event: 'update-downloaded', listener: (info: {
34
+ version: string;
35
+ }) => void): void;
36
+ on(event: 'error', listener: (error: Error) => void): void;
37
+ off(event: 'download-progress', listener: (progress: {
38
+ percent: number;
39
+ }) => void): void;
40
+ off(event: 'update-downloaded', listener: (info: {
41
+ version: string;
42
+ }) => void): void;
43
+ off(event: 'error', listener: (error: Error) => void): void;
44
+ }
45
+ export interface AppUpdateOptions {
46
+ feedUrl?: string;
47
+ packaged: boolean;
48
+ currentVersion: string;
49
+ env: NodeJS.ProcessEnv;
50
+ updater: AppUpdaterPort;
51
+ onState?: (state: AppUpdateState) => void;
52
+ /** 测试注入。缺省写 `env.DEEPPATH_UPDATE_STATUS_FILE`。 */
53
+ statusFile?: string;
54
+ }
55
+ export declare function appUpdateMenuAction(state: AppUpdateState): 'none' | 'check' | 'install';
56
+ export declare function shouldNotifyUpdateReady(previous: AppUpdatePhase, next: AppUpdateState): boolean;
57
+ export declare class AppUpdateController {
58
+ private readonly options;
59
+ private current;
60
+ private enabled;
61
+ private started;
62
+ private disposed;
63
+ private bound;
64
+ private candidate;
65
+ private downloadedVersion;
66
+ private checkPromise;
67
+ private cancelTimer;
68
+ private readonly statusFile;
69
+ constructor(options: AppUpdateOptions);
70
+ get state(): AppUpdateState;
71
+ start(): Promise<AppUpdateState>;
72
+ checkNow(): Promise<AppUpdateState>;
73
+ installNow(): AppUpdateState;
74
+ dispose(): void;
75
+ private bind;
76
+ private readonly onProgress;
77
+ private readonly onDownloaded;
78
+ private readonly onError;
79
+ private doCheck;
80
+ private isNewer;
81
+ private publish;
82
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 桌面自动更新。产品经 `ProductConfig.updates.feedUrl` 注入 generic 通道;
3
+ * 本模块只编排检查、下载和安装,具体传输交给 `AppUpdaterPort`
4
+ * (生产环境是 electron-updater)。
5
+ */
6
+ import { appendFileSync } from 'node:fs';
7
+ import { gt, valid } from 'semver';
8
+ export const APP_UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
9
+ export function appUpdateMenuAction(state) {
10
+ if (state.phase === 'disabled')
11
+ return 'none';
12
+ if (state.phase === 'ready' || state.phase === 'installing')
13
+ return 'install';
14
+ return 'check';
15
+ }
16
+ export function shouldNotifyUpdateReady(previous, next) {
17
+ return previous !== 'ready' && next.phase === 'ready' && Boolean(next.version);
18
+ }
19
+ function resolveFeed(options) {
20
+ const override = options.env.DEEPPATH_UPDATE_FEED_URL?.trim() ?? '';
21
+ const feedUrl = override || options.feedUrl?.trim() || '';
22
+ const forceUnpackaged = options.env.DEEPPATH_APP_UPDATE === '1';
23
+ if (!feedUrl || (!options.packaged && !forceUnpackaged))
24
+ return { enabled: false };
25
+ return { enabled: true, feedUrl };
26
+ }
27
+ function clampPercent(percent) {
28
+ if (!Number.isFinite(percent))
29
+ return 0;
30
+ return Math.min(100, Math.max(0, percent));
31
+ }
32
+ function defaultSchedule(fn, ms) {
33
+ const timer = setInterval(fn, ms);
34
+ timer.unref();
35
+ return () => clearInterval(timer);
36
+ }
37
+ export class AppUpdateController {
38
+ options;
39
+ current = { phase: 'disabled' };
40
+ enabled = false;
41
+ started = false;
42
+ disposed = false;
43
+ bound = false;
44
+ candidate;
45
+ downloadedVersion;
46
+ checkPromise;
47
+ cancelTimer;
48
+ statusFile;
49
+ constructor(options) {
50
+ this.options = options;
51
+ this.statusFile = options.statusFile ?? options.env.DEEPPATH_UPDATE_STATUS_FILE;
52
+ }
53
+ get state() {
54
+ return this.current;
55
+ }
56
+ async start() {
57
+ if (this.started)
58
+ return this.checkPromise ?? this.state;
59
+ this.started = true;
60
+ const target = resolveFeed(this.options);
61
+ if (!target.enabled)
62
+ return this.publish({ phase: 'disabled' });
63
+ this.enabled = true;
64
+ this.options.updater.autoDownload = true;
65
+ this.options.updater.autoInstallOnAppQuit = true;
66
+ this.options.updater.allowDowngrade = false;
67
+ this.options.updater.setFeedURL({ provider: 'generic', url: target.feedUrl });
68
+ this.bind();
69
+ const first = this.checkNow();
70
+ this.cancelTimer = defaultSchedule(() => {
71
+ void this.checkNow();
72
+ }, APP_UPDATE_CHECK_INTERVAL_MS);
73
+ return first;
74
+ }
75
+ checkNow() {
76
+ if (this.disposed || !this.enabled)
77
+ return Promise.resolve(this.state);
78
+ if (this.state.phase === 'downloading' || this.state.phase === 'ready' || this.state.phase === 'installing') {
79
+ return Promise.resolve(this.state);
80
+ }
81
+ if (this.checkPromise)
82
+ return this.checkPromise;
83
+ this.checkPromise = this.doCheck().finally(() => {
84
+ this.checkPromise = undefined;
85
+ });
86
+ return this.checkPromise;
87
+ }
88
+ installNow() {
89
+ if (this.disposed || this.state.phase !== 'ready' || !this.downloadedVersion)
90
+ return this.state;
91
+ const version = this.downloadedVersion;
92
+ this.publish({ phase: 'installing', version });
93
+ try {
94
+ this.options.updater.quitAndInstall(false, true);
95
+ }
96
+ catch (error) {
97
+ return this.publish({
98
+ phase: 'error',
99
+ version,
100
+ message: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ return this.state;
104
+ }
105
+ dispose() {
106
+ this.disposed = true;
107
+ this.cancelTimer?.();
108
+ this.cancelTimer = undefined;
109
+ if (!this.bound)
110
+ return;
111
+ this.options.updater.off('download-progress', this.onProgress);
112
+ this.options.updater.off('update-downloaded', this.onDownloaded);
113
+ this.options.updater.off('error', this.onError);
114
+ this.bound = false;
115
+ }
116
+ bind() {
117
+ if (this.bound)
118
+ return;
119
+ this.bound = true;
120
+ this.options.updater.on('download-progress', this.onProgress);
121
+ this.options.updater.on('update-downloaded', this.onDownloaded);
122
+ this.options.updater.on('error', this.onError);
123
+ }
124
+ onProgress = (progress) => {
125
+ if (this.downloadedVersion)
126
+ return;
127
+ this.publish({
128
+ phase: 'downloading',
129
+ ...(this.candidate ? { version: this.candidate } : {}),
130
+ percent: clampPercent(progress.percent),
131
+ });
132
+ };
133
+ onDownloaded = (info) => {
134
+ if (!this.isNewer(info.version))
135
+ return;
136
+ this.downloadedVersion = info.version;
137
+ this.candidate = info.version;
138
+ this.publish({ phase: 'ready', version: info.version });
139
+ };
140
+ onError = (error) => {
141
+ const version = this.candidate ?? this.state.version;
142
+ this.publish({
143
+ phase: 'error',
144
+ ...(version ? { version } : {}),
145
+ message: error instanceof Error ? error.message : String(error),
146
+ });
147
+ };
148
+ async doCheck() {
149
+ this.downloadedVersion = undefined;
150
+ this.candidate = undefined;
151
+ this.publish({ phase: 'checking' });
152
+ try {
153
+ const result = await this.options.updater.checkForUpdates();
154
+ if (this.disposed)
155
+ return this.state;
156
+ if (this.downloadedVersion && this.isNewer(this.downloadedVersion)) {
157
+ if (this.state.phase === 'ready' && this.state.version === this.downloadedVersion)
158
+ return this.state;
159
+ return this.publish({ phase: 'ready', version: this.downloadedVersion });
160
+ }
161
+ if (!result)
162
+ return this.publish({ phase: 'error', message: 'app update: no check result' });
163
+ const version = result.updateInfo.version;
164
+ if (!result.isUpdateAvailable)
165
+ return this.publish({ phase: 'idle' });
166
+ if (valid(version) === null) {
167
+ return this.publish({ phase: 'error', message: 'app update: feed version is invalid' });
168
+ }
169
+ if (!this.isNewer(version))
170
+ return this.publish({ phase: 'idle' });
171
+ this.candidate = version;
172
+ if (this.state.phase === 'downloading') {
173
+ return this.publish({ phase: 'downloading', version, percent: this.state.percent ?? 0 });
174
+ }
175
+ return this.publish({ phase: 'downloading', version, percent: 0 });
176
+ }
177
+ catch (error) {
178
+ if (this.disposed)
179
+ return this.state;
180
+ return this.publish({
181
+ phase: 'error',
182
+ ...(this.candidate ? { version: this.candidate } : {}),
183
+ message: error instanceof Error ? error.message : String(error),
184
+ });
185
+ }
186
+ }
187
+ isNewer(version) {
188
+ return valid(version) !== null && valid(this.options.currentVersion) !== null && gt(version, this.options.currentVersion);
189
+ }
190
+ publish(state) {
191
+ if (this.disposed)
192
+ return this.current;
193
+ this.current = state;
194
+ if (this.statusFile)
195
+ appendFileSync(this.statusFile, `${JSON.stringify(state)}\n`);
196
+ this.options.onState?.(state);
197
+ return state;
198
+ }
199
+ }
@@ -20,6 +20,12 @@ export interface StoredAttachment {
20
20
  error?: string;
21
21
  }
22
22
  export declare function isValidChatAttachmentsKey(chatId: string): boolean;
23
+ /**
24
+ * 当前会话的附件目录路径,**不创建目录**(供拼系统提示等只读场景使用)。
25
+ * chatId 非法时仍然返回路径但由调用方在写入前用
26
+ * {@link isValidChatAttachmentsKey} 校验。
27
+ */
28
+ export declare function chatAttachmentsDirPath(chatId: string): string;
23
29
  /**
24
30
  * 当前会话的附件目录(会确保目录存在)。chatId 非法时仍然返回路径但由
25
31
  * 调用方在写入前用 {@link isValidChatAttachmentsKey} 校验。
@@ -31,12 +31,20 @@ const SAFE_CHAT_ID = /^[0-9a-zA-Z_-]{1,64}$/;
31
31
  export function isValidChatAttachmentsKey(chatId) {
32
32
  return SAFE_CHAT_ID.test(chatId);
33
33
  }
34
+ /**
35
+ * 当前会话的附件目录路径,**不创建目录**(供拼系统提示等只读场景使用)。
36
+ * chatId 非法时仍然返回路径但由调用方在写入前用
37
+ * {@link isValidChatAttachmentsKey} 校验。
38
+ */
39
+ export function chatAttachmentsDirPath(chatId) {
40
+ return path.join(getUserDataDir(), ATTACHMENTS_DIR_NAME, chatId);
41
+ }
34
42
  /**
35
43
  * 当前会话的附件目录(会确保目录存在)。chatId 非法时仍然返回路径但由
36
44
  * 调用方在写入前用 {@link isValidChatAttachmentsKey} 校验。
37
45
  */
38
46
  export function getChatAttachmentsDir(chatId) {
39
- const dir = path.join(getUserDataDir(), ATTACHMENTS_DIR_NAME, chatId);
47
+ const dir = chatAttachmentsDirPath(chatId);
40
48
  mkdirSync(dir, { recursive: true });
41
49
  return dir;
42
50
  }
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * 回合结束后生成「下一轮用户输入」建议。
3
3
  *
4
- * 只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
4
+ * 主路径只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
5
5
  * `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
6
- * 不做规则抽取、不按 PPT/计划/代码套模板。永不抛错。
6
+ * 永不抛错;没有真实下一步时不展示建议。
7
+ *
8
+ * 项目分支额外保留两类保障:
9
+ * - 正文里显式承诺的下一步指令(如「请回复:确认内容」)始终排在最前,
10
+ * 不依赖模型是否稳定产出;
11
+ * - 长回复按头 + 尾截断,避免只看头部时丢掉结尾处的邀请。
7
12
  *
8
13
  * 调用方应该 fire-and-forget,且只广播一次最终结果。
9
14
  * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
10
15
  */
11
16
  export declare const SUGGESTED_REPLY_MAX = 8;
17
+ /** 项目分支历史上建议芯片固定最多 3 条,生成入口继续沿用该上限。 */
18
+ export declare const SUGGESTED_REPLY_COUNT = 3;
12
19
  /**
13
20
  * 建议芯片的判断来源:有 `[next_steps]` 用最后一段标签正文,否则用回复最后一段。
14
21
  */
@@ -20,9 +27,23 @@ export declare function cleanSuggestedReply(raw: string): string;
20
27
  * 最多 {@link SUGGESTED_REPLY_MAX} 条。
21
28
  */
22
29
  export declare function parseSuggestedReplies(raw: string): string[];
30
+ /**
31
+ * 助手正文里显式写出的下一步指令(「请回复:确认内容」/「回复「确认内容」」)。
32
+ *
33
+ * 这是角色提示词对下一轮用户输入的**明确承诺**,对应的按钮必须原样出现:
34
+ * 不能交给追问建议模型自由改写(长回复被截断、模型跑偏都会让按钮消失)。
35
+ * 只认「请回复:X」「回复「X」」这两种窄格式,X 为 2-12 字的短标签,
36
+ * 避免把正文里的普通引用误当成按钮。
37
+ */
38
+ export declare function explicitReplyHints(assistantText: string): string[];
39
+ /**
40
+ * 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
41
+ * 该函数作为项目分支的兼容能力保留;新的生成主路径不再依赖它兜底。
42
+ */
43
+ export declare function fallbackSuggestedReplies(userText: string, assistantText: string): string[];
23
44
  export interface GenerateSuggestedRepliesResult {
24
45
  suggestions: string[];
25
- /** true = LLM 失败、超时或输出不合格;suggestions 为空。空数组本身算有效判断。 */
46
+ /** true = LLM 失败、超时或输出不合格;suggestions 为空或仅剩显式回复提示。 */
26
47
  usedFallback: boolean;
27
48
  }
28
49
  export interface GenerateSuggestedRepliesOptions {
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * 回合结束后生成「下一轮用户输入」建议。
3
3
  *
4
- * 只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
4
+ * 主路径只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
5
5
  * `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
6
- * 不做规则抽取、不按 PPT/计划/代码套模板。永不抛错。
6
+ * 永不抛错;没有真实下一步时不展示建议。
7
+ *
8
+ * 项目分支额外保留两类保障:
9
+ * - 正文里显式承诺的下一步指令(如「请回复:确认内容」)始终排在最前,
10
+ * 不依赖模型是否稳定产出;
11
+ * - 长回复按头 + 尾截断,避免只看头部时丢掉结尾处的邀请。
7
12
  *
8
13
  * 调用方应该 fire-and-forget,且只广播一次最终结果。
9
14
  * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
10
15
  */
11
16
  import { llmService } from '../llm/index.js';
12
17
  export const SUGGESTED_REPLY_MAX = 8;
18
+ /** 项目分支历史上建议芯片固定最多 3 条,生成入口继续沿用该上限。 */
19
+ export const SUGGESTED_REPLY_COUNT = 3;
13
20
  const MAX_SUGGESTION_CHARS = 48;
14
21
  const USER_TEXT_LIMIT = 500;
15
22
  const SOURCE_LIMIT = 2000;
@@ -26,7 +33,8 @@ const SYSTEM_PROMPT = `你是对话追问建议助手。根据「下一步来源
26
33
  3. 不要编造,不要用「继续完善这份结果」「告诉我下一步怎么做」这类套话凑数。
27
34
  4. 有几条真实下一步就给几条,最少 0 条,最多 8 条。
28
35
  5. 每条 6-40 个字。不要编号、不要引号、不要解释、不要附带命令行或绝对路径。
29
- 6. 只输出 JSON 字符串数组,例如 ["把封面改成深蓝商务风","第2页个人简介写具体"]`;
36
+ 6. 只输出 JSON 字符串数组,例如 ["把封面改成深蓝商务风","第2页个人简介写具体"]
37
+ 7. 如果下一步来源显示任务尚未完成、校验/验证未通过、或助手在问「是否继续」,第一条建议必须是继续完成任务的短指令。`;
30
38
  const LIST_ITEM_RE = /^(?:\d+[.)、]|[-*•])\s+(\S.*)$/;
31
39
  function withTimeout(promise, timeoutMs, label) {
32
40
  if (timeoutMs <= 0)
@@ -50,6 +58,18 @@ function clip(text, limit) {
50
58
  return chars.join('');
51
59
  return `${chars.slice(0, limit).join('')}…`;
52
60
  }
61
+ /**
62
+ * 长文本截断(头 + 尾)。助手回复的**结尾**才是结论与下一步邀请所在
63
+ * (例如「以上初稿已生成完毕。如需继续,请回复:确认内容」);只取头部会让
64
+ * 追问建议模型看不到邀请。头部保留话题上下文,尾部保留收尾邀请,两头各取一半。
65
+ */
66
+ function clipHeadTail(text, limit) {
67
+ const chars = Array.from((text ?? '').trim());
68
+ if (chars.length <= limit)
69
+ return chars.join('');
70
+ const head = Math.floor(limit / 2);
71
+ return `${chars.slice(0, head).join('')}…${chars.slice(-(limit - head)).join('')}`;
72
+ }
53
73
  function lastParagraph(text) {
54
74
  const trimmed = (text ?? '').trim();
55
75
  if (!trimmed)
@@ -140,15 +160,92 @@ export function parseSuggestedReplies(raw) {
140
160
  const listLines = lines.filter((line) => isListItem(line));
141
161
  return uniqueSuggestions(listLines.length > 0 ? listLines : lines);
142
162
  }
163
+ function uniqueThree(candidates, extras = []) {
164
+ const out = [];
165
+ const seen = new Set();
166
+ for (const raw of [...candidates, ...extras]) {
167
+ const cleaned = cleanSuggestedReply(raw);
168
+ if (!cleaned || seen.has(cleaned))
169
+ continue;
170
+ seen.add(cleaned);
171
+ out.push(cleaned);
172
+ if (out.length === SUGGESTED_REPLY_COUNT)
173
+ break;
174
+ }
175
+ return out;
176
+ }
177
+ /**
178
+ * 助手正文里显式写出的下一步指令(「请回复:确认内容」/「回复「确认内容」」)。
179
+ *
180
+ * 这是角色提示词对下一轮用户输入的**明确承诺**,对应的按钮必须原样出现:
181
+ * 不能交给追问建议模型自由改写(长回复被截断、模型跑偏都会让按钮消失)。
182
+ * 只认「请回复:X」「回复「X」」这两种窄格式,X 为 2-12 字的短标签,
183
+ * 避免把正文里的普通引用误当成按钮。
184
+ */
185
+ export function explicitReplyHints(assistantText) {
186
+ const out = [];
187
+ const text = assistantText ?? '';
188
+ const push = (raw) => {
189
+ const cleaned = cleanSuggestedReply(raw);
190
+ if (cleaned && Array.from(cleaned).length <= 12 && !out.includes(cleaned)) {
191
+ out.push(cleaned);
192
+ }
193
+ };
194
+ for (const match of text.matchAll(/请回复[::][ \t]*([^\n,。;、,.;!?!?]{2,12})/g)) {
195
+ push(match[1]);
196
+ }
197
+ for (const match of text.matchAll(/回复[「『“"]([^」』”"\n]{2,12})[」』”"]/g)) {
198
+ push(match[1]);
199
+ }
200
+ return out.slice(0, SUGGESTED_REPLY_COUNT);
201
+ }
202
+ const PPT_FALLBACK = ['调整封面标题和配色', '把某一页内容写得更具体', '再加一页项目案例'];
203
+ const PLAN_FALLBACK = ['按这个计划开始执行', '先改第 2 步再执行', '把计划写得更细一点'];
204
+ const CODE_FALLBACK = ['解释这段实现的思路', '帮我补上测试', '再优化一下可读性'];
205
+ const GENERIC_FALLBACK = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
206
+ const PPT_OFFER_FALLBACK = ['调整幻灯片的内容和文案', '调整配色和版式', '再加一页补充材料'];
207
+ const PPT_INCOMPLETE_FALLBACK = [
208
+ '继续修复并生成 PPT 预览稿',
209
+ '按当前方案继续生成 PPT 预览',
210
+ '先修正配置再继续生成预览稿',
211
+ ];
212
+ /**
213
+ * 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
214
+ * 该函数作为项目分支的兼容能力保留;新的生成主路径不再依赖它兜底。
215
+ */
216
+ export function fallbackSuggestedReplies(userText, assistantText) {
217
+ const blob = `${userText}\n${assistantText}`;
218
+ if (/\.pptx\b|幻灯片|演示文稿|\bppt\b/i.test(blob)) { // shell-neutral:allow — Office 扩展名 'ppt'/'.pptx',不是产品品牌
219
+ // PPT 任务未完成:校验未通过、预览稿未生成、助手在问是否继续时,
220
+ // 第一条建议必须是可以直接点发、继续把 PPT 预览稿生成出来的指令。
221
+ if (/svg_output|SVG\s*预览|预览.{0,12}(?:未|没有|为空|尚未)|验证.{0,12}(?:未通过|失败|错误)|校验.{0,12}(?:未通过|失败|错误)|是否继续|请确认是否继续/.test(assistantText)) {
222
+ return uniqueThree(PPT_INCOMPLETE_FALLBACK, PPT_FALLBACK);
223
+ }
224
+ if (/修改内容|调整样式/.test(assistantText)) {
225
+ return uniqueThree(PPT_OFFER_FALLBACK, PPT_FALLBACK);
226
+ }
227
+ return uniqueThree(PPT_FALLBACK, GENERIC_FALLBACK);
228
+ }
229
+ if (/标准工作流程|待办清单|\bTODO\b|先制定计划/.test(blob) || /^\s*计划已/.test(assistantText)) {
230
+ return uniqueThree(PLAN_FALLBACK, GENERIC_FALLBACK);
231
+ }
232
+ if (/```|单元测试|函数实现|补测试/.test(blob)) {
233
+ return uniqueThree(CODE_FALLBACK, GENERIC_FALLBACK);
234
+ }
235
+ return uniqueThree(GENERIC_FALLBACK);
236
+ }
143
237
  /**
144
238
  * 生成本轮追问建议。永不抛错;没有下一步来源或模型判定没有下一步时返回空数组。
145
239
  */
146
240
  export async function generateSuggestedReplies(userText, assistantText, opts = {}) {
241
+ // 正文里显式承诺的下一步指令(如「确认内容」)始终排在最前,优先级高于
242
+ // LLM 自由生成的建议——按钮不能时有时无。
243
+ const hints = explicitReplyHints(assistantText);
147
244
  const source = extractNextStepsSource(assistantText);
148
245
  const user = clip(userText, USER_TEXT_LIMIT);
149
- const sourceClip = clip(source, SOURCE_LIMIT);
246
+ const sourceClip = clipHeadTail(source, SOURCE_LIMIT);
150
247
  if (!sourceClip) {
151
- return { suggestions: [], usedFallback: false };
248
+ return { suggestions: uniqueThree(hints), usedFallback: false };
152
249
  }
153
250
  const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 12_000;
154
251
  try {
@@ -164,8 +261,12 @@ export async function generateSuggestedReplies(userText, assistantText, opts = {
164
261
  }), perAttemptTimeoutMs, 'ai-suggestions');
165
262
  const raw = result.content ?? '';
166
263
  const parsed = parseSuggestedReplies(raw);
167
- if (parsed.length > 0) {
168
- return { suggestions: parsed, usedFallback: false };
264
+ // 显式承诺的那条已经在 hints 里了;模型经常再产出一条「确认内容,按这版继续」
265
+ // 这类包含同一指令的变体,去掉,给真正的修改建议腾位置。
266
+ const withoutHintEchoes = parsed.filter((item) => !hints.some((hint) => item.includes(hint)));
267
+ const suggestions = uniqueThree([...hints, ...withoutHintEchoes]);
268
+ if (suggestions.length > 0) {
269
+ return { suggestions, usedFallback: false };
169
270
  }
170
271
  if (Array.isArray(sliceJsonArray(raw))) {
171
272
  return { suggestions: [], usedFallback: false };
@@ -174,6 +275,6 @@ export async function generateSuggestedReplies(userText, assistantText, opts = {
174
275
  }
175
276
  catch (err) {
176
277
  console.warn('[ai-suggestions] LLM 追问建议失败,不展示建议', err);
177
- return { suggestions: [], usedFallback: true };
278
+ return { suggestions: uniqueThree(hints), usedFallback: true };
178
279
  }
179
280
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A4: drive one chat turn through the sidecar-hosted Python CoreLoop.
2
+ * A4: drive one chat turn through the sidecar-hosted Rust CoreLoop.
3
3
  *
4
4
  * This is the default chat path (since 2026-08-26): the sidecar owns
5
5
  * think→act→observe, and tool calls come back over the reverse channel
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A4: drive one chat turn through the sidecar-hosted Python CoreLoop.
2
+ * A4: drive one chat turn through the sidecar-hosted Rust CoreLoop.
3
3
  *
4
4
  * This is the default chat path (since 2026-08-26): the sidecar owns
5
5
  * think→act→observe, and tool calls come back over the reverse channel
@@ -39,6 +39,7 @@ import { resolveBranchActivation } from './branch-helper.js';
39
39
  import { detectInterruptedTurn } from './interrupted-helper.js';
40
40
  import { dropCurrentUserMessage } from './history-helper.js';
41
41
  import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
42
+ import { chatAttachmentsDirPath } from '../attachments.js';
42
43
  import { loadProjectRuleFiles } from '../project-rules.js';
43
44
  import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
44
45
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
@@ -2435,7 +2436,11 @@ export class LocalBackendRouter {
2435
2436
  ? `另有源文件夹(只读):${chatProject.sourceFolders.join('、')}。`
2436
2437
  : '') +
2437
2438
  `请一律使用项目目录内的路径(相对路径按家目录解析)。` +
2438
- `如确需访问项目外的文件,向用户说明该限制,或请其在项目里附加为源文件夹。`;
2439
+ `如确需读取项目外的文件,可请用户在项目里附加为源文件夹。\n` +
2440
+ `例外:本会话的用户上传附件目录 ${chatAttachmentsDirPath(chatId)} 也在项目家目录外,` +
2441
+ `但它已作为**只读放行根**开放给 local_read_file(用绝对路径可直接读取,不会被项目围栏拒绝)。` +
2442
+ `用户附到本条消息的文件都放在那里;需要转换(docx / pdf / xlsx / pptx 等)时,` +
2443
+ `把该绝对路径交给 local_exec_shell 的转换脚本处理即可,**不要**因为它在项目家目录外就拒绝读取或要求用户重新拷贝。`;
2439
2444
  // W6-5 + W6-7a:项目级规则文件(AGENTS.md / CLAUDE.md)是不可信输入,
2440
2445
  // 仅在用户显式信任该项目后才注入模型上下文——未信任一律不读取、不注入
2441
2446
  // (打开恶意仓库时,一段构造的规则文件不能劫持 agent)。信任状态持久化、
package/dist/main.js CHANGED
@@ -11,6 +11,8 @@ import log from 'electron-log';
11
11
  import { getBrand } from './brand.js';
12
12
  import { getPreloadPath } from './runtime.js';
13
13
  import { getProductConfig } from './product-config.js';
14
+ import { AppUpdateController, appUpdateMenuAction, shouldNotifyUpdateReady, } from './app-update.js';
15
+ import { createElectronAppUpdater } from './app-update-electron.js';
14
16
  import { assertHostIpcAllowed, getResolvedHostTools } from './host-tools-runtime.js';
15
17
  import { createJsonStore } from './json-store.js';
16
18
  import { getUserDataDir } from './runtime.js';
@@ -206,7 +208,7 @@ function applyStrictCsp() {
206
208
  "img-src 'self' data: blob:",
207
209
  "style-src 'self' 'unsafe-inline'",
208
210
  "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
209
- "font-src 'self' data:",
211
+ "font-src 'self' data: blob:",
210
212
  ].join('; ');
211
213
  callback({
212
214
  responseHeaders: {
@@ -471,11 +473,25 @@ function createMenu() {
471
473
  },
472
474
  ]
473
475
  : []),
474
- // 帮助菜单链接是产品注入配置(3.1,product.json links);中性 shell
475
- // 未注入时不渲染对应菜单项;两项都缺则整个「帮助」菜单不渲染。
476
+ // 帮助菜单:更新动作来自自动更新状态;链接是产品注入配置
477
+ // (3.1,product.json links)。都没有则不渲染「帮助」。
476
478
  ...(() => {
477
479
  const links = getProductConfig().links ?? {};
478
480
  const submenu = [];
481
+ const updateAction = appUpdates ? appUpdateMenuAction(appUpdates.state) : 'none';
482
+ if (updateAction !== 'none') {
483
+ submenu.push({
484
+ label: updateAction === 'install' ? '重启并安装更新' : '检查更新',
485
+ click: () => {
486
+ if (!appUpdates)
487
+ return;
488
+ if (appUpdateMenuAction(appUpdates.state) === 'install')
489
+ appUpdates.installNow();
490
+ else
491
+ void appUpdates.checkNow();
492
+ },
493
+ });
494
+ }
479
495
  if (links.releasePage) {
480
496
  submenu.push({
481
497
  label: '打开发布页',
@@ -817,7 +833,37 @@ function setupIpcHandlers() {
817
833
  }
818
834
  });
819
835
  }
836
+ let appUpdates = null;
837
+ function startDesktopUpdates() {
838
+ let previous = 'disabled';
839
+ appUpdates = new AppUpdateController({
840
+ feedUrl: getProductConfig().updates?.feedUrl,
841
+ packaged: app.isPackaged,
842
+ currentVersion: app.getVersion(),
843
+ env: process.env,
844
+ updater: createElectronAppUpdater(),
845
+ onState: state => {
846
+ log.info('[app-update]', state.phase, state.version ?? '', state.message ?? '');
847
+ if (shouldNotifyUpdateReady(previous, state) && state.version) {
848
+ const notification = new ElectronNotification({
849
+ title: getBrand().displayName,
850
+ body: `新版本 ${state.version} 已下载,点击重启并安装`,
851
+ });
852
+ notification.on('click', () => {
853
+ appUpdates?.installNow();
854
+ });
855
+ notification.show();
856
+ }
857
+ const actionChanged = appUpdateMenuAction({ phase: previous }) !== appUpdateMenuAction(state);
858
+ previous = state.phase;
859
+ if (actionChanged)
860
+ createMenu();
861
+ },
862
+ });
863
+ void appUpdates.start();
864
+ }
820
865
  app.whenReady().then(async () => {
866
+ startDesktopUpdates();
821
867
  applyStrictCsp();
822
868
  runtime = await createHostRuntime({
823
869
  broadcast: (channel, payload) => broadcastTerminalEvent(channel, payload),
@@ -15,6 +15,13 @@ export interface ProductLinks {
15
15
  /** 帮助菜单「访问官网」URL。 */
16
16
  website?: string;
17
17
  }
18
+ export interface ProductUpdates {
19
+ /**
20
+ * electron-updater generic 通道根地址(目录,不含 latest*.yml)。
21
+ * 打包配置里的 publishUrl 由产品组装根注入到这里。空则不检查更新。
22
+ */
23
+ feedUrl?: string;
24
+ }
18
25
  export interface ProductConfig {
19
26
  /**
20
27
  * 云端遥测端点(insights flush 的 API base)。空 = 不上报(中性默认);
@@ -23,6 +30,11 @@ export interface ProductConfig {
23
30
  insightsApiBase?: string;
24
31
  /** 帮助菜单链接;缺省的项不渲染。 */
25
32
  links?: ProductLinks;
33
+ /**
34
+ * 桌面自动更新。缺省不检查。只在已打包的应用里生效,除非
35
+ * `DEEPPATH_APP_UPDATE=1`。`DEEPPATH_UPDATE_FEED_URL` 可覆盖 feedUrl。
36
+ */
37
+ updates?: ProductUpdates;
26
38
  /**
27
39
  * BS 模式的 userData 目录名(~/ 下)。CS 模式由 Electron 按
28
40
  * productName 分目录,不经此字段。
@@ -43,7 +43,23 @@ const STREAM_PATH_PATTERNS = [
43
43
  /^\/api\/v2\/chats\/[^/]+\/(send|run|agent)$/,
44
44
  /^\/api\/v2\/chats\/[^/]+\/messages\/[^/]+\/regenerate$/,
45
45
  ];
46
- const MAX_BODY_BYTES = 25 * 1024 * 1024;
46
+ /**
47
+ * 请求体上限。会话附件(`/host/attachments/save`)在浏览器模式走 base64,
48
+ * 相对原始字节膨胀约 4/3,所以这里必须比 `ATTACHMENT_MAX_BYTES`(25MB,
49
+ * 按**解码后**字节校验)留出足够 headroom,否则一个合法的 20MB 文件也会
50
+ * 在进到 saveAttachmentFiles 之前就被 body 上限挡掉。批量多个文件共享同一
51
+ * 上限(超出返回 413,前端据此提示用户分开上传)。
52
+ */
53
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
54
+ /** body 超限专用错误:外层据此回 413 + 可读原因,而不是笼统的 500。 */
55
+ class BodyTooLargeError extends Error {
56
+ limit;
57
+ constructor(limit) {
58
+ super(`request body too large (max ${Math.round(limit / 1024 / 1024)}MB)`);
59
+ this.limit = limit;
60
+ this.name = 'BodyTooLargeError';
61
+ }
62
+ }
47
63
  const MIME = {
48
64
  '.html': 'text/html; charset=utf-8',
49
65
  '.js': 'text/javascript; charset=utf-8',
@@ -79,7 +95,7 @@ async function readJsonBody(req) {
79
95
  for await (const chunk of req) {
80
96
  size += chunk.length;
81
97
  if (size > MAX_BODY_BYTES)
82
- throw new Error('request body too large');
98
+ throw new BodyTooLargeError(MAX_BODY_BYTES);
83
99
  chunks.push(chunk);
84
100
  }
85
101
  if (chunks.length === 0)
@@ -432,6 +448,16 @@ export function createBsServer(deps) {
432
448
  }
433
449
  sendJson(res, 404, { detail: 'not found' });
434
450
  })().catch((err) => {
451
+ // body 超限是可预期的客户端问题(附件太大/一次传太多),回 413 +
452
+ // 可读原因,让前端把它显示给用户,而不是笼统的 500 "internal error"。
453
+ if (err instanceof BodyTooLargeError) {
454
+ log.warn('[bs] request body too large', { url: req.url, limit: err.limit });
455
+ if (!res.headersSent)
456
+ sendJson(res, 413, { detail: err.message });
457
+ else
458
+ res.end();
459
+ return;
460
+ }
435
461
  log.error('[bs] unhandled request error', err);
436
462
  if (!res.headersSent)
437
463
  sendJson(res, 500, { detail: 'internal error' });
@@ -7,15 +7,16 @@
7
7
  * 由宿主的退出钩子调用(Electron before-quit / BS SIGTERM)。
8
8
  */
9
9
  import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
10
11
  import log from 'electron-log';
11
- import { SidecarSupervisor, resolveSidecarPython } from './supervisor.js';
12
+ import { SidecarSupervisor } from './supervisor.js';
12
13
  import { setSidecarSupervisor, setSidecarSupervisorPending, llmService } from '../llm/index.js';
13
14
  import { resolveSidecarStoragePath } from './storage-path.js';
14
15
  import { deriveEgressAllowListFromBaseUrl, probeExecSandboxCapability, } from './exec-sandbox.js';
15
16
  import { createToolInvokeHandler } from './reverse-tools.js';
16
17
  import { createProcessSpawnHandler } from './reverse-spawn.js';
17
18
  import { collectAmbientProxyEndpoints } from './proxy-detect.js';
18
- import { buildEgressProxyPlan, decideEgressProxy, deriveWebEgressHosts, pickFreePort, recordEgressPosture, startEgressProxy, } from './egress-proxy.js';
19
+ import { buildEgressProxyPlan, decideEgressProxy, ensureEgressProxyExecutable, deriveWebEgressHosts, pickFreePort, recordEgressPosture, startEgressProxy, } from './egress-proxy.js';
19
20
  import { WEB_TOOL_RPC_TIMEOUT_MS, webToolsEnabled } from '../tool-router.js';
20
21
  import { sidecarWebSearchEnv } from '../storage/web-search-settings.js';
21
22
  import { executeHostedWebSearch } from '../hosted-web-search.js';
@@ -48,7 +49,7 @@ async function deriveSidecarEgressAllowList() {
48
49
  }
49
50
  /**
50
51
  * W1.3.3(3.1a 起默认开):把 sidecar 出网收敛到本机
51
- * steerable_egress_proxy。显式 `STEERABLE_EGRESS_PROXY=0` 退出。
52
+ * steerable-egress-proxy 二进制。显式 `STEERABLE_EGRESS_PROXY=0` 退出。
52
53
  *
53
54
  * 两类自动回退到旧的 Seatbelt 端口级派生路径(加固永远不能弄断 LLM
54
55
  * 通路):
@@ -78,8 +79,17 @@ async function startEgressProxyIfEnabled(store) {
78
79
  storedProvider: searchSettings?.provider,
79
80
  llmBaseUrl: settings.baseUrl,
80
81
  });
82
+ const executable = await ensureEgressProxyExecutable(path.dirname(fileURLToPath(import.meta.url)));
83
+ if (!executable) {
84
+ log.warn('[egress-proxy] steerable-egress-proxy binary was not found; staying on port-level enforcement');
85
+ recordEgressPosture({
86
+ mode: 'port-only-fallback',
87
+ reason: '未找到 steerable-egress-proxy 二进制,按主机管控未启用',
88
+ });
89
+ return null;
90
+ }
81
91
  const plan = buildEgressProxyPlan({
82
- pythonExecutable: resolveSidecarPython(),
92
+ executable,
83
93
  port: await pickFreePort(),
84
94
  providerBaseUrl: settings.baseUrl,
85
95
  providerApiKey: settings.apiKey || undefined,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * W1.3.3:桌面宿主内嵌 `steerable_egress_proxy` 的启动器。
2
+ * 桌面宿主启动闭源 `steerable-egress-proxy` 二进制的启动器。
3
3
  *
4
4
  * 默认态势(3.1a,2026-09-08 起默认开):sidecar 出网收敛到本机代理,
5
5
  * Seatbelt 列表只放行代理端口,真正的主机名单由代理进程持有(代理跑在
@@ -71,8 +71,30 @@ export declare function egressAllowEntry(baseUrl: string | undefined): {
71
71
  * null(调用方回退到旧的 Seatbelt 派生路径——空名单的代理没有意义,
72
72
  * 框架侧也会 fail loud)。
73
73
  */
74
+ export declare function resolveEgressProxyExecutable(): string | null;
75
+ /** Walk up from a compiled file until the repo that owns the fetch script. */
76
+ export declare function findFrameworkRoot(startDir: string): string | null;
77
+ /**
78
+ * 下载缓存目录。egress 代理跑在沙箱外,所以这里绝不能落在 sidecar 沙箱
79
+ * 可写的根(`~/.steerable`、storage path、scratch)之下——否则沙箱内
80
+ * 代码可以替换这个二进制,下次启动即在沙箱外执行。
81
+ */
82
+ export declare function egressBinaryCacheDir(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, home?: string): string;
83
+ /**
84
+ * 缓存命中且与同目录 `.sha256` 一致时返回路径。每次启动都重新计算哈希,
85
+ * 被截断或替换的文件不会被执行。
86
+ */
87
+ export declare function verifiedCachedEgress(cacheDir: string, version: string, target: string): string | null;
88
+ /** Download the lockstep egress binary. Returns the printed path, or null. */
89
+ export declare function downloadPublishedEgress(repoRoot: string, cacheDir: string): Promise<string | null>;
90
+ /**
91
+ * Env override, then a locally built private binary, then a cached copy
92
+ * whose SHA-256 still matches, then the verified public Release asset.
93
+ * A missing binary stays null so boot can fall back to port-level rules.
94
+ */
95
+ export declare function ensureEgressProxyExecutable(startDir: string, download?: (repoRoot: string, cacheDir: string) => Promise<string | null>, cacheDir?: string): Promise<string | null>;
74
96
  export declare function buildEgressProxyPlan(options: {
75
- pythonExecutable: string;
97
+ executable: string;
76
98
  port: number;
77
99
  providerBaseUrl?: string;
78
100
  /** 存在且 provider 为 https 时启用凭证代理模式(W2.2.2)。 */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * W1.3.3:桌面宿主内嵌 `steerable_egress_proxy` 的启动器。
2
+ * 桌面宿主启动闭源 `steerable-egress-proxy` 二进制的启动器。
3
3
  *
4
4
  * 默认态势(3.1a,2026-09-08 起默认开):sidecar 出网收敛到本机代理,
5
5
  * Seatbelt 列表只放行代理端口,真正的主机名单由代理进程持有(代理跑在
@@ -18,9 +18,13 @@
18
18
  * - 代理直连目标主机,无上游代理链。
19
19
  */
20
20
  import { spawn } from 'node:child_process';
21
- import { randomBytes } from 'node:crypto';
21
+ import { createHash, randomBytes } from 'node:crypto';
22
+ import { existsSync, readFileSync } from 'node:fs';
22
23
  import { connect, createServer } from 'node:net';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
23
26
  import { defaultSearchBaseUrl } from '../storage/web-search-settings.js';
27
+ import { resolveSidecarPython } from './supervisor.js';
24
28
  /**
25
29
  * `baseUrl` 对应的允许条目形态:裸主机(框架侧隐含 443/80)或 `host:port`。
26
30
  *
@@ -53,8 +57,136 @@ export function egressAllowEntry(baseUrl) {
53
57
  * null(调用方回退到旧的 Seatbelt 派生路径——空名单的代理没有意义,
54
58
  * 框架侧也会 fail loud)。
55
59
  */
60
+ export function resolveEgressProxyExecutable() {
61
+ const fromEnv = process.env.STEERABLE_EGRESS_PROXY_BIN?.trim();
62
+ return fromEnv && existsSync(fromEnv) ? fromEnv : null;
63
+ }
64
+ /** Walk up from a compiled file until the repo that owns the fetch script. */
65
+ export function findFrameworkRoot(startDir) {
66
+ let dir = path.resolve(startDir);
67
+ for (let i = 0; i < 8; i += 1) {
68
+ if (existsSync(path.join(dir, 'scripts', 'fetch_verified_artifacts.py')))
69
+ return dir;
70
+ const parent = path.dirname(dir);
71
+ if (parent === dir)
72
+ return null;
73
+ dir = parent;
74
+ }
75
+ return null;
76
+ }
77
+ function localBuiltEgress(repoRoot) {
78
+ const exe = process.platform === 'win32' ? 'steerable-egress-proxy.exe' : 'steerable-egress-proxy';
79
+ for (const kind of ['release', 'debug']) {
80
+ const candidate = path.join(repoRoot, 'packages', 'egress-proxy', 'rs', 'target', kind, exe);
81
+ if (existsSync(candidate))
82
+ return candidate;
83
+ }
84
+ return null;
85
+ }
86
+ /**
87
+ * 下载缓存目录。egress 代理跑在沙箱外,所以这里绝不能落在 sidecar 沙箱
88
+ * 可写的根(`~/.steerable`、storage path、scratch)之下——否则沙箱内
89
+ * 代码可以替换这个二进制,下次启动即在沙箱外执行。
90
+ */
91
+ export function egressBinaryCacheDir(platform = process.platform, env = process.env, home = os.homedir()) {
92
+ if (platform === 'darwin')
93
+ return path.join(home, 'Library', 'Caches', 'steerable', 'egress-proxy');
94
+ if (platform === 'win32') {
95
+ return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'steerable', 'egress-proxy');
96
+ }
97
+ return path.join(env.XDG_CACHE_HOME || path.join(home, '.cache'), 'steerable', 'egress-proxy');
98
+ }
99
+ function hostEgressTarget() {
100
+ if (process.platform === 'darwin')
101
+ return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
102
+ if (process.platform === 'win32')
103
+ return process.arch === 'x64' ? 'win32-x64' : null;
104
+ if (process.platform === 'linux')
105
+ return process.arch === 'x64' ? 'linux-x64' : null;
106
+ return null;
107
+ }
108
+ function lockstepVersion(repoRoot) {
109
+ try {
110
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
111
+ return typeof pkg.version === 'string' ? pkg.version : null;
112
+ }
113
+ catch {
114
+ // 读不到根 package.json 就无法定位缓存文件,交给下载步骤处理。
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * 缓存命中且与同目录 `.sha256` 一致时返回路径。每次启动都重新计算哈希,
120
+ * 被截断或替换的文件不会被执行。
121
+ */
122
+ export function verifiedCachedEgress(cacheDir, version, target) {
123
+ const name = `steerable-egress-proxy-bin-${version}-${target}${target === 'win32-x64' ? '.exe' : ''}`;
124
+ const binary = path.join(cacheDir, name);
125
+ const digestFile = `${binary}.sha256`;
126
+ if (!existsSync(binary) || !existsSync(digestFile))
127
+ return null;
128
+ const expected = readFileSync(digestFile, 'utf8').trim().split(/\s+/)[0]?.toLowerCase();
129
+ const actual = createHash('sha256').update(readFileSync(binary)).digest('hex');
130
+ return expected && expected === actual ? binary : null;
131
+ }
132
+ const EGRESS_DOWNLOAD_TIMEOUT_MS = 60_000;
133
+ /** Download the lockstep egress binary. Returns the printed path, or null. */
134
+ export function downloadPublishedEgress(repoRoot, cacheDir) {
135
+ const script = path.join(repoRoot, 'scripts', 'fetch_verified_artifacts.py');
136
+ // 与 sidecar 同一解释器:系统 python3 可能不带 SSL,下载不了 https。
137
+ const python = resolveSidecarPython();
138
+ return new Promise((resolve) => {
139
+ const child = spawn(python, [script, 'egress', '--lockstep', '--target', 'host', '--out', cacheDir], {
140
+ cwd: repoRoot,
141
+ stdio: ['ignore', 'pipe', 'pipe'],
142
+ });
143
+ const timer = setTimeout(() => child.kill(), EGRESS_DOWNLOAD_TIMEOUT_MS);
144
+ let stdout = '';
145
+ child.stdout.setEncoding('utf8');
146
+ child.stdout.on('data', (chunk) => {
147
+ stdout += chunk;
148
+ });
149
+ child.on('error', () => {
150
+ clearTimeout(timer);
151
+ resolve(null);
152
+ });
153
+ child.on('close', (code) => {
154
+ clearTimeout(timer);
155
+ const printed = stdout.trim().split('\n').pop()?.trim();
156
+ if (code === 0 && printed && existsSync(printed))
157
+ resolve(printed);
158
+ else
159
+ resolve(null);
160
+ });
161
+ });
162
+ }
163
+ /**
164
+ * Env override, then a locally built private binary, then a cached copy
165
+ * whose SHA-256 still matches, then the verified public Release asset.
166
+ * A missing binary stays null so boot can fall back to port-level rules.
167
+ */
168
+ export async function ensureEgressProxyExecutable(startDir, download = downloadPublishedEgress, cacheDir = egressBinaryCacheDir()) {
169
+ const fromEnv = resolveEgressProxyExecutable();
170
+ if (fromEnv)
171
+ return fromEnv;
172
+ const repoRoot = findFrameworkRoot(startDir);
173
+ if (!repoRoot)
174
+ return null;
175
+ const built = localBuiltEgress(repoRoot);
176
+ if (built)
177
+ return built;
178
+ const version = lockstepVersion(repoRoot);
179
+ const target = hostEgressTarget();
180
+ if (!version || !target)
181
+ return null;
182
+ const cached = verifiedCachedEgress(cacheDir, version, target);
183
+ if (cached)
184
+ return cached;
185
+ const downloaded = await download(repoRoot, cacheDir);
186
+ return downloaded && verifiedCachedEgress(cacheDir, version, target);
187
+ }
56
188
  export function buildEgressProxyPlan(options) {
57
- const { pythonExecutable, port, providerBaseUrl, providerApiKey } = options;
189
+ const { executable, port, providerBaseUrl, providerApiKey } = options;
58
190
  const proxiedHosts = [];
59
191
  const directHosts = [];
60
192
  let brokerHost = null;
@@ -91,10 +223,8 @@ export function buildEgressProxyPlan(options) {
91
223
  tokenValue: randomBytes(24).toString('base64url'),
92
224
  };
93
225
  return {
94
- command: pythonExecutable,
226
+ command: executable,
95
227
  args: [
96
- '-m',
97
- 'steerable_egress_proxy',
98
228
  '--bind',
99
229
  proxyEndpoint,
100
230
  ...proxiedHosts.flatMap((host) => ['--allow', host]),
@@ -292,7 +292,7 @@ export interface SidecarChatStreamRequest {
292
292
  providerOptions?: Record<string, unknown>;
293
293
  /** Per-start RPC timeout (NOT per-chunk). Default 30_000ms. */
294
294
  startTimeoutMs?: number;
295
- /** Route the stream through the sidecar-hosted Python CoreLoop (A4). */
295
+ /** Route the stream through the sidecar-hosted Rust CoreLoop (A4). */
296
296
  useCoreLoop?: boolean;
297
297
  /**
298
298
  * W7-1: continue the durable record's interrupted turn instead of opening
@@ -21,9 +21,10 @@ export interface OpenAICompatOverrides {
21
21
  reasoningEchoField?: string;
22
22
  supportsForcedToolChoice?: boolean;
23
23
  /**
24
- * thinking 模式下带 tool_calls 的 assistant 消息即使没产出思考,也必须
25
- * 回传 reasoning 字段(空串)——DeepSeek 缺键即 400;框架按 baseUrl
26
- * 自动为 api.deepseek.com 打开,这里只是允许设置页显式覆盖。
24
+ * thinking 模式下**所有** assistant 消息即使没产出思考,也必须回传
25
+ * reasoning 字段(空串)——DeepSeek 缺键即 400,不只是带 tool_calls 的
26
+ * 轮次;框架按 baseUrl 自动为 api.deepseek.com 打开,这里只是允许设置页
27
+ * 显式覆盖。
27
28
  */
28
29
  echoEmptyReasoningForToolCalls?: boolean;
29
30
  cachedTokensFields?: string[];
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
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/",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/pathlyapp/steerable-framework.git",
9
+ "url": "git+https://github.com/pathlyapp/opensteerable.git",
10
10
  "directory": "packages/agent-shell/ts"
11
11
  },
12
12
  "bugs": {
13
- "url": "https://github.com/pathlyapp/steerable-framework/issues"
13
+ "url": "https://github.com/pathlyapp/opensteerable/issues"
14
14
  },
15
15
  "type": "module",
16
16
  "main": "dist/runtime.js",
@@ -51,20 +51,23 @@
51
51
  "dotenv": "^17.4.2",
52
52
  "electron-log": "^5.4.3",
53
53
  "electron-store": "^8.1.0",
54
+ "electron-updater": "^6.8.9",
54
55
  "he": "^1.2.0",
55
56
  "node-pty": "^1.1.0",
56
- "@steerable/agent-protocol": "0.6.35",
57
- "@steerable/pack-sdk": "0.6.35",
58
- "@steerable/agent-harness": "0.6.35"
57
+ "semver": "^7.8.0",
58
+ "@steerable/agent-harness": "0.6.37",
59
+ "@steerable/agent-protocol": "0.6.37",
60
+ "@steerable/pack-sdk": "0.6.37"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@types/better-sqlite3": "^7.6.13",
62
64
  "@types/he": "^1.2.3",
63
65
  "@types/node": "^22.10.5",
66
+ "@types/semver": "^7.8.0",
64
67
  "electron": "^28.3.3",
68
+ "esbuild": "^0.25.0",
65
69
  "typescript": "^5.8.3",
66
- "vitest": "^3.2.4",
67
- "esbuild": "^0.25.0"
70
+ "vitest": "^3.2.4"
68
71
  },
69
72
  "scripts": {
70
73
  "gen": "echo agent-shell-ts",