diff --git a/lib/bridge.js b/lib/bridge.js index d1457a9..eefebfc 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -14,11 +14,36 @@ import { formatLastTurn, loadLastTurn } from './history.js'; import { describeAgent, displayLabel, workspaceName } from './label.js'; import { formatModel, loadSessionModels, selectSessionModel, } from './models.js'; import { formatStatusText, } from './status.js'; -function compactErrorCode(err) { - const code = err?.code; - if (code === 'busy' || code === 'cancelled' || code === 'changed' - || code === 'summary' || code === 'commit' || code === 'persistence') { - return code; +/** + * 把 command-compact 返回的英文结果翻译成 TG 中文回复。 + * 文案以宿主 @deepseek-ai/dsh-command-compact 的固定输出为准;无法识别时返回 + * undefined,由调用方回退为「压缩失败 + 原文详情」。 + */ +function translateCompactResult(result) { + const kind = result?.kind; + const text = result?.text ?? ''; + if (kind === 'success') { + const m = /Compacted (\d+) history items \(~(\d+) tokens\)/.exec(text); + if (m) + return MSG.COMPACT_DONE(Number(m[1]), Number(m[2])); + if (/No compactable history yet/.test(text)) + return MSG.COMPACT_NOTHING; + return undefined; + } + if (kind === 'error') { + if (/active compaction|not idle/.test(text)) + return MSG.COMPACT_BUSY; + if (/^Compaction cancelled/.test(text)) + return MSG.COMPACT_CANCELLED; + if (/history selected for compaction changed/.test(text)) + return MSG.COMPACT_CHANGED; + if (/could not produce a useful summary/.test(text)) + return MSG.COMPACT_SUMMARY_FAILED; + if (/did not finish cleanly/.test(text)) + return MSG.COMPACT_COMMIT_FAILED; + if (/could not be saved/.test(text)) + return MSG.COMPACT_PERSIST_FAILED; + return undefined; } return undefined; } @@ -758,17 +783,17 @@ export class TelegramBridge { return undefined; } // ── /compact:手动压缩当前绑定会话(需会话空闲)── + // + // 压缩引擎(compaction-basic)挂载在隔离作用域,插件 ctx 拿不到;但宿主的 + // command-compact 已把 /compact 注册进命令运行时的按 agent 作用层(Web 端 + // 同款通道),因此这里复用它:语义、busy/错误映射、命令生命周期日志与 Web + // 端 /compact 完全一致,且无需接触引擎内部。 async requestCompact(chatId) { const binding = this.bindings.get(String(chatId)); if (!binding) { await this.client.sendMessage(chatId, MSG.NEED_BIND); return; } - const engine = this.serviceOf('compaction'); - if (!engine?.compactNow) { - await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE); - return; - } if (this.compacting.has(binding.sessionId)) { await this.client.sendMessage(chatId, MSG.COMPACT_INFLIGHT); return; @@ -779,25 +804,24 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.GONE); return; } - const controller = new AbortController(); - let operation; - try { - // compactNow 在会话忙时同步抛 busy(runMaintenance 同步判定)。 - operation = engine.compactNow(agent, controller.signal); - } - catch (err) { - const code = compactErrorCode(err); - if (code === 'busy') { - await this.client.sendMessage(chatId, MSG.COMPACT_BUSY); - return; - } - this.ctx.logger.warn(`dsh-telegram-channel: /compact rejected: ${this.redact(err)}`); - await this.client.sendMessage(chatId, MSG.COMPACT_FAILED(this.redact(err))); + const commands = this.serviceOf('commands'); + if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'compact')) { + await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE); return; } + // 会话正在跑任务时压缩会被拒(runMaintenance 忙);提前提示避免「已开始→失败」两步。 + const busy = this.busySessions.has(binding.sessionId) + || Boolean(agent.phase?.kind + && agent.phase?.kind !== 'idle'); + if (busy) { + await this.client.sendMessage(chatId, MSG.COMPACT_BUSY); + return; + } + const controller = new AbortController(); this.compacting.add(binding.sessionId); this.compactAborts.set(binding.sessionId, controller); await this.client.sendMessage(chatId, MSG.COMPACT_STARTED); + const operation = commands.execute(agent, '/compact', [], controller.signal); void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => { this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`); }); @@ -805,13 +829,20 @@ export class TelegramBridge { /** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */ async runCompaction(chatId, sessionId, operation, controller) { try { - const result = await operation; - if (result === null) { - await this.enqueueNotice(chatId, MSG.COMPACT_NOTHING); + const execution = await operation; + if (execution === undefined) { + // find() 通过但 execute 未命中:按未注册处理。 + await this.enqueueNotice(chatId, MSG.COMPACT_UNAVAILABLE); + return; } - else { - await this.enqueueNotice(chatId, MSG.COMPACT_DONE(result.shadowedSeqs.length, result.shadowedTokenCount)); + const translated = translateCompactResult(execution.result); + if (translated !== undefined) { + await this.enqueueNotice(chatId, translated); + return; } + // 无法识别的结果:回退为通用失败 + 宿主原文。 + const raw = execution.result?.text; + await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(raw)); } catch (err) { if (controller.signal.aborted) { @@ -819,30 +850,8 @@ export class TelegramBridge { this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop'); return; } - const code = compactErrorCode(err); - switch (code) { - case 'cancelled': - await this.enqueueNotice(chatId, MSG.COMPACT_CANCELLED); - return; - case 'changed': - await this.enqueueNotice(chatId, MSG.COMPACT_CHANGED); - return; - case 'summary': - await this.enqueueNotice(chatId, MSG.COMPACT_SUMMARY_FAILED); - return; - case 'commit': - await this.enqueueNotice(chatId, MSG.COMPACT_COMMIT_FAILED); - return; - case 'persistence': - await this.enqueueNotice(chatId, MSG.COMPACT_PERSIST_FAILED); - return; - case 'busy': - await this.enqueueNotice(chatId, MSG.COMPACT_BUSY); - return; - default: - this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`); - await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err))); - } + this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`); + await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err))); } finally { this.compacting.delete(sessionId); diff --git a/lib/commands.d.ts b/lib/commands.d.ts index 40898bc..ef8688a 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -15,7 +15,7 @@ export declare const MSG: { readonly STATUS_BOUND: (label: string) => string; readonly STATUS_BOUND_COLD: (label: string) => string; readonly COMPACT_USAGE: "用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)"; - readonly COMPACT_UNAVAILABLE: "当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。"; + readonly COMPACT_UNAVAILABLE: "当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。"; readonly COMPACT_STARTED: "已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。"; readonly COMPACT_BUSY: "当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。"; readonly COMPACT_INFLIGHT: "该会话已在进行压缩,请稍候(完成后会报告结果)。"; diff --git a/lib/commands.js b/lib/commands.js index f584a79..8920d16 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -39,7 +39,7 @@ export const MSG = { return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`; }, COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)', - COMPACT_UNAVAILABLE: '当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。', + COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。', COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。', COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。', COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。', diff --git a/src/bridge.ts b/src/bridge.ts index 3920731..3336109 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -126,27 +126,49 @@ interface ProjectionRegistryLike { snapshot?: (session: unknown) => { asOfSeq: number; values: Record } | undefined } -/** 手动压缩引擎(结构镜像 ctx.compaction.compactNow)。 */ -interface CompactionEngineLike { - compactNow?: ( +/** + * 命令运行时(结构镜像 ctx.commands.execute)。 + * + * /compact 不在插件可见作用域内直接提供 compaction 服务(compaction-basic 挂载在 + * 隔离作用域,ctx.get 取不到),但宿主命令运行时可见,且 command-compact 已把 + * /compact 注册进该运行时的按 agent 作用层 —— Web 端正是用 execute() 派发它的。 + * TG 端复用同一条通道:语义、busy/错误映射、命令生命周期日志与 Web 完全一致。 + */ +interface CommandsRuntimeLike { + find?: (agent: unknown, name: string) => unknown + execute?: ( agent: unknown, + line: string, + images: readonly unknown[], signal: AbortSignal, - sourceCommandId?: string, - ) => Promise<{ - shadowedSeqs: number[] - shadowedTokenCount: number - summarySeq: number - } | null> + ) => Promise<{ commandId: string; result: { kind: string; text?: string } } | undefined> } -/** 手动压缩的预期失败类(ManualCompactionError.code 的运行时字符串)。 */ -type CompactFailureCode = 'busy' | 'cancelled' | 'changed' | 'summary' | 'commit' | 'persistence' +/** command-compact 的 CommandResult.kind 值。 */ +type CompactCommandKind = 'success' | 'error' -function compactErrorCode(err: unknown): CompactFailureCode | undefined { - const code = (err as { code?: unknown } | undefined)?.code - if (code === 'busy' || code === 'cancelled' || code === 'changed' - || code === 'summary' || code === 'commit' || code === 'persistence') { - return code +/** + * 把 command-compact 返回的英文结果翻译成 TG 中文回复。 + * 文案以宿主 @deepseek-ai/dsh-command-compact 的固定输出为准;无法识别时返回 + * undefined,由调用方回退为「压缩失败 + 原文详情」。 + */ +function translateCompactResult(result: { kind: string; text?: string } | undefined): string | undefined { + const kind = result?.kind as CompactCommandKind | undefined + const text = result?.text ?? '' + if (kind === 'success') { + const m = /Compacted (\d+) history items \(~(\d+) tokens\)/.exec(text) + if (m) return MSG.COMPACT_DONE(Number(m[1]), Number(m[2])) + if (/No compactable history yet/.test(text)) return MSG.COMPACT_NOTHING + return undefined + } + if (kind === 'error') { + if (/active compaction|not idle/.test(text)) return MSG.COMPACT_BUSY + if (/^Compaction cancelled/.test(text)) return MSG.COMPACT_CANCELLED + if (/history selected for compaction changed/.test(text)) return MSG.COMPACT_CHANGED + if (/could not produce a useful summary/.test(text)) return MSG.COMPACT_SUMMARY_FAILED + if (/did not finish cleanly/.test(text)) return MSG.COMPACT_COMMIT_FAILED + if (/could not be saved/.test(text)) return MSG.COMPACT_PERSIST_FAILED + return undefined } return undefined } @@ -914,6 +936,11 @@ export class TelegramBridge { } // ── /compact:手动压缩当前绑定会话(需会话空闲)── + // + // 压缩引擎(compaction-basic)挂载在隔离作用域,插件 ctx 拿不到;但宿主的 + // command-compact 已把 /compact 注册进命令运行时的按 agent 作用层(Web 端 + // 同款通道),因此这里复用它:语义、busy/错误映射、命令生命周期日志与 Web + // 端 /compact 完全一致,且无需接触引擎内部。 private async requestCompact(chatId: number): Promise { const binding = this.bindings.get(String(chatId)) @@ -921,11 +948,6 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NEED_BIND) return } - const engine = this.serviceOf('compaction') - if (!engine?.compactNow) { - await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE) - return - } if (this.compacting.has(binding.sessionId)) { await this.client.sendMessage(chatId, MSG.COMPACT_INFLIGHT) return @@ -936,24 +958,24 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.GONE) return } - const controller = new AbortController() - let operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null> - try { - // compactNow 在会话忙时同步抛 busy(runMaintenance 同步判定)。 - operation = engine.compactNow(agent as never, controller.signal) - } catch (err) { - const code = compactErrorCode(err) - if (code === 'busy') { - await this.client.sendMessage(chatId, MSG.COMPACT_BUSY) - return - } - this.ctx.logger.warn(`dsh-telegram-channel: /compact rejected: ${this.redact(err)}`) - await this.client.sendMessage(chatId, MSG.COMPACT_FAILED(this.redact(err))) + const commands = this.serviceOf('commands') + if (!commands?.execute || typeof commands.find !== 'function' || !commands.find(agent, 'compact')) { + await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE) return } + // 会话正在跑任务时压缩会被拒(runMaintenance 忙);提前提示避免「已开始→失败」两步。 + const busy = this.busySessions.has(binding.sessionId) + || Boolean((agent as { phase?: { kind?: string } }).phase?.kind + && (agent as { phase?: { kind?: string } }).phase?.kind !== 'idle') + if (busy) { + await this.client.sendMessage(chatId, MSG.COMPACT_BUSY) + return + } + const controller = new AbortController() this.compacting.add(binding.sessionId) this.compactAborts.set(binding.sessionId, controller) await this.client.sendMessage(chatId, MSG.COMPACT_STARTED) + const operation = commands.execute(agent as never, '/compact', [], controller.signal) void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => { this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`) }) @@ -963,46 +985,32 @@ export class TelegramBridge { private async runCompaction( chatId: number, sessionId: string, - operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>, + operation: Promise<{ commandId: string; result: { kind: string; text?: string } } | undefined>, controller: AbortController, ): Promise { try { - const result = await operation - if (result === null) { - await this.enqueueNotice(chatId, MSG.COMPACT_NOTHING) - } else { - await this.enqueueNotice(chatId, MSG.COMPACT_DONE(result.shadowedSeqs.length, result.shadowedTokenCount)) + const execution = await operation + if (execution === undefined) { + // find() 通过但 execute 未命中:按未注册处理。 + await this.enqueueNotice(chatId, MSG.COMPACT_UNAVAILABLE) + return } + const translated = translateCompactResult(execution.result) + if (translated !== undefined) { + await this.enqueueNotice(chatId, translated) + return + } + // 无法识别的结果:回退为通用失败 + 宿主原文。 + const raw = execution.result?.text + await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(raw)) } catch (err) { if (controller.signal.aborted) { // 桥停止/热重载导致的取消:静默,不打扰(本实例正在退出)。 this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop') return } - const code = compactErrorCode(err) - switch (code) { - case 'cancelled': - await this.enqueueNotice(chatId, MSG.COMPACT_CANCELLED) - return - case 'changed': - await this.enqueueNotice(chatId, MSG.COMPACT_CHANGED) - return - case 'summary': - await this.enqueueNotice(chatId, MSG.COMPACT_SUMMARY_FAILED) - return - case 'commit': - await this.enqueueNotice(chatId, MSG.COMPACT_COMMIT_FAILED) - return - case 'persistence': - await this.enqueueNotice(chatId, MSG.COMPACT_PERSIST_FAILED) - return - case 'busy': - await this.enqueueNotice(chatId, MSG.COMPACT_BUSY) - return - default: - this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`) - await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err))) - } + this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`) + await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err))) } finally { this.compacting.delete(sessionId) this.compactAborts.delete(sessionId) diff --git a/src/commands.ts b/src/commands.ts index 22e45a4..8a7e6ee 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -39,7 +39,7 @@ export const MSG = { return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)` }, COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)', - COMPACT_UNAVAILABLE: '当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。', + COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。', COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。', COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。', COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。', diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index c03e8ba..5b33af4 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -912,11 +912,11 @@ test('/compact compacts bound session and reports result', async () => { roots: () => [agent], get: (id: ReturnType) => (String(id) === sessionId ? agent : undefined), }, - compaction: { - compactNow: async () => ({ - shadowedSeqs: [1, 2, 3, 4], - shadowedTokenCount: 12_345, - summarySeq: 9, + commands: { + find: () => true, + execute: async () => ({ + commandId: 'cmd-1', + result: { kind: 'success', text: 'Compacted 4 history items (~12345 tokens).' }, }), }, on() { return () => {} }, @@ -962,10 +962,15 @@ test('/compact busy agent replies COMPACT_BUSY without starting', async () => { roots: () => [agent], get: (id: ReturnType) => (String(id) === sessionId ? agent : undefined), }, - compaction: { - compactNow: () => { - throw Object.assign(new Error('busy'), { code: 'busy' }) - }, + commands: { + find: () => true, + execute: async () => ({ + commandId: 'cmd-busy', + result: { + kind: 'error', + text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.', + }, + }), }, on() { return () => {} }, } @@ -987,11 +992,14 @@ test('/compact busy agent replies COMPACT_BUSY without starting', async () => { }) sent.length = 0 // drop the BOUND message; keep only /compact replies await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2)) - assert.equal(sent.length, 1) - assert.equal(sent[0]!.text, MSG.COMPACT_BUSY) + assert.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent') + for (let i = 0; i < 100 && !sent.some((m) => m.text.includes(MSG.COMPACT_BUSY)); i++) { + await new Promise((r) => setTimeout(r, 5)) + } + assert.ok(sent.some((m) => m.text === MSG.COMPACT_BUSY), 'busy notice arrives') }) -test('/compact without compaction engine reports unavailable', async () => { +test('/compact without registered /compact command reports unavailable', async () => { const sent: SentMessage[] = [] const followups: UserMessage[] = [] const sessionId = 'live-noeng' @@ -1003,6 +1011,9 @@ test('/compact without compaction engine reports unavailable', async () => { roots: () => [agent], get: (id: ReturnType) => (String(id) === sessionId ? agent : undefined), }, + commands: { + find: () => undefined, // command not registered on this host + }, on() { return () => {} }, } const bridge = new TelegramBridge(ctx as any, {