@xmanrui/dsh-im 0.3.0 → 0.5.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 (68) hide show
  1. package/README.md +64 -0
  2. package/assets/logo-icon.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/lib/client.js +765 -295
  5. package/lib/index.js +112 -110
  6. package/package.json +2 -1
  7. package/plugin-src/client/build.mjs +6 -1
  8. package/plugin-src/client/channels/dingtalk/api.js +2 -0
  9. package/plugin-src/client/channels/dingtalk/index.js +67 -14
  10. package/plugin-src/client/channels/feishu/api.js +2 -0
  11. package/plugin-src/client/channels/feishu/index.js +70 -22
  12. package/plugin-src/client/channels/qq/api.js +2 -0
  13. package/plugin-src/client/channels/qq/index.js +45 -8
  14. package/plugin-src/client/channels/shared/token-api.js +2 -0
  15. package/plugin-src/client/channels/shared/token-channel.js +34 -8
  16. package/plugin-src/client/channels/wecom/api.js +2 -0
  17. package/plugin-src/client/channels/wecom/index.js +45 -8
  18. package/plugin-src/client/channels/weixin/api.js +2 -0
  19. package/plugin-src/client/channels/weixin/index.js +68 -8
  20. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  21. package/plugin-src/client/channels/whatsapp/index.js +27 -5
  22. package/plugin-src/client/i18n.js +15 -1
  23. package/plugin-src/client/index.js +28 -1
  24. package/plugin-src/client/styles.js +32 -9
  25. package/plugin-src/client/workspace-editor.js +96 -0
  26. package/plugin-src/client/workspace-snapshot-fence.js +28 -0
  27. package/plugin-src/host/channels/dingtalk/production.mjs +34 -11
  28. package/plugin-src/host/channels/dingtalk/rpc.mjs +17 -2
  29. package/plugin-src/host/channels/feishu/production.mjs +42 -5
  30. package/plugin-src/host/channels/feishu/rpc.mjs +17 -2
  31. package/plugin-src/host/channels/qq/production.mjs +48 -22
  32. package/plugin-src/host/channels/qq/rpc.mjs +16 -2
  33. package/plugin-src/host/channels/shared/production.mjs +48 -22
  34. package/plugin-src/host/channels/shared/rpc.mjs +15 -0
  35. package/plugin-src/host/channels/shared/workspace-rpc.mjs +25 -0
  36. package/plugin-src/host/channels/slack/production.mjs +48 -23
  37. package/plugin-src/host/channels/slack/rpc.mjs +16 -0
  38. package/plugin-src/host/channels/wecom/production.mjs +49 -23
  39. package/plugin-src/host/channels/wecom/rpc.mjs +16 -2
  40. package/plugin-src/host/channels/weixin/production.mjs +31 -11
  41. package/plugin-src/host/channels/weixin/rpc.mjs +21 -2
  42. package/plugin-src/host/channels/whatsapp/production.mjs +49 -23
  43. package/plugin-src/host/channels/whatsapp/rpc.mjs +16 -2
  44. package/src/channels/dingtalk/dingtalk-bridge.mjs +22 -11
  45. package/src/channels/dingtalk/dingtalk-controller.mjs +6 -1
  46. package/src/channels/dingtalk/harness-client.mjs +4 -3
  47. package/src/channels/dingtalk/state-store.mjs +5 -0
  48. package/src/channels/discord/discord-api.mjs +1 -1
  49. package/src/channels/feishu/bridge.mjs +36 -16
  50. package/src/channels/feishu/harness-client.mjs +6 -5
  51. package/src/channels/feishu/state-store.mjs +5 -0
  52. package/src/channels/qq/qq-bridge.mjs +25 -15
  53. package/src/channels/qq/qq-controller.mjs +8 -1
  54. package/src/channels/qq/state-store.mjs +5 -0
  55. package/src/channels/shared/bot-workspace-store.mjs +584 -0
  56. package/src/channels/shared/conversation-state-store.mjs +5 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +23 -13
  58. package/src/channels/shared/workspace-command.mjs +26 -0
  59. package/src/channels/shared/workspace-session.mjs +44 -0
  60. package/src/channels/wecom/state-store.mjs +5 -0
  61. package/src/channels/wecom/wecom-bridge.mjs +22 -13
  62. package/src/channels/wecom/wecom-controller.mjs +8 -1
  63. package/src/channels/weixin/harness-client.mjs +6 -5
  64. package/src/channels/weixin/state-store.mjs +5 -0
  65. package/src/channels/weixin/weixin-api.mjs +1 -1
  66. package/src/channels/weixin/weixin-bridge.mjs +16 -6
  67. package/src/channels/weixin/weixin-controller.mjs +6 -1
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +8 -1
@@ -1,8 +1,12 @@
1
+ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
+ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
3
+
1
4
  const HELP_TEXT = [
2
5
  'QQ 机器人已连接 DeepSeek Harness。',
3
6
  '',
4
7
  '直接发送文字即可继续当前会话。',
5
8
  '/new 开启一个全新会话',
9
+ '/workspace 工作区绝对路径 切换工作区',
6
10
  '/status 检查连接状态',
7
11
  '/help 显示本帮助',
8
12
  ].join('\n');
