@xmanrui/dsh-im 0.7.2 → 0.9.0

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.
Files changed (37) hide show
  1. package/README.en.md +154 -0
  2. package/README.md +28 -132
  3. package/lib/client.js +15 -14
  4. package/lib/index.js +125 -124
  5. package/package.json +2 -1
  6. package/plugin-src/client/styles.js +15 -14
  7. package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
  8. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  9. package/plugin-src/host/channels/discord/index.mjs +1 -1
  10. package/plugin-src/host/channels/feishu/index.mjs +1 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  12. package/plugin-src/host/channels/qq/index.mjs +1 -1
  13. package/plugin-src/host/channels/qq/production.mjs +3 -0
  14. package/plugin-src/host/channels/shared/production.mjs +3 -0
  15. package/plugin-src/host/channels/slack/index.mjs +1 -1
  16. package/plugin-src/host/channels/slack/production.mjs +3 -0
  17. package/plugin-src/host/channels/telegram/index.mjs +1 -1
  18. package/plugin-src/host/channels/wecom/index.mjs +1 -1
  19. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  20. package/plugin-src/host/channels/weixin/index.mjs +1 -1
  21. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  22. package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
  23. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  24. package/plugin-src/host/harness-command-executor.mjs +21 -0
  25. package/plugin-src/host/index.mjs +1 -1
  26. package/src/channels/dingtalk/dingtalk-bridge.mjs +14 -1
  27. package/src/channels/discord/discord-api.mjs +1 -1
  28. package/src/channels/feishu/bridge.mjs +14 -1
  29. package/src/channels/qq/qq-bridge.mjs +15 -1
  30. package/src/channels/shared/bot-workspace-store.mjs +13 -0
  31. package/src/channels/shared/compact-command.mjs +95 -0
  32. package/src/channels/shared/harness-client.mjs +59 -1
  33. package/src/channels/shared/text-harness-bridge.mjs +14 -1
  34. package/src/channels/shared/workspace-command.mjs +70 -6
  35. package/src/channels/wecom/wecom-bridge.mjs +15 -1
  36. package/src/channels/weixin/weixin-api.mjs +1 -1
  37. package/src/channels/weixin/weixin-bridge.mjs +15 -1
@@ -40,6 +40,37 @@ function workspaceFromList(workspacePath, workspaceList) {
40
40
  return workspace;
41
41
  }
42
42
 
