From 36e95c263190cd49176cb3358fdcebf51dbe257c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 17 Sep 2026 12:05:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(goal):=20TG=20/goal=20=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E7=9B=AE=E6=A0=87=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 复用宿主命令运行时(commands.execute,与 Web 端 /goal 同通道),不依赖 GUI 连接:command-goal 在宿主侧操作 session-log 支撑的 goals 域 - 语法与 Web 完全一致:/goal <目标>、/goal(查看)、clear/edit/pause/resume - 目标状态变更(blocked/complete)实时推送 TG 通知,手机可感知目标模式进展 - 配置侧启用 command-goal(plugin_toggle,已 live 生效) --- lib/bridge.d.ts | 5 +++++ lib/bridge.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++ lib/commands.d.ts | 4 ++++ lib/commands.js | 4 ++++ src/bridge.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++ src/commands.ts | 5 +++++ 6 files changed, 130 insertions(+) diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 58f4914..7fc4ca3 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -85,6 +85,11 @@ export declare class TelegramBridge { /** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */ private serviceOf; private requestCompact; + /** + * TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条 + * 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。 + */ + private requestGoal; /** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */ private runCompaction; private followupBound; diff --git a/lib/bridge.js b/lib/bridge.js index 2ca1dea..8359ad8 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -194,6 +194,7 @@ export class TelegramBridge { { command: 'model', description: '切换当前绑定会话的模型' }, { command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' }, { command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' }, + { command: 'goal', description: '目标模式:/goal <目标>(clear/edit/pause/resume 管理)' }, { command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' }, { command: 'unbind', description: '断开手机绑定(不关闭本机会话)' }, { command: 'stop', description: '中止当前正在运行的任务' }, @@ -309,6 +310,9 @@ export class TelegramBridge { case 'compact': await this.requestCompact(chatId); return; + case 'goal': + await this.requestGoal(chatId, parsed.arg); + return; case 'rich': await this.handleRichCommand(chatId, parsed.arg); return; @@ -895,6 +899,41 @@ export class TelegramBridge { this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`); }); } + /** + * TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条 + * 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。 + */ + async requestGoal(chatId, arg) { + const binding = this.bindings.get(String(chatId)); + if (!binding) { + await this.client.sendMessage(chatId, MSG.NEED_BIND); + return; + } + const agent = await this.ensureLiveAgent(binding.sessionId); + if (!agent) { + this.bindings.delete(String(chatId)); + await this.client.sendMessage(chatId, MSG.GONE); + return; + } + const commands = this.serviceOf('commands'); + if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'goal')) { + await this.client.sendMessage(chatId, '当前环境不支持 /goal(command-goal 未装配)'); + return; + } + const line = `/goal${arg ? ` ${arg}` : ''}`; + try { + const execution = await commands.execute(agent, line, [], new AbortController().signal); + if (execution === undefined) { + await this.client.sendMessage(chatId, '/goal 未执行:命令在运行时已注销'); + return; + } + const text = execution.result?.text ?? (execution.result?.kind === 'success' ? '完成' : '失败'); + await this.client.sendMessage(chatId, text.slice(0, 3500)); + } + catch (err) { + await this.client.sendMessage(chatId, `/goal 执行失败:${this.redact(err).slice(0, 200)}`); + } + } /** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */ async runCompaction(chatId, sessionId, operation, controller) { try { @@ -1204,6 +1243,23 @@ export class TelegramBridge { } return; } + // 'goal/changed' 不在本插件的 SessionEvent 类型联合内,宽松断言读取。 + const eventType = event.type; + if (eventType === 'goal/changed') { + // Goal mode lives on the phone too: surface phase transitions (blocked/complete + // especially) without waiting for the next assistant text. + const data = event.data; + const phase = data?.phase; + if (phase === 'blocked' || phase === 'complete') { + const reason = data?.blockedReason; + const label = phase === 'blocked' + ? `目标被阻塞${reason ? `:${reason.code ?? ''} ${reason.message ?? ''}`.slice(0, 120) : ''}` + : '目标已达成 ✓'; + for (const b of targets) + this.enqueueNotice(b.chatId, `> ${label}`); + } + return; + } if (event.type === 'todo/write') { const todos = Array.isArray(event.data?.todos) ? event.data.todos : []; this.lastTodos.set(id, todos); diff --git a/lib/commands.d.ts b/lib/commands.d.ts index 08cbd11..0aefad6 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -67,6 +67,10 @@ export type ParsedCommand = { type: 'rich'; text: string; arg?: string; +} | { + type: 'goal'; + text: string; + arg?: string; } | { type: 'unbind'; text: string; diff --git a/lib/commands.js b/lib/commands.js index 8d8056f..6c108ac 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -127,6 +127,10 @@ export function parseCommand(text) { return { type: 'status', text }; case '/compact': return { type: 'compact', text }; + case '/goal': + case '/目标': + // 目标语句是多词文本,保留完整参数(去掉命令名本身) + return { type: 'goal', text, arg: text.slice(command.length).trim() }; case '/rich': case '/render': case '/setting': { diff --git a/src/bridge.ts b/src/bridge.ts index 06cb9c7..ec8029b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -334,6 +334,7 @@ export class TelegramBridge { { command: 'model', description: '切换当前绑定会话的模型' }, { command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' }, { command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' }, + { command: 'goal', description: '目标模式:/goal <目标>(clear/edit/pause/resume 管理)' }, { command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' }, { command: 'unbind', description: '断开手机绑定(不关闭本机会话)' }, { command: 'stop', description: '中止当前正在运行的任务' }, @@ -451,6 +452,9 @@ export class TelegramBridge { case 'compact': await this.requestCompact(chatId) return + case 'goal': + await this.requestGoal(chatId, (parsed as { arg?: string }).arg) + return case 'rich': await this.handleRichCommand(chatId, parsed.arg) return @@ -1050,6 +1054,41 @@ export class TelegramBridge { }) } + /** + * TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条 + * 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。 + */ + private async requestGoal(chatId: number, arg?: string): Promise { + const binding = this.bindings.get(String(chatId)) + if (!binding) { + await this.client.sendMessage(chatId, MSG.NEED_BIND) + return + } + const agent = await this.ensureLiveAgent(binding.sessionId) + if (!agent) { + this.bindings.delete(String(chatId)) + await this.client.sendMessage(chatId, MSG.GONE) + return + } + const commands = this.serviceOf('commands') + if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'goal')) { + await this.client.sendMessage(chatId, '当前环境不支持 /goal(command-goal 未装配)') + return + } + const line = `/goal${arg ? ` ${arg}` : ''}` + try { + const execution = await commands.execute(agent as never, line, [], new AbortController().signal) + if (execution === undefined) { + await this.client.sendMessage(chatId, '/goal 未执行:命令在运行时已注销') + return + } + const text = execution.result?.text ?? (execution.result?.kind === 'success' ? '完成' : '失败') + await this.client.sendMessage(chatId, text.slice(0, 3500)) + } catch (err) { + await this.client.sendMessage(chatId, `/goal 执行失败:${this.redact(err).slice(0, 200)}`) + } + } + /** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */ private async runCompaction( chatId: number, @@ -1367,6 +1406,23 @@ export class TelegramBridge { return } + // 'goal/changed' 不在本插件的 SessionEvent 类型联合内,宽松断言读取。 + const eventType = (event as { type: string }).type + if (eventType === 'goal/changed') { + // Goal mode lives on the phone too: surface phase transitions (blocked/complete + // especially) without waiting for the next assistant text. + const data = (event as { data?: { phase?: string; blockedReason?: { code?: string; message?: string } } }).data + const phase = data?.phase + if (phase === 'blocked' || phase === 'complete') { + const reason = data?.blockedReason + const label = phase === 'blocked' + ? `目标被阻塞${reason ? `:${reason.code ?? ''} ${reason.message ?? ''}`.slice(0, 120) : ''}` + : '目标已达成 ✓' + for (const b of targets) this.enqueueNotice(b.chatId, `> ${label}`) + } + return + } + if (event.type === 'todo/write') { const todos = Array.isArray(event.data?.todos) ? event.data.todos : [] this.lastTodos.set(id, todos as Array<{ content?: string; status?: string }>) diff --git a/src/commands.ts b/src/commands.ts index 4e18a2e..3b5c2d0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -111,6 +111,7 @@ export type ParsedCommand = | { type: 'status'; text: string } | { type: 'compact'; text: string } | { type: 'rich'; text: string; arg?: string } + | { type: 'goal'; text: string; arg?: string } | { type: 'unbind'; text: string } | { type: 'stop'; text: string } | { type: 'mission'; text: string } @@ -140,6 +141,10 @@ export function parseCommand(text: string): ParsedCommand { return { type: 'status', text } case '/compact': return { type: 'compact', text } + case '/goal': + case '/目标': + // 目标语句是多词文本,保留完整参数(去掉命令名本身) + return { type: 'goal', text, arg: text.slice(command.length).trim() } case '/rich': case '/render': case '/setting': {