feat: support compact command across IM channels

This commit is contained in:
xmanrui 2026-08-18 23:20:05 +08:00
parent 4545cfe9c5
commit 1cc7a26f30
40 changed files with 728 additions and 136 deletions

View file

@ -9,6 +9,7 @@ import {
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
@ -21,6 +22,7 @@ const HELP_TEXT = [
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -346,6 +348,17 @@ export class DingtalkHarnessBridge {
}
return;
}
const compactCommand = await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#send(sessionWebhook, compactCommand.message);
return;
}
if (typeof this.#api.createAiCard === 'function'
&& typeof this.#api.updateAiCard === 'function'

View file

@ -11,6 +11,7 @@ import {
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
@ -22,6 +23,7 @@ const HELP_TEXT = [
'',
'直接发送问题即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -322,6 +324,17 @@ export class FeishuHarnessBridge {
}
return;
}
const compactCommand = await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#send(event.message.chat_id, compactCommand.message);
return;
}
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
try {

View file

@ -1,4 +1,5 @@
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
@ -14,6 +15,7 @@ const HELP_TEXT = [
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -241,6 +243,18 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const compactCommand = await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#bot.sendText(target, compactCommand.message);
await this.#state.markSeen(messageId);
return;
}
let stream = null;
let streamFinished = false;

View file

@ -668,6 +668,19 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
return target.ask(sessionId, ...args);
};
}
if (property === 'executeCommand' && typeof target.executeCommand === 'function') {
return (sessionId, ...args) => {
const generation = sessionGenerations.get(sessionId);
sessionGenerations.delete(sessionId);
if (!isCurrentScope()
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
const error = new Error('The bot workspace changed before this command started.');
error.code = WORKSPACE_SESSION_STALE;
throw error;
}
return target.executeCommand(sessionId, ...args);
};
}
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
},

View file

@ -0,0 +1,95 @@
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const COMPACT_COMMAND = /^\/compact(?=$|\s)([\s\S]*)$/i;
const COMPACT_USAGE = '用法:/compact(不带参数)';
const COMPACT_RESULT_TEXT = new Map([
['No compactable history yet.', '暂无可压缩的历史记录。'],
[
'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
'当前会话正在生成回复或执行压缩,请稍后重试。',
],
['Compaction cancelled.', '上下文压缩已取消。'],
[
'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
'压缩期间会话历史发生变化,本次未修改会话,请重试。',
],
[
'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
'未能生成有效的压缩摘要,本次未修改会话。',
],
[
'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
'上下文压缩未正常完成,部分会话历史可能已变化,请检查会话后再重试。',
],
[
'Compaction finished, but the session could not be saved.',
'上下文已压缩,但会话保存失败。',
],
]);
function commandResult(message) {
return { handled: true, message, messages: [message] };
}
function compactResultText(result) {
if (!result || typeof result !== 'object'
|| !['success', 'error'].includes(result.kind)
|| (result.text !== undefined && typeof result.text !== 'string')) {
throw new TypeError('Harness returned an invalid /compact result');
}
const text = result.text?.trim() ?? '';
const compacted = /^Compacted (\d+) history items \(~(\d+) tokens\)\.$/u.exec(text);
if (compacted) {
return `已压缩 ${compacted[1]} 条历史记录(约 ${compacted[2]} 个 token)。`;
}
if (COMPACT_RESULT_TEXT.has(text)) return COMPACT_RESULT_TEXT.get(text);
if (text) return text;
return result.kind === 'success' ? '上下文压缩完成。' : '上下文压缩失败。';
}
function compactErrorMessage(error) {
const code = error?.code ?? error?.failure?.code;
if (code === 'session-not-found') {
return '当前聊天绑定的会话已不存在,请发送新消息开启会话。';
}
if (code === 'agent-busy') return '当前会话正在生成回复,请稍后重试。';
if (code === 'cancelled' || error?.name === 'AbortError') return '上下文压缩已取消。';
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
return '工作区或机器人状态已发生变化,请重试。';
}
if (code === 'commands-unavailable') {
return '当前 Harness 暂不支持从机器人执行上下文压缩。';
}
return '上下文压缩失败,请稍后重试。';
}
/**
* Execute the explicit Harness compaction command for an existing IM conversation Session.
* Unknown input returns null so the caller may continue ordinary message routing.
*/
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
if (typeof text !== 'string') return null;
const match = COMPACT_COMMAND.exec(text.trim());
if (!match) return null;
if (match[1].trim()) return commandResult(COMPACT_USAGE);
if (typeof state?.sessionFor !== 'function') {
return commandResult('当前机器人没有可用的会话状态。');
}
const sessionId = state.sessionFor(conversationKey);
if (typeof sessionId !== 'string' || !sessionId) {
return commandResult('当前聊天还没有可压缩的会话,请先发送一条消息。');
}
if (typeof harness?.executeCommand !== 'function') {
return commandResult('当前机器人暂不支持上下文压缩。');
}
try {
const execution = await harness.executeCommand(sessionId, '/compact', options);
if (execution === undefined) {
return commandResult('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。');
}
return commandResult(compactResultText(execution?.result));
} catch (error) {
return commandResult(compactErrorMessage(error));
}
}

