mu-agent-bridge 1.0.35 → 1.0.40

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 (60) hide show
  1. package/README.md +2 -0
  2. package/dist/src-ts/channel-sessions.js +90 -24
  3. package/dist/src-ts/cli.js +1 -1
  4. package/dist/src-ts/control-server.js +48 -7
  5. package/dist/src-ts/plugin-manager.js +47 -0
  6. package/dist/src-ts/plugins/chatgpt/cdp-client.js +72 -0
  7. package/dist/src-ts/plugins/chatgpt/index.js +122 -4
  8. package/dist/src-ts/plugins/doubao/cdp-client.js +243 -18
  9. package/dist/src-ts/plugins/doubao/index.js +81 -5
  10. package/dist/src-ts/plugins/doubao/session-history.js +123 -0
  11. package/dist/src-ts/plugins/dsh/index.js +16 -2
  12. package/dist/src-ts/plugins/dsh/session-history.js +371 -0
  13. package/dist/src-ts/plugins/qwenwork/cdp-client.js +8 -0
  14. package/dist/src-ts/plugins/qwenwork/index.js +12 -1
  15. package/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
  16. package/dist/src-ts/plugins/workbuddy/cdp-client.js +33 -0
  17. package/dist/src-ts/plugins/workbuddy/index.js +21 -1
  18. package/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
  19. package/dist/src-ts/session-archive.js +509 -0
  20. package/dist/src-ts/session-history.js +2 -0
  21. package/dist/src-ts/session-management.js +665 -0
  22. package/dist/src-ts/session-summary.js +171 -0
  23. package/dist/tests-ts/channel-sessions.test.js +233 -2
  24. package/dist/tests-ts/chatgpt-history.test.js +197 -0
  25. package/dist/tests-ts/control-server.test.js +39 -0
  26. package/dist/tests-ts/doubao-history.test.js +172 -0
  27. package/dist/tests-ts/doubao-plugin.test.js +124 -3
  28. package/dist/tests-ts/dsh-history.test.js +165 -0
  29. package/dist/tests-ts/native-session-management.test.js +195 -0
  30. package/dist/tests-ts/plugin-manager.test.js +254 -3
  31. package/dist/tests-ts/qwenwork-history.test.js +339 -0
  32. package/dist/tests-ts/session-archive.test.js +422 -0
  33. package/dist/tests-ts/session-management.test.js +323 -0
  34. package/dist/tests-ts/session-summary.test.js +199 -0
  35. package/dist/tests-ts/workbuddy-history.test.js +256 -0
  36. package/package.json +1 -1
  37. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +72 -0
  38. package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +122 -4
  39. package/plugins/chatgpt/package.json +1 -1
  40. package/plugins/chatgpt/plugin.json +14 -1
  41. package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +243 -18
  42. package/plugins/doubao/dist/src-ts/plugins/doubao/index.js +81 -5
  43. package/plugins/doubao/dist/src-ts/plugins/doubao/session-history.js +123 -0
  44. package/plugins/doubao/package.json +1 -1
  45. package/plugins/doubao/plugin.json +1 -1
  46. package/plugins/dsh/config.schema.json +2 -1
  47. package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +16 -2
  48. package/plugins/dsh/dist/src-ts/plugins/dsh/session-history.js +371 -0
  49. package/plugins/dsh/package.json +1 -1
  50. package/plugins/dsh/plugin.json +1 -1
  51. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +8 -0
  52. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +12 -1
  53. package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
  54. package/plugins/qwenwork/package.json +1 -1
  55. package/plugins/qwenwork/plugin.json +1 -1
  56. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +33 -0
  57. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +21 -1
  58. package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
  59. package/plugins/workbuddy/package.json +1 -1
  60. package/plugins/workbuddy/plugin.json +1 -1
package/README.md CHANGED
@@ -44,6 +44,8 @@ abweb 的“配置码”栏;配对码 10 分钟有效,只能使用一次,
44
44
  `AGENT_BRIDGE_PLUGINS_DIR`、`AGENT_BRIDGE_STATE_DIR`。默认插件目录是仓库的 `plugins/`,状态目录
45
45
  是 Windows 的 `%APPDATA%\agent-bridge\state`。
46
46
 
47
+ 跨会话读取支持按来源授权的 AUN/飞书托管记录,以及另外授权的 ChatGPT、WorkBuddy 受限原生历史,并通过独立无工具端点总结。归档默认关闭,千问办公暂不支持原生历史。配置与 `/sessions` 命令见[跨会话读取说明](docs/cross-session-reading.md)。
48
+
47
49
  ## npm 插件
48
50
 
49
51
  默认包 `mu-agent-bridge` 已内置 abweb、AUN、千问办公、DSH、豆包、飞书、WorkBuddy 和 ChatGPT/Codex 八个插件,直接安装即可使用:
@@ -1,13 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ChannelSessions = void 0;
4
+ exports.channelSourceKey = channelSourceKey;
4
5
  const node_crypto_1 = require("node:crypto");
5
6
  const promises_1 = require("node:fs/promises");
6
7
  const node_path_1 = require("node:path");
