@xmanrui/dsh-im 3.0.8 → 3.1.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.
@@ -0,0 +1,193 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ import { t } from './i18n.mjs';
4
+ import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
5
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
6
+
7
+ const HISTORY_COMMAND = /^\/history(?=$|\s)([\s\S]*)$/iu;
8
+ const HISTORY_USAGE = '用法:/history [数量](默认 3 条,最多 5 条)';
9
+ const MAX_MESSAGES = 5;
10
+ const PAGE_SIZE = 50;
11
+ const MAX_PAGES = 3;
12
+ const READ_TIMEOUT_MS = 10_000;
13
+
14
+ function commandResult(message) {
15
+ return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
16
+ }
17
+
18
+ function previewText(content) {
19
+ if (!Array.isArray(content)) throw new TypeError('Invalid history message content');
20
+ return content.flatMap((block) => {
21
+ if (block?.type === 'text' && typeof block.text === 'string') return [block.text];
22
+ if (block?.type === 'image') return [t('[图片]')];
23
+ if (block?.type === 'file') return [t('[文件]')];
24
+ return [];
25
+ }).join('\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').trim()
26
+ || t('本条没有可预览的文字。');
27
+ }
28
+
29
+ function visibleMessages(events) {
30
+ const messages = [];
31
+ const assistants = new Map();
32
+ for (const event of events) {
33
+ const data = event.data;
34
+ if (event.type === 'user/message' && event.surfaceOp === 'append'
35
+ && data?.source?.kind === 'user') {
36
+ // Older sessions may contain commands saved before the local fast path
37
+ // existed. Exclude them before counting, so normal messages fill the limit.
38
+ if (isHistoryCommand(previewText(data.content))) continue;
39
+ messages.push({ seq: event.seq, role: 'user', content: data.content });
40
+ } else if (event.type === 'assistant/message' && event.surfaceOp === 'append'
41
+ && Number.isSafeInteger(data?.turn) && data.turn >= 0) {
42
+ // Remember even an empty/interrupted final message: never substitute an
43
+ // earlier tool-step explanation for the turn's final answer.
44
+ assistants.set(data.turn, event);
45
+ } else if (event.type === 'turn/end') {
46
+ const assistant = assistants.get(data?.turn);
47
+ assistants.delete(data?.turn);
48
+ if ((data?.reason?.kind ?? data?.reason) !== 'completed'
49
+ || !assistant || assistant.data.interrupted === true) continue;
50
+ messages.push({
51
+ seq: assistant.seq,
52
+ role: 'assistant',
53
+ content: assistant.data.message?.content,
54
+ });
55
+ }
56
+ }
57
+ return messages.sort((left, right) => left.seq - right.seq);
58
+ }
59
+
60
+ function truncateText(text, limit) {
61
+ if (text.length <= limit) return text;
62
+ const suffix = t('(已截断)');
63
+ let end = Math.max(0, limit - suffix.length);
64
+ const last = text.charCodeAt(end - 1);
65
+ if (last >= 0xd800 && last <= 0xdbff) end -= 1;
66
+ return `${text.slice(0, Math.max(0, end))}${suffix}`;
67
+ }
68
+
69
+ function formatHistory(sessionId, records, requested, hasMore) {
70
+ const shortId = sessionId.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 12);
71
+ const header = t('会话历史|{session}|最近 {count} 条', { session: shortId, count: records.length });
72
+ const footer = [t('以上为历史记录,不是本次新回复。')];
73
+ if (records.length < requested) {
74
+ footer.unshift(hasMore
75
+ ? t('本次有限读取中仅找到 {count} 条可预览消息。', { count: records.length })
76
+ : t('当前会话仅有 {count} 条可预览消息。', { count: records.length }));
77
+ }
78
+ const entries = records.map((record) => ({ ...record, text: previewText(record.content) }));
79
+ let bodyLimit = 500;
80
+ let result;
81
+ do {
82
+ const sections = entries.map((entry, index) => (
83
+ `${index + 1}. ${entry.role === 'user' ? t('用户') : t('助手')}\n${truncateText(entry.text, bodyLimit)}`
84
+ ));
85
+ result = commandResult([header, ...sections, footer.join('\n')].join('\n\n'));
86
+ bodyLimit = Math.floor(bodyLimit / 2);
87
+ } while (bodyLimit > 0 && (result.message.length > 3_000 || result.messages.length > 3));
88
+ return result;
89
+ }
90
+
91
+ function historyErrorMessage(error) {
92
+ const code = error?.code ?? error?.failure?.code;
93
+ if (code === 'session-not-found') return t('当前聊天绑定的会话已不存在,请重新绑定会话。');
94
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found'
95
+ || code === 'session-binding-changed') return t('会话、工作区或机器人状态已发生变化,请重新执行 /history。');
96
+ if (code === 'harness-api-not-found') return t('当前 Harness 暂不支持读取会话历史。');
97
+ if (error?.name === 'AbortError') return t('历史读取已取消。');
98
+ if (error?.name === 'TimeoutError' || code === 'harness-timeout') return t('读取历史超时,请稍后重试。');
99
+ return t('暂时无法读取会话历史,请稍后重试。');
100
+ }
101
+
102
+ export function isHistoryCommand(text) {
103
+ return typeof text === 'string' && HISTORY_COMMAND.test(text.trim());
104
+ }
105
+
106
+ /** Read the current binding only; never create/resume a session or prompt the model. */
107
+ export async function runHistoryCommand(text, harness, state, key, {
108
+ signal: callerSignal,
109
+ isDirect = false,
110
+ hasImages = false,
111
+ hasFiles = false,
112
+ } = {}) {
113
+ if (!isHistoryCommand(text)) return null;
114
+ const argument = HISTORY_COMMAND.exec(text.trim())[1].trim();
115
+ if (argument && (!/^\d+$/u.test(argument) || /^0+$/u.test(argument))) {
116
+ return commandResult(t(HISTORY_USAGE));
117
+ }
118
+ const count = argument ? Math.min(Number(argument), MAX_MESSAGES) : 3;
119
+ if (!isDirect) return commandResult(t('请在与机器人的私聊中使用 /history。'));
120
+ if (hasImages || hasFiles) return commandResult(t('/history 仅支持文字命令,请移除图片或文件后重试。'));
121
+ const sessionId = state?.sessionFor?.(key);
122
+ if (typeof sessionId !== 'string' || !sessionId) {
123
+ return commandResult(t('当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。'));
124
+ }
125
+
126
+ try {
127
+ const session = harness?.workspaceSession?.(sessionId);
128
+ if (typeof session?.readHistory !== 'function') {
129
+ return commandResult(t('当前 Harness 暂不支持读取会话历史。'));
130
+ }
131
+ const timeout = AbortSignal.timeout(READ_TIMEOUT_MS);
132
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
133
+ const deadline = Date.now() + READ_TIMEOUT_MS;
134
+ const events = new Map();
135
+ let beforeSeq;
136
+ let snapshotEnd;
137
+ let records = [];
138
+ let hasMore = false;
139
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
140
+ signal.throwIfAborted();
141
+ if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
142
+ const page = await session.readHistory({
143
+ maxMessages: PAGE_SIZE,
144
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
145
+ timeoutMs: Math.max(1, deadline - Date.now()),
146
+ signal,
147
+ });
148
+ signal.throwIfAborted();
149
+ if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
150
+ if (state.sessionFor(key) !== sessionId) {
151
+ const error = new Error('Session binding changed during history read');
152
+ error.code = 'session-binding-changed';
153
+ throw error;
154
+ }
155
+ if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') {
156
+ throw new TypeError('Invalid history page');
157
+ }
158
+ let oldestSeq = beforeSeq ?? Infinity;
159
+ for (const entry of page.events) {
160
+ const event = entry?.event;
161
+ if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0
162
+ || typeof event.type !== 'string' || !event.type) throw new TypeError('Invalid history event');
163
+ // Subsequent pages must not extend the snapshot taken by the first read.
164
+ if (snapshotEnd !== undefined && event.seq > snapshotEnd) continue;
165
+ const previous = events.get(event.seq);
166
+ if (previous) {
167
+ if (!isDeepStrictEqual(previous, event)) throw new TypeError('Conflicting history events');
168
+ } else {
169
+ if (beforeSeq !== undefined && event.seq >= beforeSeq) throw new TypeError('Invalid history cursor');
170
+ events.set(event.seq, event);
171
+ }
172
+ oldestSeq = Math.min(oldestSeq, event.seq);
173
+ }
174
+ const ordered = [...events.values()].sort((left, right) => left.seq - right.seq);
175
+ snapshotEnd ??= ordered.at(-1)?.seq;
176
+ hasMore = page.hasMore;
177
+ if (hasMore && (!Number.isFinite(oldestSeq) || oldestSeq === beforeSeq)) {
178
+ throw new TypeError('History cursor did not advance');
179
+ }
180
+ records = visibleMessages(ordered).slice(-count);
181
+ if (records.length >= count || !hasMore) break;
182
+ beforeSeq = oldestSeq;
183
+ }
184
+ if (records.length === 0) {
185
+ return commandResult(hasMore
186
+ ? t('本次有限读取中未找到可预览的历史消息。')
187
+ : t('当前会话暂无可预览的历史消息。'));
188
+ }
189
+ return formatHistory(sessionId, records, count, hasMore);
190
+ } catch (error) {
191
+ return commandResult(historyErrorMessage(error));
192
+ }
193
+ }
@@ -75,6 +75,7 @@ export default {
75
75
  '当前消息缺少可绑定的会话上下文。':
76
76
  'The current message lacks a conversation context to bind to.',
77
77
  '当前聊天已绑定会话:': 'This chat is now bound to the Session:',
78
+ '发送 /history 查看最近对话。': 'Send /history to preview recent conversation messages.',
78
79
  '标题:{title}': 'Title: {title}',
79
80
  '归档:{archived}': 'Archived: {archived}',
80
81
  '是': 'Yes',
@@ -1,5 +1,45 @@
1
1
  // English translations (shared-c area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ // history-command.mjs / command help
4
+ '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)':
5
+ '/history [count] Preview recent messages (default 3, maximum 5)',
6
+ '用法:/history [数量](默认 3 条,最多 5 条)':
7
+ 'Usage: /history [count] (default 3, maximum 5)',
8
+ '[图片]': '[Image]',
9
+ '[文件]': '[File]',
10
+ '本条没有可预览的文字。': 'This message has no text to preview.',
11
+ '(已截断)': ' (truncated)',
12
+ '会话历史|{session}|最近 {count} 条':
13
+ 'Session history | {session} | Recent messages: {count}',
14
+ '以上为历史记录,不是本次新回复。':
15
+ 'These are history records, not a new reply.',
16
+ '本次有限读取中仅找到 {count} 条可预览消息。':
17
+ 'Messages available to preview within this bounded read: {count}.',
18
+ '当前会话仅有 {count} 条可预览消息。':
19
+ 'Messages available to preview in this Session: {count}.',
20
+ '用户': 'User',
21
+ '助手': 'Assistant',
22
+ '当前聊天绑定的会话已不存在,请重新绑定会话。':
23
+ 'The Session bound to this chat no longer exists. Please bind a Session again.',
24
+ '会话、工作区或机器人状态已发生变化,请重新执行 /history。':
25
+ 'The Session, workspace, or bot state has changed. Please run /history again.',
26
+ '当前 Harness 暂不支持读取会话历史。':
27
+ 'This Harness does not support reading Session history.',
28
+ '历史读取已取消。': 'History reading was cancelled.',
29
+ '读取历史超时,请稍后重试。': 'Reading history timed out. Please try again later.',
30
+ '暂时无法读取会话历史,请稍后重试。':
31
+ 'Unable to read Session history right now. Please try again later.',
32
+ '请在与机器人的私聊中使用 /history。':
33
+ 'Please use /history in a direct chat with the bot.',
34
+ '/history 仅支持文字命令,请移除图片或文件后重试。':
35
+ '/history supports text commands only. Remove images or files and try again.',
36
+ '当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。':
37
+ 'This chat has no bound Session. Send a message or use /session to bind one first.',
38
+ '本次有限读取中未找到可预览的历史消息。':
39
+ 'No history messages were available to preview within this bounded read.',
40
+ '当前会话暂无可预览的历史消息。':
41
+ 'This Session has no history messages available to preview yet.',
42
+
3
43
  // harness-approval.mjs
4
44
  '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。':
5
45
  'Please reply exactly with 「批准」 (approve) or 「拒绝」 (reject). Also accepted: 同意 / 不同意 / yes / no.',
@@ -1,6 +1,7 @@
1
1
  import { t } from './i18n.mjs';
2
2
  import { runWorkspaceCommand } from './workspace-command.mjs';
3
3
  import { runCompactCommand } from './compact-command.mjs';
4
+ import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
4
5
  import {
5
6
  isControlCommand,
6
7
  runControlCommand,
@@ -260,7 +261,8 @@ export class TextHarnessBridge {
260
261
  }
261
262
  const collectingBatch = normalized.kind === 'direct'
262
263
  && this.#batches.status(key).phase === 'collecting';
263
- const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
264
+ const commandRunner = collectingBatch ? null : isHistoryCommand(text) ? runHistoryCommand
265
+ : hasInboundFiles(normalized) ? null : isControlCommand(text)
264
266
  ? runControlCommand
265
267
  : (isModelCommand(text)
266
268
  ? runModelCommand
@@ -417,6 +419,7 @@ export class TextHarnessBridge {
417
419
  key,
418
420
  {
419
421
  signal: this.#signal,
422
+ isDirect: message.kind === 'direct',
420
423
  hasImages: hasInboundImages(message),
421
424
  hasFiles: hasInboundFiles(message),
422
425
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -529,6 +532,7 @@ export class TextHarnessBridge {
529
532
  t('直接发送文字、图片或文件即可继续当前会话。'),
530
533
  t('/new 开启一个全新会话'),
531
534
  t('/compact 压缩当前会话的较早上下文'),
535
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
532
536
  t('/workspace 工作区绝对路径 切换工作区'),
533
537
  t('/workspacelist 列出工作区绝对路径'),
534
538
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -344,6 +344,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
344
344
  t('标题:{title}', { title }),
345
345
  `ID:${boundSessionId}`,
346
346
  t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }),
347
+ t('发送 /history 查看最近对话。'),
347
348
  ].join('\n');
348
349
  return commandResult(message, splitWorkspaceCommandMessage(message));
349
350
  } catch (error) {
@@ -12,6 +12,7 @@ import {
12
12
  isBatchInputCommand,
13
13
  } from '../shared/batch-input.mjs';
14
14
  import { runCompactCommand } from '../shared/compact-command.mjs';
15
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
15
16
  import {
16
17
  isControlCommand,
17
18
  runControlCommand,
@@ -61,6 +62,7 @@ function helpText() {
61
62
  t('直接发送文字、图片或文件即可继续当前会话。'),
62
63
  t('/new 开启一个全新会话'),
63
64
  t('/compact 压缩当前会话的较早上下文'),
65
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
64
66
  t('/workspace 工作区绝对路径 切换工作区'),
65
67
  t('/workspacelist 列出工作区绝对路径'),
66
68
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -591,7 +593,8 @@ export class WecomHarnessBridge {
591
593
  return this.#finishBatchResult(frame, messageId, chatId, result);
592
594
  }
593
595
  }
594
- const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
596
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
597
+ : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
595
598
  ? runControlCommand
596
599
  : (isModelCommand(commandText)
597
600
  ? runModelCommand
@@ -768,6 +771,7 @@ export class WecomHarnessBridge {
768
771
  this.#status.lastMessageAt = new Date().toISOString();
769
772
  const result = await runner(message.content, this.#harness, this.#state, key, {
770
773
  signal: this.#signal,
774
+ isDirect: bodyOf(frame).chattype === 'single',
771
775
  hasImages: hasInboundImages(message),
772
776
  hasFiles: hasInboundFiles(message),
773
777
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -18,6 +18,7 @@ import {
18
18
  isBatchInputCommand,
19
19
  } from '../shared/batch-input.mjs';
20
20
  import { runCompactCommand } from '../shared/compact-command.mjs';
21
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
21
22
  import {
22
23
  isControlCommand,
23
24
  runControlCommand,
@@ -68,6 +69,7 @@ const HELP_TEXT = () => [
68
69
  t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
69
70
  t('/new 开启一个全新会话'),
70
71
  t('/compact 压缩当前会话的较早上下文'),
72
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
71
73
  t('/workspace 工作区绝对路径 切换工作区'),
72
74
  t('/workspacelist 列出工作区绝对路径'),
73
75
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -364,7 +366,8 @@ export class WeixinHarnessBridge {
364
366
  );
365
367
  }
366
368
  }
367
- const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
369
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
370
+ : hasWeixinFileItems(message) ? null : isControlCommand(commandText)
368
371
  ? runControlCommand
369
372
  : (isModelCommand(commandText)
370
373
  ? runModelCommand
@@ -541,6 +544,7 @@ export class WeixinHarnessBridge {
541
544
  this.#status.lastMessageAt = new Date().toISOString();
542
545
  const result = await runner(text, this.#harness, this.#state, key, {
543
546
  signal: this.#signal,
547
+ isDirect: true,
544
548
  hasImages: hasWeixinImageItems(message),
545
549
  hasFiles: hasWeixinFileItems(message),
546
550
  pendingInteraction: this.#pendingInteractions.has(key)