@xmanrui/dsh-im 0.5.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.
- package/README.md +28 -4
- package/lib/client.js +649 -357
- package/lib/index.js +117 -103
- package/package.json +1 -1
- package/plugin-src/client/build.mjs +1 -1
- package/plugin-src/client/i18n.js +14 -0
- package/plugin-src/client/index.js +11 -3
- package/plugin-src/client/styles.js +49 -8
- package/plugin-src/client/workspace-directory-picker.js +230 -0
- package/plugin-src/client/workspace-editor.js +38 -65
- package/scripts/verify-package.mjs +5 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +7 -2
- package/src/channels/dingtalk/harness-client.mjs +69 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +7 -2
- package/src/channels/feishu/harness-client.mjs +69 -0
- package/src/channels/qq/qq-bridge.mjs +7 -2
- package/src/channels/shared/bot-workspace-store.mjs +217 -2
- package/src/channels/shared/harness-session-binding.mjs +110 -0
- package/src/channels/shared/text-harness-bridge.mjs +8 -3
- package/src/channels/shared/workspace-command.mjs +292 -7
- package/src/channels/shared/workspace-session.mjs +22 -9
- package/src/channels/wecom/wecom-bridge.mjs +7 -2
- package/src/channels/weixin/harness-client.mjs +69 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +9 -4
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
|
6
|
+
|
|
7
|
+
function workspacePaths(value) {
|
|
8
|
+
if (!Array.isArray(value?.items)) return [];
|
|
9
|
+
return value.items.flatMap((item) => (
|
|
10
|
+
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
|
11
|
+
));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function workspaceFromList(workspacePath, workspaceList) {
|
|
15
|
+
if (!Array.isArray(workspaceList?.items)
|
|
16
|
+
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
|
|
17
|
+
throw new Error('Harness returned an invalid response for workspace.list');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
|
|
21
|
+
if (!workspace) return null;
|
|
22
|
+
if (!Array.isArray(workspace.sessionIds)
|
|
23
|
+
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
|
|
24
|
+
throw new Error('Harness returned invalid session IDs for workspace.list');
|
|
25
|
+
}
|
|
26
|
+
return workspace;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
|
|
30
|
+
if (!Array.isArray(sessionList?.items)) {
|
|
31
|
+
throw new Error('Harness returned an invalid response for session.list');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const archived = new Set(archivedSessionIds);
|
|
35
|
+
const summaries = new Map(sessionList.items.flatMap((item) => (
|
|
36
|
+
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
|
|
37
|
+
)));
|
|
38
|
+
return {
|
|
39
|
+
workspace: workspace.path,
|
|
40
|
+
sessions: workspace.sessionIds.map((sessionId) => {
|
|
41
|
+
const summary = summaries.get(sessionId);
|
|
42
|
+
const title = summary?.projections?.values?.title;
|
|
43
|
+
return {
|
|
44
|
+
sessionId,
|
|
45
|
+
title: typeof title === 'string' ? title : null,
|
|
46
|
+
archived: archived.has(sessionId),
|
|
47
|
+
blank: summary?.blank === true,
|
|
48
|
+
origin: summary?.origin === 'subagent' ? 'subagent' : null,
|
|
49
|
+
summaryAvailable: summary !== undefined,
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
3
54
|
|
|
4
55
|
function sleep(ms, signal) {
|
|
5
56
|
return new Promise((resolve, reject) => {
|
|
@@ -216,6 +267,24 @@ export class HarnessClient {
|
|
|
216
267
|
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
|
217
268
|
}
|
|
218
269
|
|
|
270
|
+
async listWorkspaces(options = {}) {
|
|
271
|
+
await this.ensureRunning(options);
|
|
272
|
+
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
276
|
+
await this.ensureRunning(options);
|
|
277
|
+
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
|
|
278
|
+
const workspace = workspaceFromList(workspacePath, workspaceList);
|
|
279
|
+
if (!workspace) return { workspace: workspacePath, sessions: [] };
|
|
280
|
+
const sessionList = await this.rpc('session.list', {}, 30_000, options);
|
|
281
|
+
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async adoptWorkspaceSession(value, options = {}) {
|
|
285
|
+
return adoptRegisteredWorkspaceSession(this, value, options);
|
|
286
|
+
}
|
|
287
|
+
|
|
219
288
|
async workspaceId(options = {}) {
|
|
220
289
|
const { workspace = this.#workspace, ...rpcOptions } = options;
|
|
221
290
|
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|
|
@@ -108,7 +108,7 @@ export class DiscordApi {
|
|
|
108
108
|
headers: {
|
|
109
109
|
authorization: `Bot ${this.#token}`,
|
|
110
110
|
'content-type': 'application/json',
|
|
111
|
-
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.
|
|
111
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.7.0)',
|
|
112
112
|
},
|
|
113
113
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
114
114
|
signal: requestSignal(signal, timeoutMs),
|
|
@@ -14,6 +14,9 @@ const HELP_TEXT = [
|
|
|
14
14
|
'直接发送问题即可继续当前会话。',
|
|
15
15
|
'/new 开启一个全新会话',
|
|
16
16
|
'/workspace 工作区绝对路径 切换工作区',
|
|
17
|
+
'/workspacelist 列出工作区绝对路径',
|
|
18
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
19
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
17
20
|
'/status 检查连接状态',
|
|
18
21
|
'/help 显示本帮助',
|
|
19
22
|
].join('\n');
|
|
@@ -109,9 +112,11 @@ export class FeishuHarnessBridge {
|
|
|
109
112
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
110
113
|
return;
|
|
111
114
|
}
|
|
112
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
115
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
113
116
|
if (workspaceCommand) {
|
|
114
|
-
|
|
117
|
+
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
118
|
+
await this.#send(event.message.chat_id, reply);
|
|
119
|
+
}
|
|
115
120
|
return;
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -1,8 +1,59 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
|
3
6
|
|
|
4
7
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
8
|
|
|
9
|
+
function workspacePaths(value) {
|
|
10
|
+
if (!Array.isArray(value?.items)) return [];
|
|
11
|
+
return value.items.flatMap((item) => (
|
|
12
|
+
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
|
|
13
|
+
));
|
|
14
|
+
}
|
|
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
|
+
|
|
6
57
|
function messageText(event) {
|
|
7
58
|
return (event?.data?.message?.content ?? [])
|
|
8
59
|
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
|
@@ -197,6 +248,24 @@ export class HarnessClient {
|
|
|
197
248
|
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
|
198
249
|
}
|
|
199
250
|
|
|
251
|
+
async listWorkspaces(options = {}) {
|
|
252
|
+
await this.ensureRunning();
|
|
253
|
+
return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
257
|
+
await this.ensureRunning();
|
|
258
|
+
const workspaceList = await this.rpc('workspace.list', {}, 30000, options);
|
|
259
|
+
const workspace = workspaceFromList(workspacePath, workspaceList);
|
|
260
|
+
if (!workspace) return { workspace: workspacePath, sessions: [] };
|
|
261
|
+
const sessionList = await this.rpc('session.list', {}, 30000, options);
|
|
262
|
+
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async adoptWorkspaceSession(value, options = {}) {
|
|
266
|
+
return adoptRegisteredWorkspaceSession(this, value, options, 30000);
|
|
267
|
+
}
|
|
268
|
+
|
|
200
269
|
async workspaceId(options = {}) {
|
|
201
270
|
const workspace = options.workspace ?? this.#workspace;
|
|
202
271
|
const { items } = await this.rpc('workspace.list', {});
|
|
@@ -7,6 +7,9 @@ const HELP_TEXT = [
|
|
|
7
7
|
'直接发送文字即可继续当前会话。',
|
|
8
8
|
'/new 开启一个全新会话',
|
|
9
9
|
'/workspace 工作区绝对路径 切换工作区',
|
|
10
|
+
'/workspacelist 列出工作区绝对路径',
|
|
11
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
12
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
10
13
|
'/status 检查连接状态',
|
|
11
14
|
'/help 显示本帮助',
|
|
12
15
|
].join('\n');
|
|
@@ -125,9 +128,11 @@ export class QqHarnessBridge {
|
|
|
125
128
|
await this.#state.markSeen(messageId);
|
|
126
129
|
return;
|
|
127
130
|
}
|
|
128
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
131
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
129
132
|
if (workspaceCommand) {
|
|
130
|
-
|
|
133
|
+
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
134
|
+
await this.#bot.sendText(target, reply);
|
|
135
|
+
}
|
|
131
136
|
await this.#state.markSeen(messageId);
|
|
132
137
|
return;
|
|
133
138
|
}
|
|
@@ -1,10 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readFile,
|
|
4
|
+
realpath,
|
|
5
|
+
rename,
|
|
6
|
+
stat,
|
|
7
|
+
unlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
2
10
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
11
|
|
|
4
12
|
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
|
5
13
|
|
|
6
14
|
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
|
|
7
15
|
|
|
16
|
+
function workspaceSessionStale(message) {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.code = WORKSPACE_SESSION_STALE;
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function canonicalWorkspacePath(value) {
|
|
23
|
+
return resolve(await realpath(value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function sameWorkspacePath(left, right) {
|
|
27
|
+
if (left === right) return true;
|
|
28
|
+
try {
|
|
29
|
+
return await canonicalWorkspacePath(left) === await canonicalWorkspacePath(right);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
function botIdOf(value) {
|
|
9
36
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
|
10
37
|
throw new TypeError('Invalid bot id');
|
|
@@ -179,6 +206,72 @@ export class BotWorkspaceStore {
|
|
|
179
206
|
});
|
|
180
207
|
}
|
|
181
208
|
|
|
209
|
+
async bindWorkspaceSession(botId, value, {
|
|
210
|
+
conversationKey,
|
|
211
|
+
sessionId,
|
|
212
|
+
clearSessions,
|
|
213
|
+
setSession,
|
|
214
|
+
incarnation,
|
|
215
|
+
expectedGeneration,
|
|
216
|
+
} = {}) {
|
|
217
|
+
const id = botIdOf(botId);
|
|
218
|
+
if (typeof conversationKey !== 'string' || !conversationKey
|
|
219
|
+
|| typeof sessionId !== 'string' || !sessionId) {
|
|
220
|
+
throw new TypeError('conversationKey and sessionId are required');
|
|
221
|
+
}
|
|
222
|
+
if (typeof clearSessions !== 'function' || typeof setSession !== 'function') {
|
|
223
|
+
throw new TypeError('session state callbacks are required');
|
|
224
|
+
}
|
|
225
|
+
if (!this.has(id)
|
|
226
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
227
|
+
const error = new Error('找不到要修改的机器人。');
|
|
228
|
+
error.code = 'workspace-bot-not-found';
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
|
|
232
|
+
return this.#enqueue(id, async () => {
|
|
233
|
+
if (!this.has(id)
|
|
234
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
235
|
+
const error = new Error('找不到要修改的机器人。');
|
|
236
|
+
error.code = 'workspace-bot-not-found';
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
if (expectedGeneration !== undefined
|
|
240
|
+
&& expectedGeneration !== this.generationFor(id)) {
|
|
241
|
+
throw workspaceSessionStale(
|
|
242
|
+
'The bot workspace changed before the session binding could be committed.',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!(await sameWorkspacePath(workspace, this.workspaceFor(id)))) {
|
|
247
|
+
const previous = this.#workspaces[id];
|
|
248
|
+
// Fence every session resolved before this transition, then remove
|
|
249
|
+
// the old workspace mappings before publishing the new workspace.
|
|
250
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
251
|
+
await clearSessions();
|
|
252
|
+
this.#workspaces[id] = workspace;
|
|
253
|
+
try {
|
|
254
|
+
await this.#persist();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// Session mappings stay cleared and the advanced generation stays
|
|
257
|
+
// fenced. Restoring either could pair an old session with a state
|
|
258
|
+
// transition whose durable outcome is unknown.
|
|
259
|
+
this.#workspaces[id] = previous;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// This write remains inside the same bot transition as the workspace
|
|
265
|
+
// mutation, so another switch or bind cannot interleave between them.
|
|
266
|
+
await setSession(conversationKey, sessionId);
|
|
267
|
+
return {
|
|
268
|
+
workspace,
|
|
269
|
+
sessionId,
|
|
270
|
+
generation: this.#generations.get(id),
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
182
275
|
async invalidateSessions(botId, { clearSessions } = {}) {
|
|
183
276
|
const id = botIdOf(botId);
|
|
184
277
|
return this.#enqueue(id, async () => {
|
|
@@ -398,7 +491,42 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|
|
398
491
|
const sessionGenerations = new Map();
|
|
399
492
|
const scopedHarness = new Proxy(harness, {
|
|
400
493
|
get(target, property) {
|
|
401
|
-
if (property === 'currentWorkspace')
|
|
494
|
+
if (property === 'currentWorkspace') {
|
|
495
|
+
return () => {
|
|
496
|
+
if (!isCurrentScope()) {
|
|
497
|
+
const error = new Error('找不到要修改的机器人。');
|
|
498
|
+
error.code = 'workspace-bot-not-found';
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
return workspaces.workspaceFor(botId);
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (property === 'assertWorkspaceScope') {
|
|
505
|
+
return () => {
|
|
506
|
+
if (!isCurrentScope()) {
|
|
507
|
+
const error = new Error('找不到要修改的机器人。');
|
|
508
|
+
error.code = 'workspace-bot-not-found';
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if ((property === 'listWorkspaces' || property === 'listWorkspaceSessions')
|
|
514
|
+
&& typeof target[property] === 'function') {
|
|
515
|
+
return async (...args) => {
|
|
516
|
+
if (!isCurrentScope()) {
|
|
517
|
+
const error = new Error('找不到要修改的机器人。');
|
|
518
|
+
error.code = 'workspace-bot-not-found';
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
521
|
+
const result = await target[property](...args);
|
|
522
|
+
if (!isCurrentScope()) {
|
|
523
|
+
const error = new Error('找不到要修改的机器人。');
|
|
524
|
+
error.code = 'workspace-bot-not-found';
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
return result;
|
|
528
|
+
};
|
|
529
|
+
}
|
|
402
530
|
if (property === 'switchWorkspace') {
|
|
403
531
|
return (workspace) => {
|
|
404
532
|
if (!isCurrentScope()) {
|
|
@@ -412,6 +540,62 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|
|
412
540
|
});
|
|
413
541
|
};
|
|
414
542
|
}
|
|
543
|
+
if (property === 'bindWorkspaceSession') {
|
|
544
|
+
return async (conversationKey, sessionId) => {
|
|
545
|
+
if (typeof conversationKey !== 'string' || !conversationKey
|
|
546
|
+
|| typeof sessionId !== 'string' || !sessionId) {
|
|
547
|
+
throw new TypeError('conversationKey and sessionId are required');
|
|
548
|
+
}
|
|
549
|
+
if (!isCurrentScope()) {
|
|
550
|
+
const error = new Error('找不到要修改的机器人。');
|
|
551
|
+
error.code = 'workspace-bot-not-found';
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
if (typeof target.adoptWorkspaceSession !== 'function') {
|
|
555
|
+
throw new TypeError('Harness does not support adopting workspace sessions');
|
|
556
|
+
}
|
|
557
|
+
const expectedGeneration = workspaces.generationFor(botId);
|
|
558
|
+
const adopted = await target.adoptWorkspaceSession(sessionId);
|
|
559
|
+
if (!isCurrentScope()) {
|
|
560
|
+
const error = new Error('找不到要修改的机器人。');
|
|
561
|
+
error.code = 'workspace-bot-not-found';
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
if (expectedGeneration !== workspaces.generationFor(botId)) {
|
|
565
|
+
throw workspaceSessionStale(
|
|
566
|
+
'The bot workspace changed while the session was being adopted.',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (!adopted || typeof adopted !== 'object'
|
|
570
|
+
|| adopted.sessionId !== sessionId || typeof adopted.workspace !== 'string') {
|
|
571
|
+
throw new TypeError('Harness returned an invalid adopted workspace session');
|
|
572
|
+
}
|
|
573
|
+
const bound = await workspaces.bindWorkspaceSession(botId, adopted.workspace, {
|
|
574
|
+
conversationKey,
|
|
575
|
+
sessionId,
|
|
576
|
+
clearSessions: () => state.clearSessions(),
|
|
577
|
+
setSession: (key, selectedSessionId) => state.setSession(key, selectedSessionId),
|
|
578
|
+
incarnation,
|
|
579
|
+
expectedGeneration,
|
|
580
|
+
});
|
|
581
|
+
if (!isCurrentScope()) {
|
|
582
|
+
const error = new Error('找不到要修改的机器人。');
|
|
583
|
+
error.code = 'workspace-bot-not-found';
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
if (bound.generation !== workspaces.generationFor(botId)) {
|
|
587
|
+
throw workspaceSessionStale(
|
|
588
|
+
'The bot workspace changed before the session binding completed.',
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
sessionGenerations.set(sessionId, bound.generation);
|
|
592
|
+
return {
|
|
593
|
+
...adopted,
|
|
594
|
+
workspace: bound.workspace,
|
|
595
|
+
sessionId: bound.sessionId,
|
|
596
|
+
};
|
|
597
|
+
};
|
|
598
|
+
}
|
|
415
599
|
if (property === 'createSession') {
|
|
416
600
|
return async (options = {}) => {
|
|
417
601
|
await workspaces.whenBotIdle(botId);
|
|
@@ -429,6 +613,37 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|
|
429
613
|
return sessionId;
|
|
430
614
|
};
|
|
431
615
|
}
|
|
616
|
+
if (property === 'workspaceSession') {
|
|
617
|
+
return (sessionId) => {
|
|
618
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
619
|
+
throw new TypeError('sessionId is required');
|
|
620
|
+
}
|
|
621
|
+
const generation = sessionGenerations.get(sessionId)
|
|
622
|
+
?? workspaces.generationFor(botId);
|
|
623
|
+
// Transfer the mutable provenance entry into this immutable handle.
|
|
624
|
+
// A later handle for the same id captures its own generation instead
|
|
625
|
+
// of sharing deletion or rebinding state with this call.
|
|
626
|
+
sessionGenerations.delete(sessionId);
|
|
627
|
+
const isCurrentSession = () => isCurrentScope()
|
|
628
|
+
&& generation === workspaces.generationFor(botId);
|
|
629
|
+
return Object.freeze({
|
|
630
|
+
sessionId,
|
|
631
|
+
async sessionExists(...args) {
|
|
632
|
+
if (!isCurrentSession()) return false;
|
|
633
|
+
const exists = await target.sessionExists(sessionId, ...args);
|
|
634
|
+
return isCurrentSession() && exists;
|
|
635
|
+
},
|
|
636
|
+
ask(...args) {
|
|
637
|
+
if (!isCurrentSession()) {
|
|
638
|
+
throw workspaceSessionStale(
|
|
639
|
+
'The bot workspace changed before this prompt started.',
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
return target.ask(sessionId, ...args);
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
}
|
|
432
647
|
if (property === 'sessionExists') {
|
|
433
648
|
return (sessionId, ...args) => {
|
|
434
649
|
if (!isCurrentScope()) return false;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const MAX_SESSION_ID_LENGTH = 256;
|
|
4
|
+
const UNSAFE_SESSION_ID = /[\p{White_Space}\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
5
|
+
const UNSAFE_WORKSPACE_PATH = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
6
|
+
|
|
7
|
+
function bindingError(code, message) {
|
|
8
|
+
const error = new Error(message);
|
|
9
|
+
error.code = code;
|
|
10
|
+
return error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function validatedSessionId(value) {
|
|
14
|
+
if (typeof value !== 'string' || !value || value.length > MAX_SESSION_ID_LENGTH
|
|
15
|
+
|| UNSAFE_SESSION_ID.test(value)) {
|
|
16
|
+
throw bindingError('session-id-invalid', 'A non-empty, safe session id is required');
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sessionWorkspace(sessionId, value) {
|
|
22
|
+
if (!Array.isArray(value?.items) || !Array.isArray(value?.archivedSessionIds)
|
|
23
|
+
|| value.archivedSessionIds.some((id) => typeof id !== 'string' || !id)) {
|
|
24
|
+
throw new Error('Harness returned an invalid response for workspace.list');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const owners = [];
|
|
28
|
+
for (const workspace of value.items) {
|
|
29
|
+
if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId
|
|
30
|
+
|| typeof workspace.path !== 'string' || !isAbsolute(workspace.path)
|
|
31
|
+
|| !Array.isArray(workspace.sessionIds)
|
|
32
|
+
|| workspace.sessionIds.some((id) => typeof id !== 'string' || !id)) {
|
|
33
|
+
throw new Error('Harness returned an invalid response for workspace.list');
|
|
34
|
+
}
|
|
35
|
+
for (const accountedId of workspace.sessionIds) {
|
|
36
|
+
if (accountedId === sessionId) owners.push(workspace);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (owners.length === 0) {
|
|
41
|
+
throw bindingError('session-not-registered', 'The session is not registered to a Harness workspace');
|
|
42
|
+
}
|
|
43
|
+
if (owners.length !== 1) {
|
|
44
|
+
throw bindingError(
|
|
45
|
+
'session-workspace-ambiguous',
|
|
46
|
+
'The session is registered to more than one Harness workspace',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (UNSAFE_WORKSPACE_PATH.test(owners[0].path)) {
|
|
50
|
+
throw new Error('Harness returned an unsafe workspace path for the session');
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
workspace: owners[0],
|
|
54
|
+
archived: value.archivedSessionIds.includes(sessionId),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sessionSummary(sessionId, value) {
|
|
59
|
+
if (!Array.isArray(value?.items)
|
|
60
|
+
|| value.items.some((item) => typeof item?.sessionId !== 'string' || !item.sessionId)) {
|
|
61
|
+
throw new Error('Harness returned an invalid response for session.list');
|
|
62
|
+
}
|
|
63
|
+
const matches = value.items.filter((item) => item.sessionId === sessionId);
|
|
64
|
+
if (matches.length === 0) {
|
|
65
|
+
throw bindingError('session-summary-unavailable', 'The session is no longer available from Harness');
|
|
66
|
+
}
|
|
67
|
+
if (matches.length !== 1) {
|
|
68
|
+
throw new Error('Harness returned duplicate session summaries for session.list');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [summary] = matches;
|
|
72
|
+
if (summary.origin === 'subagent') {
|
|
73
|
+
throw bindingError(
|
|
74
|
+
'session-subagent-unsupported',
|
|
75
|
+
'Subagent sessions cannot be adopted as a bot conversation',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (summary.origin !== undefined) {
|
|
79
|
+
throw new Error('Harness returned an invalid session origin for session.list');
|
|
80
|
+
}
|
|
81
|
+
const title = summary.projections?.values?.title;
|
|
82
|
+
if (title !== undefined && title !== null && typeof title !== 'string') {
|
|
83
|
+
throw new Error('Harness returned an invalid session title for session.list');
|
|
84
|
+
}
|
|
85
|
+
return { title: typeof title === 'string' ? title : null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function adoptRegisteredWorkspaceSession(client, value, options = {}, timeoutMs = 30_000) {
|
|
89
|
+
const sessionId = validatedSessionId(value);
|
|
90
|
+
await client.ensureRunning(options);
|
|
91
|
+
const workspaceList = await client.rpc('workspace.list', {}, timeoutMs, options);
|
|
92
|
+
const { workspace, archived } = sessionWorkspace(sessionId, workspaceList);
|
|
93
|
+
const summary = sessionSummary(
|
|
94
|
+
sessionId,
|
|
95
|
+
await client.rpc('session.list', {}, timeoutMs, options),
|
|
96
|
+
);
|
|
97
|
+
const adopted = await client.rpc('session.create', {
|
|
98
|
+
workspaceId: workspace.workspaceId,
|
|
99
|
+
sessionId,
|
|
100
|
+
}, timeoutMs, options);
|
|
101
|
+
if (!adopted || adopted.sessionId !== sessionId) {
|
|
102
|
+
throw new Error('Harness returned an invalid response for session.create');
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
sessionId,
|
|
106
|
+
workspace: workspace.path,
|
|
107
|
+
title: summary.title,
|
|
108
|
+
archived,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -101,6 +101,9 @@ export class TextHarnessBridge {
|
|
|
101
101
|
'直接发送文字即可继续当前会话。',
|
|
102
102
|
'/new 开启一个全新会话',
|
|
103
103
|
'/workspace 工作区绝对路径 切换工作区',
|
|
104
|
+
'/workspacelist 列出工作区绝对路径',
|
|
105
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
106
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
104
107
|
'/status 检查连接状态',
|
|
105
108
|
'/help 显示本帮助',
|
|
106
109
|
].join('\n'));
|
|
@@ -113,13 +116,15 @@ export class TextHarnessBridge {
|
|
|
113
116
|
await this.#state.markSeen(messageId);
|
|
114
117
|
return;
|
|
115
118
|
}
|
|
116
|
-
const
|
|
119
|
+
const conversationKey = `${message.kind}:${message.conversationId}`;
|
|
120
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
|
|
117
121
|
if (workspaceCommand) {
|
|
118
|
-
|
|
122
|
+
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
123
|
+
await this.#bot.sendText(target, reply);
|
|
124
|
+
}
|
|
119
125
|
await this.#state.markSeen(messageId);
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
122
|
-
const conversationKey = `${message.kind}:${message.conversationId}`;
|
|
123
128
|
if (command === '/new') {
|
|
124
129
|
await this.#state.clearSession(conversationKey);
|
|
125
130
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|