View file

@ -318,6 +318,7 @@ export class HarnessClient {
#interactionReconnectDelayMs;
#rpcIdPrefix;
#logPrefix;
#commandExecutor;
#managedProcess = null;
#interactionRegistry;
#interactionOwnerships;
@ -334,6 +335,7 @@ export class HarnessClient {
interactionReconnectDelayMs = 500,
rpcIdPrefix = 'im',
logPrefix = 'dsh-im',
commandExecutor,
}) {
if (typeof createWebSocket !== 'function') {
throw new TypeError('createWebSocket must be a function');
@ -347,6 +349,9 @@ export class HarnessClient {
if (typeof logPrefix !== 'string' || !logPrefix.trim()) {
throw new TypeError('logPrefix must be a non-empty string');
}
if (commandExecutor !== undefined && typeof commandExecutor !== 'function') {
throw new TypeError('commandExecutor must be a function');
}
this.#baseUrl = new URL(baseUrl);
this.#workspace = workspace;
this.#agentPreset = agentPreset;
@ -357,6 +362,7 @@ export class HarnessClient {
this.#interactionReconnectDelayMs = interactionReconnectDelayMs;
this.#rpcIdPrefix = rpcIdPrefix.trim();
this.#logPrefix = logPrefix.trim();
this.#commandExecutor = commandExecutor;
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
this.#interactionClaims = this.#interactionRegistry.claims;
@ -459,6 +465,24 @@ export class HarnessClient {
return created.sessionId;
}
async executeCommand(sessionId, line, options = {}) {
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
if (!this.#commandExecutor) {
const error = new Error('Harness command execution is unavailable');
error.code = 'commands-unavailable';
throw error;
}
try {
return await this.#commandExecutor(sessionId, line, options);
} catch (error) {
if (error?.failure && typeof error.failure === 'object') {
throw new HarnessRpcError('commands.execute', error.failure);
}
throw error;
}
}
async sessionExists(sessionId, options = {}) {
try {
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);

View file

@ -1,4 +1,5 @@
import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs';
import { askInWorkspaceSession } from './workspace-session.mjs';
import { HarnessApprovalQueue } from './harness-approval.mjs';
import {
@ -218,6 +219,7 @@ export class TextHarnessBridge {
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -244,6 +246,17 @@ export class TextHarnessBridge {
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
return;
}
const compactCommand = await runCompactCommand(
text,
this.#harness,
this.#state,
conversationKey,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#bot.sendText(target, compactCommand.message);
return;
}
await this.#bot.sendTyping?.(target).catch((error) => {
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);

View file

@ -5,6 +5,7 @@ import {
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
@ -13,6 +14,7 @@ const HELP_TEXT = [
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -321,6 +323,18 @@ export class WecomHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const compactCommand = await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#sendImmediate(frame, chatId, compactCommand.message);
await this.#state.markSeen(messageId);
return;
}
streamId = this.#generateReqId('stream');
try {

View file

@ -9,6 +9,7 @@ import {
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
@ -19,6 +20,7 @@ const HELP_TEXT = [
'',
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -248,6 +250,18 @@ export class WeixinHarnessBridge {
await this.#state.markSeen(messageId);
return;
}
const compactCommand = await runCompactCommand(
text,
this.#harness,
this.#state,
key,
{ signal: this.#signal },
);
if (compactCommand) {
await this.#send(sender, compactCommand.message, contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
let answer;
try {