@xmanrui/dsh-im 0.6.0 → 0.7.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.
@@ -1,10 +1,25 @@
1
- import { stat } from 'node:fs/promises';
1
+ import { realpath, stat } from 'node:fs/promises';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
 
4
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
5
+
4
6
  const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
5
7
  const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
8
+ const SESSION_LIST_COMMAND = /^\/sessionlist(?:\s+([\s\S]+))?$/i;
9
+ const SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
10
+ const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
6
11
  const MAX_WORKSPACE_PATH_LENGTH = 4_096;
7
12
  const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
13
+ const MAX_SESSION_ID_LENGTH = 256;
14
+ const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
15
+ const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
16
+ const SESSION_BIND_USAGE = '用法:/session Session ID';
17
+ const SESSION_LIST_USAGE = [
18
+ '用法:',
19
+ '/sessionlist 列出当前工作区会话',
20
+ '/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
21
+ '/sessionlist 工作区绝对路径 列出指定工作区会话',
22
+ ].join('\n');
8
23
 
9
24
  function commandResult(message, messages = [message]) {
10
25
  return { handled: true, message, messages };
@@ -12,20 +27,74 @@ function commandResult(message, messages = [message]) {
12
27
 
13
28
  function normalizedWorkspacePath(value) {
14
29
  if (typeof value !== 'string' || value.length > MAX_WORKSPACE_PATH_LENGTH
15
- || !isAbsolute(value) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return null;
30
+ || !isAbsolute(value) || UNSAFE_DISPLAY_TEXT.test(value)) return null;
16
31
  return resolve(value);
17
32
  }
18
33
 
34
+ function safeDisplayText(value) {
35
+ if (typeof value !== 'string') return '';
36
+ return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
37
+ }
38
+
39
+ function validSessionId(value) {
40
+ return typeof value === 'string'
41
+ && value.length > 0
42
+ && value.length <= MAX_SESSION_ID_LENGTH
43
+ && !/\p{White_Space}/u.test(value)
44
+ && !UNSAFE_DISPLAY_TEXT.test(value);
45
+ }
46
+
19
47
  async function existingWorkspacePaths(values) {
20
- const unique = [...new Set(values.map(normalizedWorkspacePath).filter(Boolean))];
21
- const checked = await Promise.all(unique.map(async (workspace) => {
48
+ const checked = await Promise.all(values.map(async (value) => {
49
+ const workspace = normalizedWorkspacePath(value);
50
+ if (!workspace) return null;
22
51
  try {
23
- return (await stat(workspace)).isDirectory() ? workspace : null;
52
+ if (!(await stat(workspace)).isDirectory()) return null;
53
+ return normalizedWorkspacePath(await realpath(workspace));
24
54
  } catch {
25
55
  return null;
26
56
  }
27
57
  }));
28
- return checked.filter(Boolean);
58
+ return [...new Set(checked.filter(Boolean))];
59
+ }
60
+
61
+ async function selectedWorkspacePath(value) {
62
+ if (typeof value !== 'string' || !isAbsolute(value.trim())) {
63
+ return { error: `工作区必须是绝对路径。\n${SESSION_LIST_USAGE}` };
64
+ }
65
+ const workspace = normalizedWorkspacePath(value.trim());
66
+ if (!workspace) {
67
+ return { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
68
+ }
69
+ let info;
70
+ try {
71
+ info = await stat(workspace);
72
+ } catch {
73
+ return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
74
+ }
75
+ if (!info.isDirectory()) {
76
+ return { error: `工作区路径必须指向一个目录。\n${SESSION_LIST_USAGE}` };
77
+ }
78
+ try {
79
+ const canonical = normalizedWorkspacePath(await realpath(workspace));
80
+ return canonical
81
+ ? { workspace: canonical }
82
+ : { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` };
83
+ } catch {
84
+ return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` };
85
+ }
86
+ }
87
+
88
+ async function workspacePathSnapshot(harness) {
89
+ const listed = await harness.listWorkspaces();
90
+ const currentValue = typeof harness?.currentWorkspace === 'function'
91
+ ? harness.currentWorkspace()
92
+ : null;
93
+ const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
94
+ const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
95
+ const paths = [...new Set([...(current ? [current] : []), ...registered])];
96
+ harness.assertWorkspaceScope?.();
97
+ return { current: current ?? null, paths };
29
98
  }
30
99
 
31
100
  export function splitWorkspaceCommandMessage(message) {
@@ -56,15 +125,7 @@ async function runWorkspaceListCommand(match, harness) {
56
125
  return commandResult('当前机器人暂不支持列出工作区。');
57
126
  }
58
127
  try {
59
- const listed = await harness.listWorkspaces();
60
- const current = typeof harness.currentWorkspace === 'function'
61
- ? normalizedWorkspacePath(harness.currentWorkspace())
62
- : null;
63
- const paths = await existingWorkspacePaths([
64
- ...(current ? [current] : []),
65
- ...(Array.isArray(listed) ? listed : []),
66
- ]);
67
- harness.assertWorkspaceScope?.();
128
+ const { current, paths } = await workspacePathSnapshot(harness);
68
129
  if (paths.length === 0) {
69
130
  return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
70
131
  }
@@ -75,6 +136,7 @@ async function runWorkspaceListCommand(match, harness) {
75
136
  )),
76
137
  '',
77
138
  '切换用法:/workspace 工作区绝对路径',
139
+ '查看会话:/sessionlist 工作区序号或绝对路径',
78
140
  ];
79
141
  const message = lines.join('\n');
80
142
  return commandResult(message, splitWorkspaceCommandMessage(message));
@@ -86,9 +148,143 @@ async function runWorkspaceListCommand(match, harness) {
86
148
  }
87
149
  }
88
150
 
89
- export async function runWorkspaceCommand(text, harness) {
151
+ async function resolveSessionListWorkspace(selector, harness) {
152
+ if (!selector) {
153
+ if (typeof harness?.currentWorkspace !== 'function') {
154
+ return { error: '当前机器人没有可用的工作区。' };
155
+ }
156
+ const selected = await selectedWorkspacePath(harness.currentWorkspace());
157
+ harness.assertWorkspaceScope?.();
158
+ return selected;
159
+ }
160
+
161
+ if (/^\d+$/u.test(selector)) {
162
+ if (typeof harness?.listWorkspaces !== 'function') {
163
+ return { error: '当前机器人暂不支持按序号选择工作区。' };
164
+ }
165
+ const { paths } = await workspacePathSnapshot(harness);
166
+ const position = Number(selector);
167
+ if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
168
+ return { error: '工作区序号不存在,请先执行 /workspacelist。' };
169
+ }
170
+ return { workspace: paths[position - 1] };
171
+ }
172
+
173
+ const selected = await selectedWorkspacePath(selector);
174
+ harness.assertWorkspaceScope?.();
175
+ return selected;
176
+ }
177
+
178
+ function sessionListMessage(workspace, sessions) {
179
+ const rows = sessions.map((session) => {
180
+ const sessionId = safeDisplayText(session?.sessionId);
181
+ if (!sessionId) throw new TypeError('Harness returned an invalid session id');
182
+ const title = session?.summaryAvailable === false
183
+ ? '标题暂不可用'
184
+ : safeDisplayText(session?.title) || '暂无标题';
185
+ return `${title}${session?.archived === true ? '(已归档)' : ''}\n ID: ${sessionId}`;
186
+ });
187
+ if (rows.length === 0) return `工作区:${workspace}\n该工作区暂无会话。`;
188
+ return [
189
+ `工作区:${workspace}`,
190
+ `会话(${rows.length}):`,
191
+ '',
192
+ ...rows.map((row, index) => `${index + 1}. ${row}`),
193
+ '',
194
+ '绑定用法:/session Session ID',
195
+ ].join('\n');
196
+ }
197
+
198
+ async function runSessionListCommand(match, harness) {
199
+ if (typeof harness?.listWorkspaceSessions !== 'function') {
200
+ return commandResult('当前机器人暂不支持列出工作区会话。');
201
+ }
202
+ const selector = match[1]?.trim() ?? '';
203
+ try {
204
+ const resolved = await resolveSessionListWorkspace(selector, harness);
205
+ if (resolved.error) return commandResult(resolved.error);
206
+ const listed = await harness.listWorkspaceSessions(resolved.workspace);
207
+ if (!listed || !Array.isArray(listed.sessions)) {
208
+ throw new TypeError('Harness returned an invalid workspace session list');
209
+ }
210
+ harness.assertWorkspaceScope?.();
211
+ const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
212
+ const message = sessionListMessage(workspace, listed.sessions);
213
+ return commandResult(message, splitWorkspaceCommandMessage(message));
214
+ } catch (error) {
215
+ if (error?.code === 'workspace-bot-not-found') {
216
+ return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区会话。');
217
+ }
218
+ return commandResult('暂时无法获取工作区会话列表,请稍后重试。');
219
+ }
220
+ }
221
+
222
+ function sessionBindErrorMessage(error) {
223
+ if (error?.code === 'session-id-invalid') {
224
+ return `Session ID 格式无效。\n${SESSION_BIND_USAGE}`;
225
+ }
226
+ if (['session-not-registered', 'session-not-found'].includes(error?.code)) {
227
+ return '未找到该会话,请先执行 /sessionlist 确认 Session ID。';
228
+ }
229
+ if (error?.code === 'session-subagent-unsupported') {
230
+ return '子代理会话不能绑定到机器人对话,请选择普通会话。';
231
+ }
232
+ if (error?.code === 'session-workspace-ambiguous') {
233
+ return '该会话的工作区归属不明确,暂时无法绑定。';
234
+ }
235
+ if (error?.code === 'session-summary-unavailable') {
236
+ return '暂时无法读取该会话的信息,请稍后重试。';
237
+ }
238
+ if (error?.code === 'workspace-bot-not-found') {
239
+ return '机器人正在移除或已重新接入,无法绑定原对话的会话。';
240
+ }
241
+ if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict']
242
+ .includes(error?.code)) {
243
+ return '工作区或会话状态已发生变化,请重试。';
244
+ }
245
+ return '暂时无法绑定会话,请稍后重试。';
246
+ }
247
+
248
+ async function runSessionBindCommand(command, harness, conversationKey) {
249
+ const match = SESSION_BIND_COMMAND.exec(command);
250
+ const sessionId = match?.[1];
251
+ if (!validSessionId(sessionId)) return commandResult(SESSION_BIND_USAGE);
252
+ if (typeof harness?.bindWorkspaceSession !== 'function') {
253
+ return commandResult('当前机器人暂不支持绑定已有会话。');
254
+ }
255
+ if (typeof conversationKey !== 'string' || !conversationKey) {
256
+ return commandResult('当前消息缺少可绑定的会话上下文。');
257
+ }
258
+ try {
259
+ const bound = await harness.bindWorkspaceSession(conversationKey, sessionId);
260
+ harness.assertWorkspaceScope?.();
261
+ const workspace = normalizedWorkspacePath(bound?.workspace);
262
+ const boundSessionId = safeDisplayText(bound?.sessionId);
263
+ if (!workspace || !boundSessionId) {
264
+ throw new TypeError('Harness returned an invalid bound session');
265
+ }
266
+ const title = safeDisplayText(bound?.title) || '暂无标题';
267
+ const message = [
268
+ '当前聊天已绑定会话:',
269
+ `工作区:${workspace}`,
270
+ `标题:${title}`,
271
+ `ID:${boundSessionId}`,
272
+ `归档:${bound?.archived === true ? '是' : '否'}`,
273
+ ].join('\n');
274
+ return commandResult(message, splitWorkspaceCommandMessage(message));
275
+ } catch (error) {
276
+ return commandResult(sessionBindErrorMessage(error));
277
+ }
278
+ }
279
+
280
+ export async function runWorkspaceCommand(text, harness, conversationKey) {
90
281
  if (typeof text !== 'string') return null;
91
282
  const command = text.trim();
283
+ if (SESSION_BIND_PREFIX.test(command)) {
284
+ return runSessionBindCommand(command, harness, conversationKey);
285
+ }
286
+ const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
287
+ if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
92
288
  const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
93
289
  if (listMatch) return runWorkspaceListCommand(listMatch, harness);
94
290
  const match = WORKSPACE_COMMAND.exec(command);
@@ -1,9 +1,20 @@
1
1
  export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
2
2
 
3
- async function sessionExists(harness, sessionId, options) {
3
+ function workspaceSession(harness, sessionId) {
4
+ if (typeof harness.workspaceSession === 'function') {
5
+ return harness.workspaceSession(sessionId);
6
+ }
7
+ return Object.freeze({
8
+ sessionId,
9
+ sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
10
+ ask: (...args) => harness.ask(sessionId, ...args),
11
+ });
12
+ }
13
+
14
+ async function sessionExists(session, options) {
4
15
  return options === undefined
5
- ? harness.sessionExists(sessionId)
6
- : harness.sessionExists(sessionId, options);
16
+ ? session.sessionExists()
17
+ : session.sessionExists(options);
7
18
  }
8
19
 
9
20
  async function createSession(harness, options) {
@@ -27,15 +38,17 @@ export async function askInWorkspaceSession({
27
38
  askOptions,
28
39
  }) {
29
40
  while (true) {
30
- let sessionId = state.sessionFor(key);
31
- if (!sessionId || !(await sessionExists(harness, sessionId, existsOptions))) {
32
- sessionId = await createSession(harness, createOptions);
33
- if (await state.setSession(key, sessionId) === false) continue;
34
- }
35
41
  try {
42
+ let sessionId = state.sessionFor(key);
43
+ let session = sessionId ? workspaceSession(harness, sessionId) : null;
44
+ if (!session || !(await sessionExists(session, existsOptions))) {
45
+ sessionId = await createSession(harness, createOptions);
46
+ if (await state.setSession(key, sessionId) === false) continue;
47
+ session = workspaceSession(harness, sessionId);
48
+ }
36
49
  return {
37
50
  sessionId,
38
- answer: await harness.ask(sessionId, text, askOptions),
51
+ answer: await session.ask(text, askOptions),
39
52
  };
40
53
  } catch (error) {
41
54
  if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
@@ -9,6 +9,8 @@ const HELP_TEXT = [
9
9
  '/new 开启一个全新会话',
10
10
  '/workspace 工作区绝对路径 切换工作区',
11
11
  '/workspacelist 列出工作区绝对路径',
12
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
13
+ '/session Session ID 将当前聊天绑定到指定会话',
12
14
  '/status 检查连接状态',
13
15
  '/help 显示本帮助',
14
16
  ].join('\n');
@@ -186,7 +188,7 @@ export class WecomHarnessBridge {
186
188
  await this.#state.markSeen(messageId);
187
189
  return;
188
190
  }
189
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
191
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
190
192
  if (workspaceCommand) {
191
193
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
192
194
  await this.#sendImmediate(frame, chatId, reply);
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
4
 
5
+ import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
6
+
5
7
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
8
 
7
9
  function workspacePaths(value) {
@@ -11,6 +13,47 @@ function workspacePaths(value) {
11
13
  ));
12
14
  }
13
15
 
16
+ function workspaceFromList(workspacePath, workspaceList) {
17
+ if (!Array.isArray(workspaceList?.items)
18
+ || !Array.isArray(workspaceList?.archivedSessionIds)) {
19
+ throw new Error('Harness returned an invalid response for workspace.list');
20
+ }
21
+
22
+ const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
23
+ if (!workspace) return null;
24
+ if (!Array.isArray(workspace.sessionIds)
25
+ || workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
26
+ throw new Error('Harness returned invalid session IDs for workspace.list');
27
+ }
28
+ return workspace;
29
+ }
30
+
31
+ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
32
+ if (!Array.isArray(sessionList?.items)) {
33
+ throw new Error('Harness returned an invalid response for session.list');
34
+ }
35
+
36
+ const archived = new Set(archivedSessionIds);
37
+ const summaries = new Map(sessionList.items.flatMap((item) => (
38
+ typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
39
+ )));
40
+ return {
41
+ workspace: workspace.path,
42
+ sessions: workspace.sessionIds.map((sessionId) => {
43
+ const summary = summaries.get(sessionId);
44
+ const title = summary?.projections?.values?.title;
45
+ return {
46
+ sessionId,
47
+ title: typeof title === 'string' ? title : null,
48
+ archived: archived.has(sessionId),
49
+ blank: summary?.blank === true,
50
+ origin: summary?.origin === 'subagent' ? 'subagent' : null,
51
+ summaryAvailable: summary !== undefined,
52
+ };
53
+ }),
54
+ };
55
+ }
56
+
14
57
  function assistantMessageText(event) {
15
58
  return (event?.data?.message?.content ?? [])
16
59
  .filter((part) => part.type === 'text' && typeof part.text === 'string')
@@ -200,6 +243,19 @@ export class HarnessClient {
200
243
  return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
201
244
  }
202
245
 
246
+ async listWorkspaceSessions(workspacePath, options = {}) {
247
+ await this.ensureRunning();
248
+ const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
249
+ const workspace = workspaceFromList(workspacePath, workspaceList);
250
+ if (!workspace) return { workspace: workspacePath, sessions: [] };
251
+ const sessionList = await this.rpc('session.list', {}, 30_000, options);
252
+ return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
253
+ }
254
+
255
+ async adoptWorkspaceSession(value, options = {}) {
256
+ return adoptRegisteredWorkspaceSession(this, value, options);
257
+ }
258
+
203
259
  async workspaceId(options = {}) {
204
260
  const workspace = options.workspace ?? this.#workspace;
205
261
  const { items } = await this.rpc('workspace.list', {});
@@ -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.6.0',
95
+ bot_agent: 'DeepSeekHarness/0.7.0',
96
96
  };
97
97
  }
98
98
 
@@ -13,6 +13,8 @@ const HELP_TEXT = [
13
13
  '/new 开启一个全新会话',
14
14
  '/workspace 工作区绝对路径 切换工作区',
15
15
  '/workspacelist 列出工作区绝对路径',
16
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
17
+ '/session Session ID 将当前聊天绑定到指定会话',
16
18
  '/status 检查连接状态',
17
19
  '/help 显示本帮助',
18
20
  ].join('\n');
@@ -120,6 +122,7 @@ export class WeixinHarnessBridge {
120
122
  }
121
123
 
122
124
  const command = text.trim().toLowerCase();
125
+ const key = conversationKey(sender);
123
126
  if (command === '/help') {
124
127
  await this.#send(sender, HELP_TEXT, contextToken, runId);
125
128
  await this.#state.markSeen(messageId);
@@ -132,12 +135,12 @@ export class WeixinHarnessBridge {
132
135
  return;
133
136
  }
134
137
  if (command === '/new') {
135
- await this.#state.clearSession(conversationKey(sender));
138
+ await this.#state.clearSession(key);
136
139
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
137
140
  await this.#state.markSeen(messageId);
138
141
  return;
139
142
  }
140
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
143
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
141
144
  if (workspaceCommand) {
142
145
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
143
146
  await this.#send(sender, reply, contextToken, runId);
@@ -146,7 +149,6 @@ export class WeixinHarnessBridge {
146
149
  return;
147
150
  }
148
151
 
149
- const key = conversationKey(sender);
150
152
  const { answer } = await askInWorkspaceSession({
151
153
  harness: this.#harness,
152
154
  state: this.#state,