@@ -121,11 +125,11 @@ export class QqHarnessBridge {
121
125
  await this.#state.markSeen(messageId);
122
126
  return;
123
127
  }
124
-
125
- let sessionId = this.#state.sessionFor(key);
126
- if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
127
- sessionId = await this.#harness.createSession();
128
- await this.#state.setSession(key, sessionId);
128
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
129
+ if (workspaceCommand) {
130
+ await this.#bot.sendText(target, workspaceCommand.message);
131
+ await this.#state.markSeen(messageId);
132
+ return;
129
133
  }
130
134
 
131
135
  let stream = null;
@@ -137,16 +141,22 @@ export class QqHarnessBridge {
137
141
  this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
138
142
  }
139
143
  }
140
- const answer = await this.#harness.ask(sessionId, text, {
141
- timeoutMs: this.#replyTimeoutMs,
142
- onUpdate: stream ? async (update) => {
143
- const progress = update.type === 'text'
144
- ? update.text
145
- : update.type === 'tool'
146
- ? `正在使用${update.name}…`
147
- : update.text;
148
- if (progress) await stream.update(progress);
149
- } : undefined,
144
+ const { answer } = await askInWorkspaceSession({
145
+ harness: this.#harness,
146
+ state: this.#state,
147
+ key,
148
+ text,
149
+ askOptions: {
150
+ timeoutMs: this.#replyTimeoutMs,
151
+ onUpdate: stream ? async (update) => {
152
+ const progress = update.type === 'text'
153
+ ? update.text
154
+ : update.type === 'tool'
155
+ ? `正在使用${update.name}…`
156
+ : update.text;
157
+ if (progress) await stream.update(progress);
158
+ } : undefined,
159
+ },
150
160
  });