43
+ function toEpochMs(value) {
44
+ if (typeof value === 'number' && Number.isFinite(value)) {
45
+ return value < 1e12 ? value * 1000 : value;
46
+ }
47
+ if (typeof value === 'string' && value) {
48
+ const parsed = Date.parse(value);
49
+ if (!Number.isNaN(parsed)) return parsed;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function sessionTimeMs(summary) {
55
+ if (!summary || typeof summary !== 'object') return null;
56
+ const candidates = [
57
+ summary?.header?.lastActivityAt,
58
+ summary?.header?.updatedAt,
59
+ summary?.header?.createdAt,
60
+ summary?.projections?.values?.lastActivityAt,
61
+ summary?.projections?.values?.updatedAt,
62
+ summary?.projections?.values?.createdAt,
63
+ summary?.lastActivityAt,
64
+ summary?.updatedAt,
65
+ summary?.createdAt,
66
+ ];
67
+ for (const value of candidates) {
68
+ const ms = toEpochMs(value);
69
+ if (ms !== null) return ms;
70
+ }
71
+ return null;
72
+ }
73
+
43
74
  function workspaceSessions(workspace, archivedSessionIds, sessionList) {
44
75
  if (!Array.isArray(sessionList?.items)) {
45
76
  throw new Error('Harness returned an invalid response for session.list');
@@ -54,7 +85,7 @@ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
54
85
  sessions: workspace.sessionIds.map((sessionId) => {
55
86
  const summary = summaries.get(sessionId);
56
87
  const title = summary?.projections?.values?.title;
57
- return {
88
+ const session = {
58
89
  sessionId,
59
90
  title: typeof title === 'string' ? title : null,
60
91
  archived: archived.has(sessionId),
@@ -62,6 +93,9 @@ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
62
93
  origin: summary?.origin === 'subagent' ? 'subagent' : null,
63
94
  summaryAvailable: summary !== undefined,
64
95
  };
96
+ const time = sessionTimeMs(summary);
97
+ if (time !== null) session.time = time;
98
+ return session;
65
99
  }),
66
100
  };
67
101
  }
@@ -284,6 +318,7 @@ export class HarnessClient {
284
318
  #interactionReconnectDelayMs;
285
319
  #rpcIdPrefix;
286
320
  #logPrefix;
321
+ #commandExecutor;
287
322
  #managedProcess = null;
288
323
  #interactionRegistry;
289
324
  #interactionOwnerships;
@@ -300,6 +335,7 @@ export class HarnessClient {
300
335
  interactionReconnectDelayMs = 500,
301
336
  rpcIdPrefix = 'im',
302
337
  logPrefix = 'dsh-im',
338
+ commandExecutor,
303
339
  }) {
304
340
  if (typeof createWebSocket !== 'function') {
305
341
  throw new TypeError('createWebSocket must be a function');
@@ -313,6 +349,9 @@ export class HarnessClient {
313
349
  if (typeof logPrefix !== 'string' || !logPrefix.trim()) {
314
350
  throw new TypeError('logPrefix must be a non-empty string');
315
351
  }
352
+ if (commandExecutor !== undefined && typeof commandExecutor !== 'function') {
353
+ throw new TypeError('commandExecutor must be a function');
354
+ }
316
355
  this.#baseUrl = new URL(baseUrl);
317
356
  this.#workspace = workspace;
318
357
  this.#agentPreset = agentPreset;
@@ -323,6 +362,7 @@ export class HarnessClient {
323
362
  this.#interactionReconnectDelayMs = interactionReconnectDelayMs;
324
363
  this.#rpcIdPrefix = rpcIdPrefix.trim();
325
364
  this.#logPrefix = logPrefix.trim();
365
+ this.#commandExecutor = commandExecutor;
326
366
  this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
327
367
  this.#interactionOwnerships = this.#interactionRegistry.ownerships;
328
368
  this.#interactionClaims = this.#interactionRegistry.claims;
@@ -425,6 +465,24 @@ export class HarnessClient {
425
465
  return created.sessionId;
426
466
  }
427
467
 
468
+ async executeCommand(sessionId, line, options = {}) {
469
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
470
+ if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
471
+ if (!this.#commandExecutor) {
472
+ const error = new Error('Harness command execution is unavailable');
473
+ error.code = 'commands-unavailable';
474
+ throw error;
475
+ }
476
+ try {
477
+ return await this.#commandExecutor(sessionId, line, options);
478
+ } catch (error) {
479
+ if (error?.failure && typeof error.failure === 'object') {
480
+ throw new HarnessRpcError('commands.execute', error.failure);
481
+ }
482
+ throw error;
483
+ }
484
+ }
485
+
428
486
  async sessionExists(sessionId, options = {}) {
429
487
  try {
430
488
  await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
@@ -1,4 +1,5 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
+ import { runCompactCommand } from './compact-command.mjs';
2
3
  import { askInWorkspaceSession } from './workspace-session.mjs';
3
4
  import { HarnessApprovalQueue } from './harness-approval.mjs';
4
5
  import {
@@ -218,10 +219,11 @@ export class TextHarnessBridge {
218
219
  '',
219
220
  '直接发送文字即可继续当前会话。',
220
221
  '/new 开启一个全新会话',
222
+ '/compact 压缩当前会话的较早上下文',
221
223
  '/workspace 工作区绝对路径 切换工作区',
222
224
  '/workspacelist 列出工作区绝对路径',
223
225
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
224
- '/session Session ID 将当前聊天绑定到指定会话',
226
+ '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
225
227
  '/status 检查连接状态',
226
228
  '/help 显示本帮助',
227
229
  ].join('\n'));
@@ -244,6 +246,17 @@ export class TextHarnessBridge {
244
246
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
245
247
  return;
246
248
  }
249
+ const compactCommand = await runCompactCommand(
250
+ text,
251
+ this.#harness,
252
+ this.#state,
253
+ conversationKey,
254
+ { signal: this.#signal },
255
+ );
256
+ if (compactCommand) {
257
+ await this.#bot.sendText(target, compactCommand.message);
258
+ return;
259
+ }
247
260
 
248
261
  await this.#bot.sendTyping?.(target).catch((error) => {
249
262
  this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
@@ -13,7 +13,7 @@ const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
13
13
  const MAX_SESSION_ID_LENGTH = 256;
14
14
  const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
15
15
  const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
16
- const SESSION_BIND_USAGE = '用法:/session Session ID';
16
+ const SESSION_BIND_USAGE = '用法:/session Session ID 或当前工作区序号(/session N)';
17
17
  const SESSION_LIST_USAGE = [
18
18
  '用法:',
19
19
  '/sessionlist 列出当前工作区会话',
@@ -175,14 +175,35 @@ async function resolveSessionListWorkspace(selector, harness) {
175
175
  return selected;
176
176
  }
177
177
 
178
- function sessionListMessage(workspace, sessions) {
178
+ function formatSessionRelativeTime(value) {
179
+ const ms = typeof value === 'number' && Number.isFinite(value) ? value : null;
180
+ if (ms === null) return '';
181
+ const date = new Date(ms);
182
+ if (Number.isNaN(date.getTime())) return '';
183
+ const pad = (n) => String(n).padStart(2, '0');
184
+ const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
185
+ const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
186
+ const now = new Date();
187
+ const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
188
+ if (dayDiff === 0) return `今天 ${hm}`;
189
+ if (dayDiff === 1) return `昨天 ${hm}`;
190
+ if (dayDiff === 2) return `前天 ${hm}`;
191
+ if (date.getFullYear() === now.getFullYear()) {
192
+ return `${date.getMonth() + 1}月${date.getDate()}日 ${hm}`;
193
+ }
194
+ return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
195
+ }
196
+
197
+ function sessionListMessage(workspace, sessions, { currentWorkspace = false } = {}) {
179
198
  const rows = sessions.map((session) => {
180
199
  const sessionId = safeDisplayText(session?.sessionId);
181
200
  if (!sessionId) throw new TypeError('Harness returned an invalid session id');
182
201
  const title = session?.summaryAvailable === false
183
202
  ? '标题暂不可用'
184
203
  : safeDisplayText(session?.title) || '暂无标题';
185
- return `${title}${session?.archived === true ? '(已归档)' : ''}\n ID: ${sessionId}`;
204
+ const timeText = formatSessionRelativeTime(session?.time);
205
+ const annotation = `${timeText ? ` · ${timeText}` : ''}${session?.archived === true ? '(已归档)' : ''}`;
206
+ return `${title}${annotation}\n ID: ${sessionId}`;
186
207
  });
187
208
  if (rows.length === 0) return `工作区:${workspace}\n该工作区暂无会话。`;
188
209
  return [
@@ -191,10 +212,19 @@ function sessionListMessage(workspace, sessions) {
191
212
  '',
192
213
  ...rows.map((row, index) => `${index + 1}. ${row}`),
193
214
  '',
194
- '绑定用法:/session Session ID',
215
+ currentWorkspace
216
+ ? '绑定用法:/session Session ID 或当前工作区序号(/session N)'
217
+ : '绑定用法:/session Session ID\n提示:/session N 只按机器人当前工作区的序号绑定。',
195
218
  ].join('\n');
196
219
  }
197
220
 
221
+ async function currentSessionListWorkspace(harness) {
222
+ if (typeof harness?.currentWorkspace !== 'function') return null;
223
+ const [current] = await existingWorkspacePaths([harness.currentWorkspace()]);
224
+ harness.assertWorkspaceScope?.();
225
+ return current ?? null;
226
+ }
227
+
198
228
  async function runSessionListCommand(match, harness) {
199
229
  if (typeof harness?.listWorkspaceSessions !== 'function') {
200
230
  return commandResult('当前机器人暂不支持列出工作区会话。');
@@ -209,7 +239,10 @@ async function runSessionListCommand(match, harness) {
209
239
  }
210
240
  harness.assertWorkspaceScope?.();
211
241
  const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
212
- const message = sessionListMessage(workspace, listed.sessions);
242
+ const currentWorkspace = await currentSessionListWorkspace(harness);
243
+ const message = sessionListMessage(workspace, listed.sessions, {
244
+ currentWorkspace: workspace === currentWorkspace,
245
+ });
213
246
  return commandResult(message, splitWorkspaceCommandMessage(message));
214
247
  } catch (error) {
215
248
  if (error?.code === 'workspace-bot-not-found') {
@@ -247,7 +280,38 @@ function sessionBindErrorMessage(error) {
247
280
 
248
281
  async function runSessionBindCommand(command, harness, conversationKey) {
249
282
  const match = SESSION_BIND_COMMAND.exec(command);
250
- const sessionId = match?.[1];
283
+ let sessionId = match?.[1];
284
+ if (typeof sessionId === 'string' && /^\d+$/u.test(sessionId)) {
285
+ // 序号模式:把 /session N 解析成当前工作区会话列表中的第 N 个会话
286
+ if (typeof harness?.listWorkspaceSessions !== 'function'
287
+ || typeof harness?.currentWorkspace !== 'function') {
288
+ return commandResult('当前机器人暂不支持按序号绑定,请使用 /session Session ID。');
289
+ }
290
+ try {
291
+ const selected = await selectedWorkspacePath(harness.currentWorkspace());
292
+ if (selected.error) return commandResult(selected.error);
293
+ const listed = await harness.listWorkspaceSessions(selected.workspace);
294
+ if (!listed || !Array.isArray(listed.sessions)) {
295
+ throw new TypeError('Harness returned an invalid workspace session list');
296
+ }
297
+ harness.assertWorkspaceScope?.();
298
+ const position = Number(sessionId);
299
+ if (!Number.isSafeInteger(position) || position < 1
300
+ || position > listed.sessions.length) {
301
+ return commandResult('会话序号不存在,请先执行 /sessionlist 查看序号。');
302
+ }
303
+ const selectedSessionId = listed.sessions[position - 1]?.sessionId;
304
+ if (!validSessionId(selectedSessionId)) {
305
+ throw new TypeError('Harness returned an invalid session id');
306
+ }
307
+ sessionId = selectedSessionId;
308
+ } catch (error) {
309
+ if (error?.code === 'workspace-bot-not-found') {
310
+ return commandResult(sessionBindErrorMessage(error));
311
+ }
312
+ return commandResult('暂时无法获取会话列表,请稍后重试。');
313
+ }
314
+ }
251
315
  if (!validSessionId(sessionId)) return commandResult(SESSION_BIND_USAGE);
252
316
  if (typeof harness?.bindWorkspaceSession !== 'function') {
253
317
  return commandResult('当前机器人暂不支持绑定已有会话。');
@@ -5,6 +5,7 @@ import {
5
5
  validHarnessQuestion,
6
6
  } from '../shared/harness-question.mjs';
7
7
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
8
+ import { runCompactCommand } from '../shared/compact-command.mjs';
8
9
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
9
10
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
10
11
 
@@ -13,10 +14,11 @@ const HELP_TEXT = [
13
14
  '',
14
15
  '直接发送文字即可继续当前会话。',
15
16
  '/new 开启一个全新会话',
17
+ '/compact 压缩当前会话的较早上下文',
16
18
  '/workspace 工作区绝对路径 切换工作区',
17
19
  '/workspacelist 列出工作区绝对路径',
18
20
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
19
- '/session Session ID 将当前聊天绑定到指定会话',
21
+ '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
20
22
  '/status 检查连接状态',
21
23
  '/help 显示本帮助',
22
24
  ].join('\n');
@@ -321,6 +323,18 @@ export class WecomHarnessBridge {
321
323
  await this.#state.markSeen(messageId);
322
324
  return;
323
325
  }
326
+ const compactCommand = await runCompactCommand(
327
+ text,
328
+ this.#harness,
329
+ this.#state,
330
+ key,
331
+ { signal: this.#signal },
332
+ );
333
+ if (compactCommand) {
334
+ await this.#sendImmediate(frame, chatId, compactCommand.message);
335
+ await this.#state.markSeen(messageId);
336
+ return;
337
+ }
324
338
 
325
339
  streamId = this.#generateReqId('stream');
326
340
  try {
@@ -92,7 +92,7 @@ function authenticatedHeaders(token) {
92
92
  function baseInfo() {
93
93
  return {
94
94
  channel_version: WEIXIN_PROTOCOL_VERSION,
95
- bot_agent: 'DeepSeekHarness/0.7.2',
95
+ bot_agent: 'DeepSeekHarness/0.9.0',
96
96
  };
97
97
  }
98
98
 
@@ -9,6 +9,7 @@ import {
9
9
  validHarnessQuestion,
10
10
  } from '../shared/harness-question.mjs';
11
11
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
12
+ import { runCompactCommand } from '../shared/compact-command.mjs';
12
13
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
13
14
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
14
15
 
@@ -19,10 +20,11 @@ const HELP_TEXT = [
19
20
  '',
20
21
  '直接发送文字或带文字识别结果的语音即可继续当前会话。',
21
22
  '/new 开启一个全新会话',
23
+ '/compact 压缩当前会话的较早上下文',
22
24
  '/workspace 工作区绝对路径 切换工作区',
23
25
  '/workspacelist 列出工作区绝对路径',
24
26
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
25
- '/session Session ID 将当前聊天绑定到指定会话',
27
+ '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
26
28
  '/status 检查连接状态',
27
29
  '/help 显示本帮助',
28
30
  ].join('\n');
@@ -248,6 +250,18 @@ export class WeixinHarnessBridge {
248
250
  await this.#state.markSeen(messageId);
249
251
  return;
250
252
  }
253
+ const compactCommand = await runCompactCommand(
254
+ text,
255
+ this.#harness,
256
+ this.#state,
257
+ key,
258
+ { signal: this.#signal },
259
+ );
260
+ if (compactCommand) {
261
+ await this.#send(sender, compactCommand.message, contextToken, runId);
262
+ await this.#state.markSeen(messageId);
263
+ return;
264
+ }
251
265
 
252
266
  let answer;
253
267
  try {