8
+ function channelSourceKey(message) {
9
+ return JSON.stringify([message.channel, message.recipientId || "", message.chatId || "", message.peerId, message.threadId || ""]);
10
+ }
11
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
12
+ const validSession = (value) => {
13
+ if (!isRecord(value))
14
+ return false;
15
+ return [value.source, value.agentPlugin, value.sessionId, value.updatedAt].every(item => typeof item === "string")
16
+ && (value.newSession === undefined || typeof value.newSession === "boolean");
17
+ };
7
18
  const unsupportedCommands = /^(?:\/(?:s|session|slist|name|rename|del|status|stop|pause|resume|compact|fork|rewind|rw|repair|renew))(?:[ \t]+[^\r\n]*)?$/;
8
19
  class ChannelSessions {
9
20
  directory;
10
21
  sessions = {};
22
+ active = {};
11
23
  loaded;
12
24
  writes = Promise.resolve();
13
25
  pending = new Map();
@@ -18,25 +30,87 @@ class ChannelSessions {
18
30
  this.loaded ??= (async () => {
19
31
  try {
20
32
  const data = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)(this.directory, "channel-sessions.json"), "utf8"));
21
- if (!data || typeof data !== "object" || Array.isArray(data))
33
+ if (!isRecord(data))
22
34
  throw new Error("通道会话文件格式无效");
23
- for (const [key, value] of Object.entries(data)) {
24
- const entry = value;
25
- if (!entry || typeof entry.sessionId !== "string" || typeof entry.source !== "string" || typeof entry.agentPlugin !== "string" || typeof entry.updatedAt !== "string" || (entry.newSession !== undefined && typeof entry.newSession !== "boolean"))
26
- throw new Error("通道会话记录无效");
27
- this.sessions[key] = entry;
35
+ const sessions = {};
36
+ const active = {};
37
+ if (data.version === 1) {
38
+ if (!isRecord(data.sessions) || !isRecord(data.active))
39
+ throw new Error("通道会话文件格式无效");
40
+ for (const [id, entry] of Object.entries(data.sessions)) {
41
+ if (!validSession(entry) || entry.logicalSessionId !== id || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id))
42
+ throw new Error("通道会话记录无效");
43
+ sessions[id] = entry;
44
+ }
45
+ for (const [source, id] of Object.entries(data.active)) {
46
+ if (typeof id !== "string" || sessions[id]?.source !== source)
47
+ throw new Error("通道活动绑定无效");
48
+ active[source] = id;
49
+ }
50
+ }
51
+ else {
52
+ for (const [source, entry] of Object.entries(data)) {
53
+ if (!validSession(entry) || entry.source !== source)
54
+ throw new Error("通道会话记录无效");
55
+ const logicalSessionId = (0, node_crypto_1.randomUUID)();
56
+ sessions[logicalSessionId] = { ...entry, logicalSessionId };
57
+ active[source] = logicalSessionId;
58
+ }
59
+ }
60
+ if (data.version !== 1)
61
+ await this.persist(active, sessions);
62
+ else {
63
+ this.sessions = sessions;
64
+ this.active = active;
28
65
  }
29
66
  }
30
67
  catch (error) {
31
- if (error.code !== "ENOENT")
68
+ if (error.code !== "ENOENT") {
69
+ this.loaded = undefined;
32
70
  throw error;
71
+ }
33
72
  }
34
73
  })();
35
74
  await this.loaded;
36
75
  }