151
161
  if (stream) {
152
162
  try {
@@ -388,7 +388,14 @@ export class QqController {
388
388
  if (record.controller.signal.aborted) {
389
389
  await this.#stopRuntime(identity.botId);
390
390
  if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
391
- else await this.#configStore.remove(identity.botId).catch(() => undefined);
391
+ else {
392
+ const removed = await this.#configStore.remove(identity.botId).catch(() => null);
393
+ if (removed) {
394
+ await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
395
+ this.#logger.warn?.('[dsh-im:qq] cancelled bot state cleanup failed:', cleanupError);
396
+ });
397
+ }
398
+ }
392
399
  await this.#restoreCredential(identity.secretRef, previousSecret);
393
400
  throw error;
394
401
  }
@@ -56,6 +56,11 @@ export class QqStateStore {
56
56
  await this.#persist();
57
57
  }
58
58
 
59
+ async clearSessions() {
60
+ this.#state.sessions = {};
61
+ await this.#persist();
62
+ }
63
+
59
64
  hasSeen(messageId) {
60
65
  return this.#state.seenMessageIds.includes(messageId);
61
66
  }
@@ -0,0 +1,584 @@
1
+ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+
4
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
5
+
6
+ const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
7
+
8
+ function botIdOf(value) {
9
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
10
+ throw new TypeError('Invalid bot id');
11
+ }
12
+ return value;
13
+ }
14
+
15
+ function normalizeDocument(value) {
16
+ if (!value || value.version !== 1 || !value.workspaces
17
+ || typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
18
+ const workspaces = {};
19
+ for (const [botId, workspace] of Object.entries(value.workspaces)) {
20
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)
21
+ || typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
22
+ workspaces[botId] = resolve(workspace);
23
+ }
24
+ return { version: 1, workspaces };
25
+ }
26
+
27
+ export async function validateWorkspacePath(value) {
28
+ if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
29
+ const error = new Error('工作区必须是绝对路径。');
30
+ error.code = 'workspace-not-absolute';
31
+ throw error;
32
+ }
33
+ const workspace = resolve(value.trim());
34
+ let info;
35
+ try {
36
+ info = await stat(workspace);
37
+ } catch (cause) {
38
+ const error = new Error('工作区路径不存在。', { cause });
39
+ error.code = 'workspace-not-found';
40
+ throw error;
41
+ }
42
+ if (!info.isDirectory()) {
43
+ const error = new Error('工作区路径必须指向一个目录。');
44
+ error.code = 'workspace-not-directory';
45
+ throw error;
46
+ }
47
+ return workspace;
48
+ }
49
+
50
+ export class BotWorkspaceStore {
51
+ #path;
52
+ #defaultWorkspace;
53
+ #workspaces = {};
54
+ #generations = new Map();
55
+ #nextGeneration = 1;
56
+ #incarnations = new Map();
57
+ #nextIncarnation = 1;
58
+ #removals = new Map();
59
+ #removalDetails = new WeakMap();
60
+ #dirtyRemovals = new Set();
61
+ #writeQueue = Promise.resolve();
62
+ #botQueues = new Map();
63
+
64
+ constructor(path, { defaultWorkspace = process.cwd() } = {}) {
65
+ if (typeof path !== 'string' || !path) throw new TypeError('workspace store path is required');
66
+ this.#path = path;
67
+ this.#defaultWorkspace = resolve(defaultWorkspace);
68
+ }
69
+
70
+ async load() {
71
+ try {
72
+ const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
73
+ if (!normalized) throw new Error('dsh-im workspace config is invalid');
74
+ this.#workspaces = normalized.workspaces;
75
+ } catch (error) {
76
+ if (error?.code !== 'ENOENT') throw error;
77
+ this.#workspaces = {};
78
+ }
79
+ this.#generations.clear();
80
+ this.#nextGeneration = 1;
81
+ this.#incarnations.clear();
82
+ this.#nextIncarnation = 1;
83
+ this.#removals.clear();
84
+ this.#dirtyRemovals.clear();
85
+ for (const botId of Object.keys(this.#workspaces)) {
86
+ this.#generations.set(botId, this.#freshGeneration());
87
+ this.#incarnations.set(botId, this.#freshIncarnation());
88
+ }
89
+ return this;
90
+ }
91
+
92
+ has(botId) {
93
+ const id = botIdOf(botId);
94
+ return Object.hasOwn(this.#workspaces, id) && !this.#removals.has(id);
95
+ }
96
+
97
+ incarnationFor(botId) {
98
+ return this.#incarnations.get(botIdOf(botId)) ?? null;
99
+ }
100
+
101
+ workspaceFor(botId) {
102
+ return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
103
+ }
104
+
105
+ generationFor(botId) {
106
+ return this.#generations.get(botIdOf(botId)) ?? null;
107
+ }
108
+
109
+ async whenIdle() {
110
+ await this.#writeQueue;
111
+ }
112
+
113
+ async whenBotIdle(botId) {
114
+ const id = botIdOf(botId);
115
+ while (true) {
116
+ const pending = this.#botQueues.get(id);
117
+ if (!pending) return;
118
+ await pending;
119
+ if (this.#botQueues.get(id) === pending) return;
120
+ }
121
+ }
122
+
123
+ async ensure(botId, { workspace = this.#defaultWorkspace } = {}) {
124
+ const id = botIdOf(botId);
125
+ const initialWorkspace = resolve(workspace);
126
+ return this.#enqueue(id, async () => {
127
+ if (!this.#workspaces[id]) {
128
+ this.#workspaces[id] = initialWorkspace;
129
+ this.#generations.set(id, this.#freshGeneration());
130
+ this.#incarnations.set(id, this.#freshIncarnation());
131
+ try {
132
+ await this.#persist();
133
+ } catch (error) {
134
+ delete this.#workspaces[id];
135
+ this.#generations.delete(id);
136
+ this.#incarnations.delete(id);
137
+ throw error;
138
+ }
139
+ } else if (!this.#generations.has(id)) {
140
+ this.#generations.set(id, this.#freshGeneration());
141
+ }
142
+ return this.#workspaces[id];
143
+ });
144
+ }
145
+
146
+ async setWorkspace(botId, value, { clearSessions, incarnation } = {}) {
147
+ const id = botIdOf(botId);
148
+ if (!this.has(id)
149
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
150
+ const error = new Error('找不到要修改的机器人。');
151
+ error.code = 'workspace-bot-not-found';
152
+ throw error;
153
+ }
154
+ const workspace = await validateWorkspacePath(value);
155
+ return this.#enqueue(id, async () => {
156
+ if (!this.has(id)
157
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
158
+ const error = new Error('找不到要修改的机器人。');
159
+ error.code = 'workspace-bot-not-found';
160
+ throw error;
161
+ }
162
+ if (workspace === this.workspaceFor(id)) return workspace;
163
+ const previous = this.#workspaces[id];
164
+ // Advance first so a session creation that started before this queued
165
+ // transition can never be written back after the clear.
166
+ this.#generations.set(id, this.#freshGeneration());
167
+ // Clear the old session mapping before publishing the new workspace.
168
+ // A crash can then lose conversation continuity, but can never pair the
169
+ // new workspace with sessions created in the old one.
170
+ await clearSessions?.();
171
+ this.#workspaces[id] = workspace;
172
+ try {
173
+ await this.#persist();
174
+ } catch (error) {
175
+ this.#workspaces[id] = previous;
176
+ throw error;
177
+ }
178
+ return workspace;
179
+ });
180
+ }
181
+
182
+ async invalidateSessions(botId, { clearSessions } = {}) {
183
+ const id = botIdOf(botId);
184
+ return this.#enqueue(id, async () => {
185
+ this.#generations.set(id, this.#freshGeneration());
186
+ await clearSessions?.();
187
+ });
188
+ }
189
+
190
+ /** Fence one lifecycle and return the opaque token required to abort/finish it. */
191
+ async beginRemoval(botId, { clearSessions } = {}) {
192
+ const id = botIdOf(botId);
193
+ return this.#enqueue(id, async () => {
194
+ const existing = this.#removals.get(id);
195
+ if (existing) return existing;
196
+ const transaction = Object.freeze({});
197
+ this.#removals.set(id, transaction);
198
+ this.#removalDetails.set(transaction, {
199
+ botId: id,
200
+ incarnation: this.incarnationFor(id),
201
+ });
202
+ this.#generations.set(id, this.#freshGeneration());
203
+ try {
204
+ await clearSessions?.();
205
+ } catch (error) {
206
+ if (this.#removals.get(id) === transaction) this.#removals.delete(id);
207
+ throw error;
208
+ }
209
+ return transaction;
210
+ });
211
+ }
212
+
213
+ /** Re-open only the lifecycle represented by transaction; stale tokens are no-ops. */
214
+ async abortRemoval(transaction) {
215
+ const { botId: id } = this.#removalDetailsFor(transaction);
216
+ return this.#enqueue(id, async () => {
217
+ if (this.#removals.get(id) !== transaction) return false;
218
+ this.#removals.delete(id);
219
+ if (Object.hasOwn(this.#workspaces, id)) {
220
+ this.#generations.set(id, this.#freshGeneration());
221
+ if (!this.#incarnations.has(id)) {
222
+ this.#incarnations.set(id, this.#freshIncarnation());
223
+ }
224
+ }
225
+ return true;
226
+ });
227
+ }
228
+
229
+ /** Retire only the lifecycle represented by transaction; stale tokens are no-ops. */
230
+ async finishRemoval(transaction) {
231
+ const { botId: id, incarnation } = this.#removalDetailsFor(transaction);
232
+ return this.#enqueue(id, async () => {
233
+ if (this.#removals.get(id) !== transaction) {
234
+ return { removed: false, persisted: true, error: null, stale: true };
235
+ }
236
+ if (this.incarnationFor(id) !== incarnation) {
237
+ this.#removals.delete(id);
238
+ return { removed: false, persisted: true, error: null, stale: true };
239
+ }
240
+ this.#removals.delete(id);
241
+ return this.#retireCurrentIncarnation(id);
242
+ });
243
+ }
244
+
245
+ /** Commit the workspace lifecycle after the config store durably removed a bot. */
246
+ async retireAfterConfigCommit(botId) {
247
+ const id = botIdOf(botId);
248
+ return this.#enqueue(id, async () => {
249
+ this.#removals.delete(id);
250
+ return this.#retireCurrentIncarnation(id);
251
+ });
252
+ }
253
+
254
+ async remove(botId) {
255
+ const result = await this.retireAfterConfigCommit(botId);
256
+ if (result.error) throw result.error;
257
+ return result.removed;
258
+ }
259
+
260
+ async reconcile(activeBotIds) {
261
+ const active = new Set([...activeBotIds].map(botIdOf));
262
+ const candidates = new Set([...Object.keys(this.#workspaces), ...this.#dirtyRemovals]);
263
+ for (const botId of candidates) {
264
+ if (!active.has(botId)) await this.remove(botId);
265
+ }
266
+ }
267
+
268
+ decorateStatus(status) {
269
+ if (!status || typeof status !== 'object' || !Array.isArray(status.bots)) return status;
270
+ return {
271
+ ...status,
272
+ bots: status.bots.map((bot) => bot?.botId
273
+ ? { ...bot, workspace: this.workspaceFor(bot.botId) }
274
+ : bot),
275
+ };
276
+ }
277
+
278
+ #freshGeneration() {
279
+ const generation = this.#nextGeneration;
280
+ this.#nextGeneration += 1;
281
+ return generation;
282
+ }
283
+
284
+ #freshIncarnation() {
285
+ const incarnation = this.#nextIncarnation;
286
+ this.#nextIncarnation += 1;
287
+ return incarnation;
288
+ }
289
+
290
+ #removalDetailsFor(transaction) {
291
+ if (!transaction || typeof transaction !== 'object') {
292
+ throw new TypeError('Invalid workspace removal transaction');
293
+ }
294
+ const details = this.#removalDetails.get(transaction);
295
+ if (!details) throw new TypeError('Invalid workspace removal transaction');
296
+ return details;
297
+ }
298
+
299
+ async #retireCurrentIncarnation(id) {
300
+ const hadWorkspace = Object.hasOwn(this.#workspaces, id);
301
+ const needsCleanup = hadWorkspace || this.#dirtyRemovals.has(id);
302
+ delete this.#workspaces[id];
303
+ this.#generations.delete(id);
304
+ this.#incarnations.delete(id);
305
+ if (!needsCleanup) return {
306
+ removed: false, persisted: true, error: null, stale: false,
307
+ };
308
+ try {
309
+ await this.#persistCurrentDocument();
310
+ return {
311
+ removed: hadWorkspace, persisted: true, error: null, stale: false,
312
+ };
313
+ } catch (error) {
314
+ this.#dirtyRemovals.add(id);
315
+ return {
316
+ removed: hadWorkspace, persisted: false, error, stale: false,
317
+ };
318
+ }
319
+ }
320
+
321
+ async #enqueue(botId, operation) {
322
+ const queued = this.#writeQueue.then(operation, operation);
323
+ const settled = queued.then(() => undefined, () => undefined);
324
+ this.#writeQueue = settled;
325
+ this.#botQueues.set(botId, settled);
326
+ void settled.finally(() => {
327
+ if (this.#botQueues.get(botId) === settled) this.#botQueues.delete(botId);
328
+ });
329
+ return queued;
330
+ }
331
+
332
+ async #persist() {
333
+ const document = { version: 1, workspaces: this.#workspaces };
334
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
335
+ const temporary = `${this.#path}.tmp`;
336
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
337
+ encoding: 'utf8',
338
+ mode: 0o600,
339
+ });
340
+ await rename(temporary, this.#path);
341
+ this.#dirtyRemovals.clear();
342
+ }
343
+
344
+ async #persistCurrentDocument() {
345
+ if (Object.keys(this.#workspaces).length > 0) {
346
+ await this.#persist();
347
+ return;
348
+ }
349
+ try {
350
+ await unlink(this.#path);
351
+ this.#dirtyRemovals.clear();
352
+ } catch (error) {
353
+ if (error?.code !== 'ENOENT') throw error;
354
+ this.#dirtyRemovals.clear();
355
+ }
356
+ }
357
+ }
358
+
359
+ function decorateResult(workspaces, result) {
360
+ return result && typeof result.then === 'function'
361
+ ? result.then((value) => workspaces.decorateStatus(value))
362
+ : workspaces.decorateStatus(result);
363
+ }
364
+
365
+ function targetStatus(controller) {
366
+ return Promise.resolve(controller.status());
367
+ }
368
+
369
+ /** Observe the config store's durable removal commit without changing its API. */
370
+ export function observeBotWorkspaceRemovals(
371
+ configStore,
372
+ { workspaces, method = 'remove', botIdFromRemoved = (removed) => removed?.botId },
373
+ ) {
374
+ if (!configStore || !workspaces || typeof configStore[method] !== 'function') {
375
+ throw new TypeError('configStore removal observer dependencies are required');
376
+ }
377
+ return new Proxy(configStore, {
378
+ get(target, property) {
379
+ const value = Reflect.get(target, property, target);
380
+ if (property === method) {
381
+ return async (...args) => {
382
+ const removed = await value.apply(target, args);
383
+ const botId = removed ? botIdFromRemoved(removed, args) : null;
384
+ if (botId) await workspaces.retireAfterConfigCommit(botId);
385
+ return removed;
386
+ };
387
+ }
388
+ return typeof value === 'function' ? value.bind(target) : value;
389
+ },
390
+ });
391
+ }
392
+
393
+ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
394
+ if (!harness || !workspaces || !state) throw new TypeError('harness, workspaces, and state are required');
395
+ const incarnation = workspaces.incarnationFor(botId);
396
+ const isCurrentScope = () => workspaces.has(botId)
397
+ && workspaces.incarnationFor(botId) === incarnation;
398
+ const sessionGenerations = new Map();
399
+ const scopedHarness = new Proxy(harness, {
400
+ get(target, property) {
401
+ if (property === 'currentWorkspace') return () => workspaces.workspaceFor(botId);
402
+ if (property === 'switchWorkspace') {
403
+ return (workspace) => {
404
+ if (!isCurrentScope()) {
405
+ const error = new Error('找不到要修改的机器人。');
406
+ error.code = 'workspace-bot-not-found';
407
+ return Promise.reject(error);
408
+ }
409
+ return workspaces.setWorkspace(botId, workspace, {
410
+ clearSessions: () => state.clearSessions(),
411
+ incarnation,
412
+ });
413
+ };
414
+ }
415
+ if (property === 'createSession') {
416
+ return async (options = {}) => {
417
+ await workspaces.whenBotIdle(botId);
418
+ if (!isCurrentScope()) {
419
+ const error = new Error('找不到要修改的机器人。');
420
+ error.code = 'workspace-bot-not-found';
421
+ throw error;
422
+ }
423
+ const generation = workspaces.generationFor(botId);
424
+ const sessionId = await target.createSession({
425
+ ...options,
426
+ workspace: workspaces.workspaceFor(botId),
427
+ });
428
+ sessionGenerations.set(sessionId, generation);
429
+ return sessionId;
430
+ };
431
+ }
432
+ if (property === 'sessionExists') {
433
+ return (sessionId, ...args) => {
434
+ if (!isCurrentScope()) return false;
435
+ const generation = sessionGenerations.get(sessionId);
436
+ if (generation !== undefined && generation !== workspaces.generationFor(botId)) {
437
+ sessionGenerations.delete(sessionId);
438
+ return false;
439
+ }
440
+ return target.sessionExists(sessionId, ...args);
441
+ };
442
+ }
443
+ if (property === 'ask') {
444
+ return (sessionId, ...args) => {
445
+ const generation = sessionGenerations.get(sessionId);
446
+ sessionGenerations.delete(sessionId);
447
+ if (!isCurrentScope()
448
+ || (generation !== undefined && generation !== workspaces.generationFor(botId))) {
449
+ const error = new Error('The bot workspace changed before this prompt started.');
450
+ error.code = WORKSPACE_SESSION_STALE;
451
+ throw error;
452
+ }
453
+ return target.ask(sessionId, ...args);
454
+ };
455
+ }
456
+ const value = Reflect.get(target, property, target);
457
+ return typeof value === 'function' ? value.bind(target) : value;
458
+ },
459
+ });
460
+ const scopedState = new Proxy(state, {
461
+ get(target, property) {
462
+ if (property === 'sessionFor') {
463
+ return (key, ...args) => {
464
+ if (!isCurrentScope()) return null;
465
+ const sessionId = target.sessionFor(key, ...args);
466
+ if (sessionId && !sessionGenerations.has(sessionId)) {
467
+ sessionGenerations.set(sessionId, workspaces.generationFor(botId));
468
+ }
469
+ return sessionId;
470
+ };
471
+ }
472
+ if (property === 'setSession') {
473
+ return (key, sessionId, ...args) => {
474
+ const generation = sessionGenerations.get(sessionId);
475
+ if (!isCurrentScope()
476
+ || (generation !== undefined && generation !== workspaces.generationFor(botId))) {
477
+ sessionGenerations.delete(sessionId);
478
+ return false;
479
+ }
480
+ return target.setSession(key, sessionId, ...args);
481
+ };
482
+ }
483
+ const value = Reflect.get(target, property, target);
484
+ return typeof value === 'function' ? value.bind(target) : value;
485
+ },
486
+ });
487
+ return Object.freeze({ harness: scopedHarness, state: scopedState });
488
+ }
489
+
490
+ export function createBotScopedHarness(harness, options) {
491
+ return createBotWorkspaceScope(harness, options).harness;
492
+ }
493
+
494
+ export function createWorkspaceAwareController(controller, { workspaces, stateFor }) {
495
+ if (!controller || !workspaces || typeof stateFor !== 'function') {
496
+ throw new TypeError('controller, workspaces, and stateFor are required');
497
+ }
498
+ const transitions = new Map();
499
+ const withBotTransition = (botId, operation) => {
500
+ const previous = transitions.get(botId) ?? Promise.resolve();
501
+ const current = previous.catch(() => undefined).then(operation);
502
+ transitions.set(botId, current);
503
+ return current.finally(() => {
504
+ if (transitions.get(botId) === current) transitions.delete(botId);
505
+ });
506
+ };
507
+ const updateWorkspace = (botId, workspace) => {
508
+ // Capture at API invocation, before even waiting for an older outer
509
+ // transition. A queued request still belongs to the incarnation that the
510
+ // caller observed, not a deterministic same-id rebind that appears later.
511
+ const incarnation = workspaces.incarnationFor(botId);
512
+ return withBotTransition(botId, async () => {
513
+ const snapshot = await controller.status();
514
+ if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
515
+ const error = new Error('找不到要修改的机器人。');
516
+ error.code = 'workspace-bot-not-found';
517
+ throw error;
518
+ }
519
+ const state = await stateFor(botId);
520
+ await workspaces.setWorkspace(botId, workspace, {
521
+ clearSessions: () => state.clearSessions(),
522
+ incarnation,
523
+ });
524
+ return workspaces.decorateStatus(await controller.status());
525
+ });
526
+ };
527
+ const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
528
+ // Fence the old runtime without changing the durable mapping. A crash
529
+ // before the controller removes its config therefore keeps the bot's
530
+ // workspace, while a crash after that commit is healed by startup
531
+ // reconciliation.
532
+ const removal = await workspaces.beginRemoval(botId, {
533
+ clearSessions: async () => {
534
+ try {
535
+ const state = await stateFor(botId);
536
+ if (!state || typeof state.clearSessions !== 'function') {
537
+ throw new TypeError('bot state does not support session cleanup');
538
+ }
539
+ await state.clearSessions();
540
+ } catch (error) {
541
+ console.warn(
542
+ `[dsh-im] ignored session cleanup failure while deleting bot ${botId}:`,
543
+ error?.message ?? error,
544
+ );
545
+ }
546
+ },
547
+ });
548
+ try {
549
+ const result = await invokeDelete();
550
+ await workspaces.finishRemoval(removal);
551
+ return workspaces.decorateStatus(result);
552
+ } catch (error) {
553
+ const after = await targetStatus(controller).catch(() => null);
554
+ const knownAbsent = Array.isArray(after?.bots)
555
+ && !after.bots.some((bot) => bot?.botId === botId);
556
+ if (knownAbsent) await workspaces.finishRemoval(removal);
557
+ else await workspaces.abortRemoval(removal);
558
+ throw error;
559
+ }
560
+ });
561
+
562
+ return new Proxy(controller, {
563
+ get(target, property) {
564
+ if (property === 'updateWorkspace') return updateWorkspace;
565
+ const value = Reflect.get(target, property, target);
566
+ if (typeof value !== 'function') return value;
567
+ if (property === 'deleteBot') {
568
+ return (botId, ...args) => deleteWithWorkspace(
569
+ botId,
570
+ () => value.call(target, botId, ...args),
571
+ );
572
+ }
573
+ if (property === 'disconnect') {
574
+ return async (...args) => {
575
+ const before = await target.status();
576
+ const botId = before?.bots?.[0]?.botId;
577
+ if (!botId) return decorateResult(workspaces, value.apply(target, args));
578
+ return deleteWithWorkspace(botId, () => value.apply(target, args));
579
+ };
580
+ }
581
+ return (...args) => decorateResult(workspaces, value.apply(target, args));
582
+ },
583
+ });
584
+ }
@@ -57,6 +57,11 @@ export class ConversationStateStore {
57
57
  await this.#persist();
58
58
  }
59
59
 
60
+ async clearSessions() {
61
+ this.#state.sessions = {};
62
+ await this.#persist();
63
+ }
64
+
60
65
  hasSeen(messageId) {
61
66
  return this.#state.seenMessageIds.includes(messageId);
62
67
  }