37
- async list() { await this.load(); return Object.values(this.sessions).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); }
76
+ async persist(active, sessions) {
77
+ await (0, promises_1.mkdir)(this.directory, { recursive: true, mode: 0o700 });
78
+ await (0, promises_1.chmod)(this.directory, 0o700);
79
+ const file = (0, node_path_1.join)(this.directory, "channel-sessions.json");
80
+ const temporary = `${file}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`;
81
+ try {
82
+ await (0, promises_1.writeFile)(temporary, `${JSON.stringify({ version: 1, active, sessions }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
83
+ await (0, promises_1.rename)(temporary, file);
84
+ }
85
+ finally {
86
+ await (0, promises_1.rm)(temporary, { force: true });
87
+ }
88
+ this.active = active;
89
+ this.sessions = sessions;
90
+ }
91
+ save(entry) {
92
+ const save = this.writes.catch(() => { }).then(() => this.persist({ ...this.active, [entry.source]: entry.logicalSessionId }, { ...this.sessions, [entry.logicalSessionId]: entry }));
93
+ this.writes = save;
94
+ return save;
95
+ }
96
+ async list() { await this.load(); return Object.values(this.sessions).map(entry => ({ ...entry })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); }
97
+ isBusy(source) { return this.pending.has(source); }
98
+ async current(message, agentPlugin) {
99
+ await this.load();
100
+ const source = channelSourceKey(message);
101
+ const current = this.writes.catch(() => { }).then(async () => {
102
+ const entry = this.sessions[this.active[source]];
103
+ if (entry)
104
+ return { ...entry };
105
+ const next = { source, logicalSessionId: (0, node_crypto_1.randomUUID)(), agentPlugin, sessionId: (0, node_crypto_1.randomUUID)(), updatedAt: new Date().toISOString(), newSession: true };
106
+ await this.persist({ ...this.active, [source]: next.logicalSessionId }, { ...this.sessions, [next.logicalSessionId]: next });
107
+ return { ...next };
108
+ });
109
+ this.writes = current.then(() => undefined, () => undefined);
110
+ return current;
111
+ }
38
112
  async run(message, agentPlugin, call, control) {
39
- const source = JSON.stringify([message.channel, message.recipientId || "", message.chatId || "", message.peerId, message.threadId || ""]);
113
+ const source = channelSourceKey(message);
40
114
  const hasAttachments = Boolean(message.attachments?.length);
41
115
  const command = hasAttachments ? undefined : message.text?.trim().match(/^\/new(?:[ \t]+([^\r\n]+))?$/);
42
116
  const unsupported = hasAttachments ? undefined : message.text?.trim().match(unsupportedCommands);
@@ -59,25 +133,17 @@ class ChannelSessions {
59
133
  const previous = this.pending.get(source) ?? Promise.resolve();
60
134
  const turn = previous.catch(() => { }).then(async () => {
61
135
  await this.load();
62
- const entry = this.sessions[source];
63
- const sessionId = !command && entry?.agentPlugin === agentPlugin ? entry.sessionId : (0, node_crypto_1.randomUUID)();
136
+ const active = this.sessions[this.active[source]];
137
+ const entry = !command && active?.agentPlugin === agentPlugin ? active : undefined;
138
+ const sessionId = entry?.sessionId ?? (0, node_crypto_1.randomUUID)();
64
139
  const response = command
65
140
  ? await control({ operation: "new", sessionId, ...(command[1]?.trim() ? { name: command[1].trim() } : {}) })
66
- : await call(sessionId, !entry || entry.agentPlugin !== agentPlugin || entry.newSession === true);
141
+ : await call(sessionId, !entry || entry.newSession === true);
67
142
  if (command && (typeof response.sessionId !== "string" || !response.sessionId.trim()))
68
143
  throw new Error("插件未返回有效的新会话标识,原会话绑定保持不变");
69
- const next = { source, agentPlugin, sessionId: response.sessionId || sessionId, updatedAt: new Date().toISOString(), ...(command ? { newSession: true } : {}) };
70
- const save = this.writes.catch(() => { }).then(async () => {
71
- const snapshot = { ...this.sessions, [source]: next };
72
- await (0, promises_1.mkdir)(this.directory, { recursive: true });
73
- const file = (0, node_path_1.join)(this.directory, "channel-sessions.json");
74
- await (0, promises_1.writeFile)(`${file}.tmp`, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
75
- await (0, promises_1.rename)(`${file}.tmp`, file);
76
- this.sessions = snapshot;
77
- });
78
- this.writes = save;
79
- await save;
80
- return response;
144
+ const next = { source, logicalSessionId: entry?.logicalSessionId ?? (0, node_crypto_1.randomUUID)(), agentPlugin, sessionId: response.sessionId || sessionId, updatedAt: new Date().toISOString(), ...(command ? { newSession: true } : {}) };
145
+ await this.save(next);
146
+ return command ? response : { ...response, source, logicalSessionId: next.logicalSessionId, agentPlugin, request: message, response };
81
147
  });
82
148
  this.pending.set(source, turn);
83
149
  try {
@@ -235,7 +235,6 @@ async function serve() {
235
235
  await manager.discover();
236
236
  await manager.versions.load();
237
237
  const coreVersion = await Promise.resolve().then(() => __importStar(require("node:fs/promises"))).then(({ readFile }) => readFile((0, node_path_1.resolve)(__dirname, "../../package.json"), "utf8").then((value) => JSON.parse(value).version).catch(() => "0.0.0"));
238
- await checkAndUpgradePlugins(manager);
239
238
  const port = Number(process.env.AGENT_BRIDGE_PORT ?? "26107");
240
239
  const host = process.env.AGENT_BRIDGE_HOST ?? "127.0.0.1";
241
240
  if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1" && !process.env.AGENT_BRIDGE_TOKEN) {
@@ -264,6 +263,7 @@ async function serve() {
264
263
  logger.error(`plugin failed: ${id}`, { error: error instanceof Error ? error.message : String(error) });
265
264
  }
266
265
  }
266
+ await checkAndUpgradePlugins(manager);
267
267
  const intervalMs = Math.max(60_000, Number(process.env.AGENT_BRIDGE_PLUGIN_UPDATE_INTERVAL_MS ?? 300_000) || 300_000);
268
268
  const updateTimer = setInterval(() => void checkAndUpgradePlugins(manager), intervalMs);
269
269
  updateTimer.unref?.();
@@ -135,6 +135,36 @@ class ControlServer {
135
135
  const authorized = (Boolean(this.options.token) && authorization === `Bearer ${this.options.token}`) || (authorization?.startsWith("Bearer ") && (this.pairedTokens.get((0, node_crypto_1.createHash)("sha256").update(authorization.slice(7)).digest("hex")) ?? 0) > Date.now());
136
136
  if (this.options.token && !isLoopback(req.socket.remoteAddress) && !authorized && !(method === "GET" && url.pathname.startsWith("/abweb")))
137
137
  return this.json(res, 401, { error: "unauthorized" });
138
+ if (parts[0] === "api" && parts[1] === "session-history") {
139
+ if (!isLoopback(req.socket.remoteAddress) && !authorized)
140
+ return this.json(res, 401, { error: "会话归档管理仅允许本机或已认证调用方" });
141
+ const history = this.options.manager.sessionManagement;
142
+ const actor = authorized ? "authenticated" : "local";
143
+ if (method === "GET" && parts.length === 3 && parts[2] === "providers")
144
+ return this.json(res, 200, history.providers());
145
+ if (method === "GET" && parts.length === 3 && parts[2] === "config")
146
+ return this.json(res, 200, await history.config());
147
+ if (method === "PATCH" && parts.length === 3 && parts[2] === "config") {
148
+ await history.saveConfig(await this.body(req, 64 * 1024), actor);
149
+ return this.json(res, 200, { ok: true });
150
+ }
151
+ if (method === "DELETE" && parts.length === 2) {
152
+ await history.clear(actor);
153
+ return this.json(res, 200, { ok: true });
154
+ }
155
+ if (method === "DELETE" && parts.length === 4 && parts[2] === "sessions") {
156
+ await history.deleteSession(decodeURIComponent(parts[3]), actor);
157
+ return this.json(res, 200, { ok: true });
158
+ }
159
+ if (method === "POST" && parts.length === 3 && parts[2] === "delete-source") {
160
+ const body = await this.body(req, 64 * 1024);
161
+ if (typeof body.source !== "string")
162
+ return this.json(res, 400, { error: "需要有效的完整来源键" });
163
+ await history.deleteSource(body.source, actor);
164
+ return this.json(res, 200, { ok: true });
165
+ }
166
+ return this.json(res, 404, { error: "会话归档接口不存在" });
167
+ }
138
168
  if (parts[0] === "api" && parts[1] === "agents" && parts[2] && parts[3] === "v1") {
139
169
  const id = decodeURIComponent(parts[2]);
140
170
  if (req.headers["x-evolcore-session-id"] !== undefined && !authorized && !isLoopback(req.socket.remoteAddress))
@@ -312,14 +342,25 @@ class ControlServer {
312
342
  const loaded = this.options.manager.get("aun");
313
343
  return loaded?.record.status === "ready" ? loaded.instance?.identity : undefined;
314
344
  }
315
- async body(req) { let raw = ""; for await (const chunk of req)
316
- raw += chunk; if (!raw)
317
- return {}; try {
318
- return JSON.parse(raw);
345
+ async body(req, maxBytes = Infinity) {
346
+ const chunks = [];
347
+ let size = 0;
348
+ for await (const chunk of req) {
349
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
350
+ size += buffer.length;
351
+ if (size > maxBytes)
352
+ throw Object.assign(new Error("请求正文不能超过 64 KiB"), { statusCode: 413 });
353
+ chunks.push(buffer);
354
+ }
355
+ if (!size)
356
+ return {};
357
+ try {
358
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
359
+ }
360
+ catch {
361
+ throw new Error("请求正文必须是有效 JSON");
362
+ }
319
363
  }
320
- catch {
321
- throw new Error("invalid JSON body");
322
- } }
323
364
  json(res, status, value) { res.statusCode = status; res.setHeader("content-type", "application/json; charset=utf-8"); res.end(JSON.stringify(value)); }
324
365
  }
325
366
  exports.ControlServer = ControlServer;
@@ -18,6 +18,7 @@ const version_manager_1 = require("./version-manager");
18
18
  const plugin_catalog_1 = require("./plugin-catalog");
19
19
  const log_store_1 = require("./log-store");
20
20
  const channel_sessions_1 = require("./channel-sessions");
21
+ const session_management_1 = require("./session-management");
21
22
  const _2020_1 = __importDefault(require("ajv/dist/2020"));
22
23
  const attachments_1 = require("./attachments");
23
24
  const logger = { info() { }, warn() { }, error() { } };
@@ -30,6 +31,7 @@ class PluginManager {
30
31
  httpRoutes = new Map();
31
32
  root;
32
33
  messages = [];
34
+ activeIngress = new Map();
33
35
  trustConfig = defaultPluginTrustConfig();
34
36
  trustLoaded = false;
35
37
  publisherLookups = new Map();
@@ -37,12 +39,14 @@ class PluginManager {
37
39
  versions;
38
40
  logs;
39
41
  sessions;
42
+ sessionManagement;
40
43
  constructor(options) {
41
44
  this.options = options;
42
45
  this.root = (0, node_path_1.resolve)(options.pluginsDir);
43
46
  this.versions = options.versions ?? new version_manager_1.VersionManager({ stateDir: options.stateDir });
44
47
  this.logs = new log_store_1.LogStore((0, node_path_1.join)(options.stateDir, "logs"));
45
48
  this.sessions = new channel_sessions_1.ChannelSessions(options.stateDir);
49
+ this.sessionManagement = new session_management_1.SessionManagement(options.stateDir, (operation, fields) => this.log("info", operation, fields, "会话管理"), { list: () => this.sessions.list(), isBusy: source => this.sessions.isBusy(source) || this.activeIngress.has(source) }, () => [...this.plugins.entries()].flatMap(([pluginId, loaded]) => loaded.instance?.sessionHistory ? [{ pluginId, provider: loaded.instance.sessionHistory, identity: loaded.instance, status: loaded.record.status }] : []));
46
50
  }
47
51
  log(level, message, fields, source = "mab") { this.logs.append({ time: new Date().toISOString(), level, source, message, ...(fields ? { fields } : {}) }); }
48
52
  setControlUrl(url) { this.controlUrl = url; }
@@ -375,8 +379,27 @@ class PluginManager {
375
379
  pluginStatus: (targetId) => this.plugins.get(targetId)?.record.status,
376
380
  ingress: {
377
381
  publish: async (message) => {
382
+ if (message.channel !== id)
383
+ throw new agent_1.AgentError("消息来源与通道插件身份不一致");
384
+ const trustedChannel = loaded.record.type === "channel" && (id === "aun" || id === "feishu");
385
+ if (trustedChannel && (typeof message.peerId !== "string" || !message.peerId.trim() || [message.peerId, message.recipientId, message.chatId, message.threadId].some(value => value !== undefined && (typeof value !== "string" || value.length > 512 || /[\u0000-\u001f*]/.test(value)))))
386
+ throw new agent_1.AgentError("通道来源标识无效");
378
387
  const target = message.agentPlugin || "qwenwork";
388
+ if (/^\/sessions(?:\s|$)/.test(message.text.trimStart())) {
389
+ if (!trustedChannel)
390
+ throw new agent_1.AgentError("当前通道不支持会话管理");
391
+ const current = await this.sessions.current(message, target);
392
+ const result = await this.sessionManagement.command(message.text, { source: current.source, sessionId: current.logicalSessionId });
393
+ await message.reply(result);
394
+ return;
395
+ }
396
+ if (trustedChannel)
397
+ await this.sessionManagement.ready();
398
+ const archiveToken = trustedChannel ? this.sessionManagement.archiveWriteToken((0, channel_sessions_1.channelSourceKey)(message)) : undefined;
379
399
  this.recordMessage(target, "request", message.text, message.peerId);
400
+ const source = (0, channel_sessions_1.channelSourceKey)(message);
401
+ if (trustedChannel)
402
+ this.activeIngress.set(source, (this.activeIngress.get(source) ?? 0) + 1);
380
403
  try {
381
404
  const instance = this.plugins.get(target)?.instance;
382
405
  const control = instance?.sessionOperations?.includes("new")
@@ -384,11 +407,13 @@ class PluginManager {
384
407
  : undefined;
385
408
  const task = !message.text.trim() && message.attachments?.length ? "请处理收到的附件" : message.text;
386
409
  const forwarded = new Set();
410
+ const sent = [];
387
411
  const onMessage = async (text) => {
388
412
  if (!text.trim() || forwarded.has(text))
389
413
  return;
390
414
  await message.reply(text);
391
415
  forwarded.add(text);
416
+ sent.push({ text });
392
417
  this.recordMessage(target, "reply", text, message.peerId);
393
418
  };
394
419
  const response = await this.sessions.run(message, target, (sessionId, newSession) => this.handle(target, { task, sessionId, onMessage, ...(newSession ? { newSession: true } : {}), ...(message.attachments ? { attachments: message.attachments } : {}) }), control);
@@ -400,15 +425,37 @@ class PluginManager {
400
425
  throw new agent_1.AgentError("当前通道不支持附件回传");
401
426
  }
402
427
  await message.replyAttachments(attachments, response.text);
428
+ sent.push({ text: response.text, attachments });
403
429
  this.recordMessage(target, "reply", response.text, message.peerId);
404
430
  }
405
431
  else
406
432
  await onMessage(response.text);
433
+ if (trustedChannel && response.source && response.logicalSessionId) {
434
+ const identity = { sessionId: response.logicalSessionId, source: response.source, principal: response.source, channel: message.channel, agentPlugin: target, completed: true, time: new Date().toISOString() };
435
+ const requestId = message.messageId || (0, node_crypto_1.randomUUID)();
436
+ const metadata = (items) => items?.map(item => ({ filename: item.filename, contentType: item.contentType, sizeBytes: item.data.byteLength }));
437
+ try {
438
+ await this.sessionManagement.recordIngressTurns(source, archiveToken, [
439
+ { ...identity, role: "user", text: message.text, requestId, messageId: message.messageId ? `${message.messageId}:user` : undefined, attachments: metadata(message.attachments) },
440
+ ...sent.map((item, index) => ({ ...identity, role: "assistant", text: item.text, requestId, messageId: message.messageId ? `${message.messageId}:assistant:${index}` : undefined, attachments: metadata(item.attachments) })),
441
+ ]);
442
+ }
443
+ catch {
444
+ this.log("warn", "会话归档失败", { sessionId: identity.sessionId, status: "failed" }, "会话管理");
445
+ }
446
+ }
407
447
  }
408
448
  catch (error) {
409
449
  this.recordMessage(target, "error", error instanceof Error ? error.message : String(error), message.peerId);
410
450
  throw error;
411
451
  }
452
+ finally {
453
+ const remaining = (this.activeIngress.get(source) ?? 1) - 1;
454
+ if (remaining)
455
+ this.activeIngress.set(source, remaining);
456
+ else
457
+ this.activeIngress.delete(source);
458
+ }
412
459
  },
413
460
  },
414
461
  runtime: { controlUrl: this.controlUrl, ports: {
@@ -95,6 +95,78 @@ class ChatgptCdpClient {
95
95
  })()`);
96
96
  return (0, session_store_1.validSession)(entry) ? entry : undefined;
97
97
  }
98
+ async historyBusy() {
99
+ return Boolean(await this.evaluate(`Boolean(document.querySelector(${JSON.stringify(stopButtonSelector)}))`));
100
+ }
101
+ async readSessionHistory(session) {
102
+ if (!this.connected())
103
+ return { supported: false, reason: "ChatGPT CDP 当前未连接" };
104
+ const before = await this.activeSession();
105
+ if (!before || before.id !== session.id || before.host !== session.host)
106
+ return { supported: false, reason: "当前活动会话身份与请求不一致" };
107
+ if (await this.historyBusy())
108
+ return { supported: false, reason: "ChatGPT 正在生成回复,暂不读取历史" };
109
+ const raw = await this.evaluate(`(() => {
110
+ const selected = document.querySelector('[data-app-action-sidebar-thread-selected="true"], [data-app-action-sidebar-thread-active="true"]');
111
+ if (selected?.getAttribute('data-app-action-sidebar-thread-id') !== ${JSON.stringify(session.id)}
112
+ || selected?.getAttribute('data-app-action-sidebar-thread-host-id') !== ${JSON.stringify(session.host)}) return { supported: false, reason: '读取期间活动会话身份发生变化' };
113
+ if (document.querySelector(${JSON.stringify(stopButtonSelector)})) return { supported: false, reason: 'ChatGPT 正在生成回复,暂不读取历史' };
114
+ const turns = [...document.querySelectorAll(${JSON.stringify(exports.turnSelector)})];
115
+ const output = [];
116
+ const missingReasons = [];
117
+ let chars = 0;
118
+ const collect = (role, text) => {
119
+ chars += text.length;
120
+ if (chars > 60000) throw new Error('ChatGPT 会话正文超过 60,000 字符上限');
121
+ output.push({ role, text });
122
+ };
123
+ for (const turn of turns) {
124
+ const turnId = turn.getAttribute(${JSON.stringify(turnIdAttribute)});
125
+ if (!turnId) {
126
+ missingReasons.push('部分消息缺少可验证的 turn 身份');
127
+ continue;
128
+ }
129
+ const users = [...turn.querySelectorAll(${JSON.stringify(exports.userBubbleSelector)})];
130
+ for (const item of users) {
131
+ const body = item.querySelector('[data-markdown-text-tone="user-message"]');
132
+ if (!body) {
133
+ missingReasons.push('部分用户消息缺少专用正文标记,已跳过');
134
+ continue;
135
+ }
136
+ const text = body.innerText?.trim();
137
+ if (text) collect('user', text);
138
+ }
139
+ const assistants = [...turn.querySelectorAll(${JSON.stringify(assistantMessageSelector)})];
140
+ for (const item of assistants) {
141
+ const marker = item.closest('[data-response-annotation-target][data-response-annotation-conversation]');
142
+ const excluded = item.closest('[data-message-author-role="tool"], [data-message-author-role="system"], [data-message-channel="analysis"], [data-message-channel="commentary"], [data-reasoning], [data-is-streaming="true"], [data-streaming="true"], [aria-busy="true"]');
143
+ const text = item.innerText?.trim();
144
+ if (marker?.getAttribute('data-response-annotation-conversation') === ${JSON.stringify(session.id.replace(/^local:/, ""))}
145
+ && marker.getAttribute('data-response-annotation-target') && !excluded && text) collect('assistant', text);
146
+ }
147
+ }
148
+ return { supported: true, turns: output, missingReasons };
149
+ })()`);
150
+ const after = await this.activeSession();
151
+ if (!after || after.id !== session.id || after.host !== session.host)
152
+ return { supported: false, reason: "读取期间活动会话身份发生变化" };
153
+ if (await this.historyBusy())
154
+ return { supported: false, reason: "ChatGPT 正在生成回复,暂不读取历史" };
155
+ const result = raw;
156
+ if (result?.supported === false && typeof result.reason === "string")
157
+ return { supported: false, reason: result.reason };
158
+ if (!result || !Array.isArray(result.turns) || !result.turns.every((turn) => {
159
+ if (!turn || typeof turn !== "object")
160
+ return false;
161
+ const value = turn;
162
+ return (value.role === "user" || value.role === "assistant") && typeof value.text === "string";
163
+ })) {
164
+ return { supported: false, reason: "ChatGPT 会话正文格式无法验证" };
165
+ }
166
+ const missingReasons = Array.isArray(result.missingReasons) ? result.missingReasons.filter((item) => typeof item === "string") : [];
167
+ missingReasons.push("仅包含当前页面已加载的可验证正文,页面虚拟化或未加载历史导致完整性无法确认");
168
+ return { supported: true, turns: result.turns, complete: false, missingReasons };
169
+ }
98
170
  async switchSession(session) {
99
171
  await this.recoverDraft();
100
172
  const locate = `[...document.querySelectorAll(${JSON.stringify(sidebarSelector)})].find(item =>
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ChatgptCdpClient = exports.ChatgptPlugin = exports.chatgptManifest = void 0;
4
+ const node_crypto_1 = require("node:crypto");
4
5
  const cdp_client_1 = require("./cdp-client");
5
6
  const session_store_1 = require("./session-store");
6
7
  exports.chatgptManifest = {
7
8
  id: "chatgpt",
8
- version: "1.0.8",
9
+ version: "1.0.9",
9
10
  displayName: "ChatGPT(Codex)",
10
11
  type: "agent",
11
12
  entry: "dist/index.js",
@@ -28,11 +29,27 @@ class ChatgptPlugin {
28
29
  failure;
29
30
  remote = false;
30
31
  queue = Promise.resolve();
32
+ queuedOperations = 0;
31
33
  store;
32
34
  logger;
35
+ historyScope = "";
36
+ historyGeneration = 0;
37
+ historyProvider;
33
38
  constructor(options = {}) {
34
39
  this.options = options;
35
40
  }
41
+ get sessionHistory() {
42
+ const client = this.client;
43
+ const unsupported = !client?.listSessions || !client.activeSession || !client.readSessionHistory;
44
+ const generation = this.historyGeneration;
45
+ return this.historyProvider ??= {
46
+ scope: this.historyScope,
47
+ mode: unsupported ? "unsupported" : "sidebar",
48
+ limitation: unsupported ? "当前 ChatGPT 客户端不提供只读历史接口" : "仅列举已加载侧栏;仅读取当前活动会话中已加载且可验证的正文,完整性可能受页面虚拟化影响",
49
+ listSessions: query => generation === this.historyGeneration ? this.listNativeSessions(query) : Promise.resolve({ supported: false, reason: "ChatGPT 插件实例已变化,请重新列举历史" }),
50
+ readSession: query => generation === this.historyGeneration ? this.readNativeSession(query) : Promise.resolve({ supported: false, reason: "ChatGPT 插件实例已变化,请重新列举历史" }),
51
+ };
52
+ }
36
53
  async activate(context) {
37
54
  const config = context.config;
38
55
  const hasSshHost = config.sshHost !== undefined;
@@ -53,6 +70,11 @@ class ChatgptPlugin {
53
70
  localPort: port(config.localCdpPort, 9230),
54
71
  } : undefined;
55
72
  this.remote = Boolean(ssh);
73
+ this.historyGeneration++;
74
+ this.historyProvider = undefined;
75
+ this.historyScope = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(ssh
76
+ ? [ssh.host, ssh.user, ssh.port, ssh.remotePort]
77
+ : ["local", port(config.localCdpPort, 9230)])).digest("hex").slice(0, 24);
56
78
  this.logger = context.logger;
57
79
  this.store = new session_store_1.ChatgptSessionStore(context.dataDir, JSON.stringify(ssh
58
80
  ? [ssh.host, ssh.user, ssh.port, ssh.remotePort]
@@ -93,8 +115,7 @@ class ChatgptPlugin {
93
115
  throw new Error("消息内容不能为空");
94
116
  if (!this.client || !this.store)
95
117
  throw new Error("ChatGPT 插件未启动");
96
- const run = this.queue.then(() => this.run(request));
97
- this.queue = run.then(() => undefined, () => undefined);
118
+ const run = this.enqueue(() => this.run(request));
98
119
  const result = await run;
99
120
  return { ...(typeof result === "string" ? { text: result } : result), sessionId: request.sessionId };
100
121
  }
@@ -106,7 +127,7 @@ class ChatgptPlugin {
106
127
  throw new Error("ChatGPT 新会话缺少 sessionId");
107
128
  if (!this.client?.createSession || !this.client.listSessions || !this.store)
108
129
  throw new Error("ChatGPT 当前传输不支持新建会话");
109
- const run = this.queue.then(async () => {
130
+ const run = this.enqueue(async () => {
110
131
  const existing = new Set((await this.client.listSessions()).map(item => JSON.stringify([item.host, item.id])));
111
132
  const created = await this.client.createSession(request.name);
112
133
  if (created) {
@@ -120,9 +141,106 @@ class ChatgptPlugin {
120
141
  this.persist();
121
142
  return { text: "ChatGPT 已创建新会话", sessionId };
122
143
  });
144
+ return run;
145
+ }
146
+ enqueue(work) {
147
+ this.queuedOperations++;
148
+ const run = this.queue.then(work).finally(() => { this.queuedOperations--; });
123
149
  this.queue = run.then(() => undefined, () => undefined);
124
150
  return run;
125
151
  }
152
+ async listNativeSessions(query) {
153
+ const client = this.client;
154
+ const reject = (reason) => ({ supported: false, reason });
155
+ if (!client?.listSessions || !client.activeSession || !client.readSessionHistory)
156
+ return reject("当前 ChatGPT 客户端不支持只读历史");
157
+ const listSessions = client.listSessions.bind(client);
158
+ if (!client.connected())
159
+ return reject("ChatGPT CDP 当前未连接");
160
+ if (this.queuedOperations)
161
+ return reject("ChatGPT 正在处理其他操作,历史读取已拒绝");
162
+ if (!Number.isInteger(query.limit) || query.limit < 1)
163
+ throw new Error("ChatGPT 历史页条数无效");
164
+ if (query.cursor !== undefined && !/^list:[a-f0-9]{64}:\d+$/.test(query.cursor))
165
+ throw new Error("ChatGPT 历史游标无效");
166
+ return this.enqueue(async () => {
167
+ if (!client.connected())
168
+ return reject("ChatGPT CDP 当前未连接");
169
+ if (await client.historyBusy?.())
170
+ return reject("ChatGPT 正在生成回复,暂不读取历史");
171
+ const entries = await listSessions();
172
+ const fingerprint = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([this.historyScope, this.historyGeneration, entries.map(({ host, id, title }) => [host, id, title])])).digest("hex");
173
+ const offset = query.cursor ? Number(query.cursor.split(":").at(-1)) : 0;
174
+ if (query.cursor !== undefined && !query.cursor.startsWith(`list:${fingerprint}:`))
175
+ throw new Error("ChatGPT 侧栏已变化,历史游标失效");
176
+ if (!Number.isSafeInteger(offset) || (query.cursor !== undefined && offset >= entries.length))
177
+ throw new Error("ChatGPT 历史游标位置无效");
178
+ const limit = Math.min(query.limit, 50);
179
+ const sessions = entries.slice(offset, offset + limit).map(item => ({ id: JSON.stringify([item.host, item.id]), title: item.title, ...(item.updatedAt ? { updatedAt: item.updatedAt } : {}) }));
180
+ return { supported: true, sessions, ...(offset + limit < entries.length ? { nextCursor: `list:${fingerprint}:${offset + limit}` } : {}), complete: false, missingReasons: ["仅包含 ChatGPT 当前已加载的侧栏项目,不代表全部历史会话"], readAt: new Date().toISOString() };
181
+ });
182
+ }
183
+ async readNativeSession(query) {
184
+ const client = this.client;
185
+ const reject = (reason) => ({ supported: false, reason });
186
+ if (!client?.listSessions || !client.activeSession || !client.readSessionHistory)
187
+ return reject("当前 ChatGPT 客户端不支持只读历史");
188
+ const listSessions = client.listSessions.bind(client);
189
+ const activeSession = client.activeSession.bind(client);
190
+ const readSessionHistory = client.readSessionHistory.bind(client);
191
+ if (!client.connected())
192
+ return reject("ChatGPT CDP 当前未连接");
193
+ if (this.queuedOperations)
194
+ return reject("ChatGPT 正在处理其他操作,历史读取已拒绝");
195
+ if (!Number.isInteger(query.limit) || query.limit < 1)
196
+ throw new Error("ChatGPT 历史页条数无效");
197
+ if (query.cursor !== undefined && !/^read:[a-f0-9]{64}:\d+$/.test(query.cursor))
198
+ throw new Error("ChatGPT 历史游标无效");
199
+ let requested;
200
+ try {
201
+ requested = JSON.parse(query.sessionId);
202
+ }
203
+ catch {
204
+ return reject("ChatGPT 会话 ID 无效");
205
+ }
206
+ if (!Array.isArray(requested) || requested.length !== 2 || requested.some(part => typeof part !== "string" || !part))
207
+ return reject("ChatGPT 会话 ID 无效");
208
+ return this.enqueue(async () => {
209
+ if (!client.connected())
210
+ return reject("ChatGPT CDP 当前未连接");
211
+ if (await client.historyBusy?.())
212
+ return reject("ChatGPT 正在生成回复,暂不读取历史");
213
+ const sessions = await listSessions();
214
+ const session = sessions.find(item => item.host === requested[0] && item.id === requested[1]);
215
+ if (!session)
216
+ return reject("目标会话不在当前 ChatGPT 侧栏中");
217
+ const active = await activeSession();
218
+ if (!active || active.host !== session.host || active.id !== session.id)
219
+ return reject("ChatGPT 只支持读取当前活动会话");
220
+ if (await client.historyBusy?.())
221
+ return reject("ChatGPT 正在生成回复,暂不读取历史");
222
+ const result = await readSessionHistory(session);
223
+ if (!result.supported)
224
+ return reject(result.reason);
225
+ const after = await activeSession();
226
+ if (!after || after.host !== session.host || after.id !== session.id)
227
+ return reject("读取期间活动会话身份发生变化");
228
+ const fingerprint = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([this.historyScope, this.historyGeneration, session.host, session.id, result.turns])).digest("hex");
229
+ const offset = query.cursor ? Number(query.cursor.split(":").at(-1)) : 0;
230
+ if (query.cursor !== undefined && !query.cursor.startsWith(`read:${fingerprint}:`))
231
+ throw new Error("ChatGPT 会话内容已变化,历史游标失效");
232
+ if (!Number.isSafeInteger(offset) || (query.cursor !== undefined && offset >= result.turns.length))
233
+ throw new Error("ChatGPT 历史游标位置无效");
234
+ const turns = result.turns.map(turn => ({ ...turn }));
235
+ const chars = turns.reduce((total, turn) => total + turn.text.length, 0);
236
+ if (chars > 60_000)
237
+ throw new Error("ChatGPT 会话正文超过 60,000 字符上限");
238
+ const limit = Math.min(query.limit, 100);
239
+ const pageTurns = turns.slice(offset, offset + limit);
240
+ const reasons = [...result.missingReasons, "仅包含当前页面已加载的可验证正文,页面虚拟化或未加载历史导致完整性无法确认"];
241
+ return { supported: true, session: { id: query.sessionId, title: session.title, ...(session.updatedAt ? { updatedAt: session.updatedAt } : {}) }, turns: pageTurns, ...(offset + limit < turns.length ? { nextCursor: `read:${fingerprint}:${offset + limit}` } : {}), complete: false, missingReasons: reasons, readAt: new Date().toISOString() };
242
+ });
243
+ }
126
244
  persist() {
127
245
  void this.store.save().catch(error => this.logger?.warn("ChatGPT 会话缓存保存失败", { error: String(error) }));
128
246
  }