mu-agent-bridge 1.0.34 → 1.0.39
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 +4 -2
- package/dist/src-ts/channel-sessions.js +90 -24
- package/dist/src-ts/control-server.js +48 -7
- package/dist/src-ts/dsh-agent.js +141 -46
- package/dist/src-ts/headless-account.patch.yml +8 -1
- package/dist/src-ts/plugin-manager.js +69 -3
- package/dist/src-ts/plugins/aun/attachments.js +44 -13
- package/dist/src-ts/plugins/aun/index.js +5 -6
- package/dist/src-ts/plugins/chatgpt/cdp-client.js +342 -9
- package/dist/src-ts/plugins/chatgpt/index.js +127 -7
- package/dist/src-ts/plugins/desktop-launch.js +19 -0
- package/dist/src-ts/plugins/doubao/attachments.js +42 -0
- package/dist/src-ts/plugins/doubao/cdp-client.js +530 -48
- package/dist/src-ts/plugins/doubao/index.js +114 -25
- package/dist/src-ts/plugins/doubao/session-history.js +123 -0
- package/dist/src-ts/plugins/dsh/index.js +18 -2
- package/dist/src-ts/plugins/dsh/session-history.js +371 -0
- package/dist/src-ts/plugins/qwenwork/cdp-client.js +271 -10
- package/dist/src-ts/plugins/qwenwork/index.js +42 -3
- package/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
- package/dist/src-ts/plugins/workbuddy/cdp-client.js +42 -2
- package/dist/src-ts/plugins/workbuddy/index.js +23 -3
- package/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
- package/dist/src-ts/session-archive.js +509 -0
- package/dist/src-ts/session-history.js +2 -0
- package/dist/src-ts/session-management.js +665 -0
- package/dist/src-ts/session-summary.js +171 -0
- package/dist/tests-ts/aun-attachments.test.js +41 -3
- package/dist/tests-ts/aun-plugin.test.js +28 -5
- package/dist/tests-ts/bundled-package.test.js +3 -1
- package/dist/tests-ts/channel-sessions.test.js +255 -2
- package/dist/tests-ts/chatgpt-history.test.js +197 -0
- package/dist/tests-ts/chatgpt-plugin.test.js +279 -2
- package/dist/tests-ts/control-server.test.js +39 -0
- package/dist/tests-ts/desktop-launch.test.js +41 -0
- package/dist/tests-ts/doubao-history.test.js +172 -0
- package/dist/tests-ts/doubao-plugin.test.js +549 -2
- package/dist/tests-ts/dsh-agent.test.js +199 -0
- package/dist/tests-ts/dsh-history.test.js +165 -0
- package/dist/tests-ts/native-session-management.test.js +195 -0
- package/dist/tests-ts/plugin-manager.test.js +354 -4
- package/dist/tests-ts/qwenwork-history.test.js +339 -0
- package/dist/tests-ts/qwenwork-images.test.js +166 -0
- package/dist/tests-ts/qwenwork-plugin.test.js +40 -1
- package/dist/tests-ts/session-archive.test.js +422 -0
- package/dist/tests-ts/session-management.test.js +323 -0
- package/dist/tests-ts/session-summary.test.js +199 -0
- package/dist/tests-ts/workbuddy-history.test.js +256 -0
- package/dist/tests-ts/workbuddy-plugin.test.js +43 -0
- package/package.json +1 -1
- package/plugins/aun/dist/src-ts/plugins/aun/attachments.js +44 -13
- package/plugins/aun/dist/src-ts/plugins/aun/index.js +5 -6
- package/plugins/aun/package.json +1 -1
- package/plugins/aun/plugin.json +1 -1
- package/plugins/chatgpt/dist/src-ts/attachments.js +62 -0
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +342 -9
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +127 -7
- package/plugins/chatgpt/package.json +1 -1
- package/plugins/chatgpt/plugin.json +14 -1
- package/plugins/doubao/config.schema.json +3 -0
- package/plugins/doubao/dist/src-ts/attachments.js +62 -0
- package/plugins/doubao/dist/src-ts/plugins/desktop-launch.js +19 -0
- package/plugins/doubao/dist/src-ts/plugins/doubao/attachments.js +42 -0
- package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +530 -48
- package/plugins/doubao/dist/src-ts/plugins/doubao/index.js +114 -25
- package/plugins/doubao/dist/src-ts/plugins/doubao/session-history.js +123 -0
- package/plugins/doubao/package.json +1 -1
- package/plugins/doubao/plugin.json +1 -1
- package/plugins/dsh/config.schema.json +2 -1
- package/plugins/dsh/dist/src-ts/attachments.js +62 -0
- package/plugins/dsh/dist/src-ts/dsh-agent.js +141 -46
- package/plugins/dsh/dist/src-ts/headless-account.patch.yml +8 -1
- package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +18 -2
- package/plugins/dsh/dist/src-ts/plugins/dsh/session-history.js +371 -0
- package/plugins/dsh/package.json +5 -2
- package/plugins/dsh/plugin.json +1 -1
- package/plugins/qwenwork/dist/src-ts/attachments.js +62 -0
- package/plugins/qwenwork/dist/src-ts/plugins/desktop-launch.js +19 -0
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +271 -10
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +42 -3
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
- package/plugins/qwenwork/package.json +1 -1
- package/plugins/qwenwork/plugin.json +1 -1
- package/plugins/workbuddy/config.schema.json +1 -1
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +42 -2
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +23 -3
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
- package/plugins/workbuddy/package.json +1 -1
- package/plugins/workbuddy/plugin.json +1 -1
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SessionArchive = exports.SESSION_ARCHIVE_LIMITS = void 0;
|
|
4
|
+
const promises_1 = require("node:fs/promises");
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
exports.SESSION_ARCHIVE_LIMITS = {
|
|
8
|
+
maxTextBytes: 64 * 1024,
|
|
9
|
+
maxSessionBytes: 8 * 1024 * 1024,
|
|
10
|
+
maxTotalBytes: 256 * 1024 * 1024,
|
|
11
|
+
retentionDays: 30,
|
|
12
|
+
maxSessions: 1000,
|
|
13
|
+
maxTurnsPerSession: 2000,
|
|
14
|
+
maxListPageSize: 50,
|
|
15
|
+
maxReadPageSize: 100,
|
|
16
|
+
};
|
|
17
|
+
const META_FILE = "metadata.json";
|
|
18
|
+
const FILE_MODE = 0o600;
|
|
19
|
+
const MAX_METADATA_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
function validTime(value) {
|
|
21
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
22
|
+
}
|
|
23
|
+
function validReasons(value) {
|
|
24
|
+
return Array.isArray(value) && value.length <= 32 && value.every((reason) => typeof reason === "string" && reason.length <= 512);
|
|
25
|
+
}
|
|
26
|
+
function validAttachments(value) {
|
|
27
|
+
return value === undefined || (Array.isArray(value) && value.length <= 8 && value.every((item) => item &&
|
|
28
|
+
typeof item.filename === "string" && item.filename.length > 0 && item.filename.length <= 512 && !/[\u0000-\u001f\u007f]/.test(item.filename) &&
|
|
29
|
+
typeof item.contentType === "string" && /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(item.contentType) &&
|
|
30
|
+
Number.isSafeInteger(item.sizeBytes) && item.sizeBytes >= 0));
|
|
31
|
+
}
|
|
32
|
+
async function boundedRead(path, maximum) {
|
|
33
|
+
const file = await (0, promises_1.open)(path, "r");
|
|
34
|
+
try {
|
|
35
|
+
const info = await file.stat();
|
|
36
|
+
if (!info.isFile() || info.size > maximum)
|
|
37
|
+
throw new Error("归档文件大小超出上限");
|
|
38
|
+
const buffer = Buffer.alloc(info.size + 1);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
while (offset < buffer.length) {
|
|
41
|
+
const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset);
|
|
42
|
+
if (!bytesRead)
|
|
43
|
+
break;
|
|
44
|
+
offset += bytesRead;
|
|
45
|
+
}
|
|
46
|
+
if (offset > info.size)
|
|
47
|
+
throw new Error("归档文件读取期间发生变化");
|
|
48
|
+
return buffer.toString("utf8", 0, offset);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await file.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validId(value, label) {
|
|
55
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 512 || /[\u0000\r\n]/.test(value))
|
|
56
|
+
throw new Error(`${label}无效`);
|
|
57
|
+
}
|
|
58
|
+
function validSource(value, label) {
|
|
59
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096 || /[\u0000\r\n]/.test(value))
|
|
60
|
+
throw new Error(`${label}无效`);
|
|
61
|
+
}
|
|
62
|
+
function fileFor(root, sessionId) {
|
|
63
|
+
return (0, node_path_1.join)(root, `${(0, node_crypto_1.createHash)("sha256").update(sessionId).digest("hex")}.jsonl`);
|
|
64
|
+
}
|
|
65
|
+
function textWithinLimit(text, maxBytes) {
|
|
66
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes)
|
|
67
|
+
return { text, truncated: false };
|
|
68
|
+
let low = 0;
|
|
69
|
+
let high = text.length;
|
|
70
|
+
while (low < high) {
|
|
71
|
+
const middle = Math.ceil((low + high) / 2);
|
|
72
|
+
if (Buffer.byteLength(text.slice(0, middle), "utf8") <= maxBytes)
|
|
73
|
+
low = middle;
|
|
74
|
+
else
|
|
75
|
+
high = middle - 1;
|
|
76
|
+
}
|
|
77
|
+
if (low > 0 && /[\uD800-\uDBFF]/.test(text[low - 1]))
|
|
78
|
+
low--;
|
|
79
|
+
return { text: text.slice(0, low), truncated: true };
|
|
80
|
+
}
|
|
81
|
+
function safeAttachments(items) {
|
|
82
|
+
if (!items)
|
|
83
|
+
return undefined;
|
|
84
|
+
return items.map((item) => ({ filename: item.filename, contentType: item.contentType, sizeBytes: item.sizeBytes }));
|
|
85
|
+
}
|
|
86
|
+
class SessionArchive {
|
|
87
|
+
directory;
|
|
88
|
+
limits;
|
|
89
|
+
now;
|
|
90
|
+
enabled;
|
|
91
|
+
queue = Promise.resolve();
|
|
92
|
+
metadata;
|
|
93
|
+
metadataCorrupt = false;
|
|
94
|
+
lastPrunedAt = 0;
|
|
95
|
+
nextExpiryAt = Infinity;
|
|
96
|
+
constructor(directory, options = {}) {
|
|
97
|
+
this.directory = directory;
|
|
98
|
+
this.enabled = options.enabled === true;
|
|
99
|
+
this.now = options.now ?? Date.now;
|
|
100
|
+
this.limits = { ...exports.SESSION_ARCHIVE_LIMITS };
|
|
101
|
+
for (const key of Object.keys(exports.SESSION_ARCHIVE_LIMITS)) {
|
|
102
|
+
const value = options.limits?.[key];
|
|
103
|
+
if (value !== undefined) {
|
|
104
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
105
|
+
throw new Error("归档限额无效");
|
|
106
|
+
this.limits[key] = Math.min(value, exports.SESSION_ARCHIVE_LIMITS[key]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
setEnabled(enabled) { void this.enqueue(async () => { this.enabled = enabled; }); }
|
|
111
|
+
async flush() { await this.queue; }
|
|
112
|
+
recordTurns(turns) {
|
|
113
|
+
return this.enqueue(async () => {
|
|
114
|
+
for (const turn of turns) {
|
|
115
|
+
if (this.enabled && turn.completed)
|
|
116
|
+
this.validateTurn(turn);
|
|
117
|
+
}
|
|
118
|
+
for (const turn of turns)
|
|
119
|
+
await this.recordTurnInQueue(turn);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
recordTurn(turn) {
|
|
123
|
+
return this.enqueue(() => this.recordTurnInQueue(turn));
|
|
124
|
+
}
|
|
125
|
+
async recordTurnInQueue(turn) {
|
|
126
|
+
if (!this.enabled || !turn.completed)
|
|
127
|
+
return;
|
|
128
|
+
this.validateTurn(turn);
|
|
129
|
+
const meta = await this.loadMetadata();
|
|
130
|
+
if (this.metadataCorrupt)
|
|
131
|
+
throw new Error("归档目录损坏,拒绝覆盖");
|
|
132
|
+
if (this.now() - this.lastPrunedAt >= 3600_000)
|
|
133
|
+
await this.pruneRecords(meta);
|
|
134
|
+
const existing = Object.hasOwn(meta.sessions, turn.sessionId) ? meta.sessions[turn.sessionId] : undefined;
|
|
135
|
+
if (existing && (existing.source !== turn.source || existing.principal !== turn.principal || existing.channel !== turn.channel || existing.agentPlugin !== turn.agentPlugin))
|
|
136
|
+
throw new Error("会话来源不一致");
|
|
137
|
+
if (!existing && Object.keys(meta.sessions).length >= this.limits.maxSessions) {
|
|
138
|
+
this.markIncomplete(meta, "会话数量超出上限,新会话未归档");
|
|
139
|
+
await this.saveMetadata(meta);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const current = existing ?? {
|
|
143
|
+
sessionId: turn.sessionId, agentPlugin: turn.agentPlugin, channel: turn.channel, principal: turn.principal,
|
|
144
|
+
source: turn.source, title: turn.title, firstSeenAt: turn.time, updatedAt: turn.time, turnCount: 0,
|
|
145
|
+
};
|
|
146
|
+
if (!existing)
|
|
147
|
+
Object.defineProperty(meta.sessions, turn.sessionId, { value: current, enumerable: true, writable: true, configurable: true });
|
|
148
|
+
const path = fileFor(this.directory, turn.sessionId);
|
|
149
|
+
const previous = await this.readTurns(path, meta, turn.sessionId);
|
|
150
|
+
const duplicate = previous.turns.some((item) => (turn.messageId && item.messageId === turn.messageId && item.role === turn.role) ||
|
|
151
|
+
(turn.requestId && item.requestId === turn.requestId && item.role === turn.role && (turn.role === "user" || (!turn.messageId && item.text === turn.text))));
|
|
152
|
+
for (const reason of previous.missingReasons)
|
|
153
|
+
this.markSessionIncomplete(current, reason);
|
|
154
|
+
if (duplicate)
|
|
155
|
+
return;
|
|
156
|
+
if (previous.turns.length >= this.limits.maxTurnsPerSession) {
|
|
157
|
+
this.markSessionIncomplete(current, "会话轮数超出上限");
|
|
158
|
+
await this.saveMetadata(meta);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const limited = textWithinLimit(turn.text, this.limits.maxTextBytes);
|
|
162
|
+
const stored = {
|
|
163
|
+
sessionId: turn.sessionId, time: turn.time, role: turn.role, text: limited.text,
|
|
164
|
+
attachments: safeAttachments(turn.attachments), messageId: turn.messageId, requestId: turn.requestId,
|
|
165
|
+
...(limited.truncated ? { truncated: true } : {}),
|
|
166
|
+
};
|
|
167
|
+
if (limited.truncated)
|
|
168
|
+
this.markSessionIncomplete(current, "单条文本超出上限,正文已截断");
|
|
169
|
+
let line = `${JSON.stringify(stored)}\n`;
|
|
170
|
+
const existingBytes = await this.fileSize(path);
|
|
171
|
+
if (existingBytes + Buffer.byteLength(line) > this.limits.maxSessionBytes) {
|
|
172
|
+
const allowance = this.limits.maxSessionBytes - existingBytes;
|
|
173
|
+
const fit = this.fitTurn(stored, Math.max(0, allowance));
|
|
174
|
+
if (!fit) {
|
|
175
|
+
this.markSessionIncomplete(current, "会话大小超出上限");
|
|
176
|
+
await this.saveMetadata(meta);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
line = `${JSON.stringify(fit)}\n`;
|
|
180
|
+
this.markSessionIncomplete(current, "会话大小超出上限,正文已截断");
|
|
181
|
+
}
|
|
182
|
+
await (0, promises_1.mkdir)(this.directory, { recursive: true, mode: 0o700 });
|
|
183
|
+
try {
|
|
184
|
+
await (0, promises_1.appendFile)(path, line, { encoding: "utf8", mode: FILE_MODE });
|
|
185
|
+
await (0, promises_1.chmod)(path, FILE_MODE);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
this.markSessionIncomplete(current, "归档正文写入失败");
|
|
189
|
+
await this.saveMetadata(meta);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
current.turnCount = previous.turns.length + 1;
|
|
193
|
+
current.updatedAt = current.updatedAt > turn.time ? current.updatedAt : turn.time;
|
|
194
|
+
this.nextExpiryAt = Math.min(this.nextExpiryAt, Date.parse(turn.time) + this.limits.retentionDays * 86400_000 + 1);
|
|
195
|
+
await this.saveMetadata(meta);
|
|
196
|
+
}
|
|
197
|
+
async listSessions(options = {}) {
|
|
198
|
+
return this.enqueue(async () => {
|
|
199
|
+
await this.pruneIfNeeded();
|
|
200
|
+
const meta = await this.loadMetadata();
|
|
201
|
+
const limit = Math.min(this.limits.maxListPageSize, Math.max(1, Math.floor(options.limit ?? this.limits.maxListPageSize)));
|
|
202
|
+
const page = Math.max(1, Math.floor(options.page ?? 1));
|
|
203
|
+
const allowed = options.sources ? new Set(options.sources) : undefined;
|
|
204
|
+
const all = Object.values(meta.sessions).filter((session) => (!options.source || session.source === options.source) && (!allowed || allowed.has(session.source)) && (!options.agentPlugin || session.agentPlugin === options.agentPlugin)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
205
|
+
const start = (page - 1) * limit;
|
|
206
|
+
const missingReasons = [...meta.incomplete];
|
|
207
|
+
return { sessions: structuredClone(all.slice(start, start + limit)), page, limit, total: all.length, hasNext: start + limit < all.length, complete: missingReasons.length === 0, missingReasons };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async snapshotSessions() {
|
|
211
|
+
return this.enqueue(async () => {
|
|
212
|
+
await this.pruneIfNeeded();
|
|
213
|
+
const meta = await this.loadMetadata();
|
|
214
|
+
return { sessions: structuredClone(Object.values(meta.sessions).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))), complete: meta.incomplete.length === 0, missingReasons: [...meta.incomplete] };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async readSession(sessionId, options = {}) {
|
|
218
|
+
return this.enqueue(async () => {
|
|
219
|
+
validId(sessionId, "会话标识");
|
|
220
|
+
await this.pruneIfNeeded();
|
|
221
|
+
const meta = await this.loadMetadata();
|
|
222
|
+
const session = Object.hasOwn(meta.sessions, sessionId) ? meta.sessions[sessionId] : undefined;
|
|
223
|
+
if (!session)
|
|
224
|
+
return { turns: [], page: 1, limit: this.limits.maxReadPageSize, total: 0, hasNext: false, complete: false, missingReasons: ["会话不存在"] };
|
|
225
|
+
const parsed = await this.readTurns(fileFor(this.directory, sessionId), meta, sessionId);
|
|
226
|
+
const limit = Math.min(this.limits.maxReadPageSize, Math.max(1, Math.floor(options.limit ?? this.limits.maxReadPageSize)));
|
|
227
|
+
const page = Math.max(1, Math.floor(options.page ?? 1));
|
|
228
|
+
const start = (page - 1) * limit;
|
|
229
|
+
const missingReasons = [...new Set([...meta.incomplete, ...parsed.missingReasons, ...(session.missingReasons ?? [])])];
|
|
230
|
+
return { session: structuredClone(session), turns: parsed.turns.slice(start, start + limit), page, limit, total: parsed.turns.length, hasNext: start + limit < parsed.turns.length, complete: missingReasons.length === 0, missingReasons };
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
deleteSession(sessionId) {
|
|
234
|
+
return this.enqueue(async () => {
|
|
235
|
+
validId(sessionId, "会话标识");
|
|
236
|
+
const meta = await this.loadMetadata();
|
|
237
|
+
if (this.metadataCorrupt)
|
|
238
|
+
throw new Error("归档目录损坏,拒绝覆盖");
|
|
239
|
+
try {
|
|
240
|
+
await (0, promises_1.unlink)(fileFor(this.directory, sessionId));
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error.code !== "ENOENT") {
|
|
244
|
+
if (Object.hasOwn(meta.sessions, sessionId))
|
|
245
|
+
this.markSessionIncomplete(meta.sessions[sessionId], "归档正文删除失败");
|
|
246
|
+
await this.saveMetadata(meta);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
delete meta.sessions[sessionId];
|
|
251
|
+
await this.saveMetadata(meta);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
clear() {
|
|
255
|
+
return this.enqueue(async () => {
|
|
256
|
+
await (0, promises_1.rm)(this.directory, { recursive: true, force: true });
|
|
257
|
+
this.metadata = undefined;
|
|
258
|
+
this.metadataCorrupt = false;
|
|
259
|
+
this.lastPrunedAt = 0;
|
|
260
|
+
this.nextExpiryAt = Infinity;
|
|
261
|
+
await (0, promises_1.mkdir)(this.directory, { recursive: true, mode: 0o700 });
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
prune() {
|
|
265
|
+
return this.enqueue(async () => {
|
|
266
|
+
const meta = await this.loadMetadata();
|
|
267
|
+
if (this.metadataCorrupt)
|
|
268
|
+
throw new Error("归档目录损坏,拒绝覆盖");
|
|
269
|
+
await this.pruneRecords(meta);
|
|
270
|
+
await this.saveMetadata(meta);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
async pruneIfNeeded() {
|
|
274
|
+
const meta = await this.loadMetadata();
|
|
275
|
+
if (this.metadataCorrupt || (this.now() - this.lastPrunedAt < 3600_000 && this.now() < this.nextExpiryAt))
|
|
276
|
+
return;
|
|
277
|
+
await this.pruneRecords(meta);
|
|
278
|
+
if (Object.keys(meta.sessions).length || meta.incomplete.length)
|
|
279
|
+
await this.saveMetadata(meta);
|
|
280
|
+
}
|
|
281
|
+
enqueue(operation) {
|
|
282
|
+
const result = this.queue.catch(() => undefined).then(operation);
|
|
283
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
validateTurn(turn) {
|
|
287
|
+
for (const [value, label] of [[turn.sessionId, "会话标识"], [turn.agentPlugin, "插件标识"], [turn.channel, "通道"]])
|
|
288
|
+
validId(value, label);
|
|
289
|
+
validSource(turn.principal, "主体");
|
|
290
|
+
validSource(turn.source, "来源");
|
|
291
|
+
if (turn.role !== "user" && turn.role !== "assistant")
|
|
292
|
+
throw new Error("消息角色无效");
|
|
293
|
+
if (typeof turn.text !== "string")
|
|
294
|
+
throw new Error("消息正文无效");
|
|
295
|
+
if (!validTime(turn.time))
|
|
296
|
+
throw new Error("消息时间无效");
|
|
297
|
+
if (!validAttachments(turn.attachments))
|
|
298
|
+
throw new Error("附件元数据无效");
|
|
299
|
+
if (turn.title !== undefined && (typeof turn.title !== "string" || turn.title.length > 512))
|
|
300
|
+
throw new Error("会话标题无效");
|
|
301
|
+
if (turn.messageId !== undefined)
|
|
302
|
+
validId(turn.messageId, "消息标识");
|
|
303
|
+
if (turn.requestId !== undefined)
|
|
304
|
+
validId(turn.requestId, "请求标识");
|
|
305
|
+
}
|
|
306
|
+
fitTurn(turn, allowance) {
|
|
307
|
+
const base = { ...turn, text: "", truncated: true };
|
|
308
|
+
const baseBytes = Buffer.byteLength(`${JSON.stringify(base)}\n`);
|
|
309
|
+
if (baseBytes > allowance)
|
|
310
|
+
return undefined;
|
|
311
|
+
let low = 0;
|
|
312
|
+
let high = turn.text.length;
|
|
313
|
+
while (low < high) {
|
|
314
|
+
const middle = Math.ceil((low + high) / 2);
|
|
315
|
+
if (Buffer.byteLength(`${JSON.stringify({ ...base, text: turn.text.slice(0, middle) })}\n`) <= allowance)
|
|
316
|
+
low = middle;
|
|
317
|
+
else
|
|
318
|
+
high = middle - 1;
|
|
319
|
+
}
|
|
320
|
+
if (low > 0 && /[\uD800-\uDBFF]/.test(turn.text[low - 1]))
|
|
321
|
+
low--;
|
|
322
|
+
return { ...base, text: turn.text.slice(0, low) };
|
|
323
|
+
}
|
|
324
|
+
markSessionIncomplete(session, reason) {
|
|
325
|
+
session.incomplete = true;
|
|
326
|
+
session.missingReasons ??= [];
|
|
327
|
+
if (!session.missingReasons.includes(reason))
|
|
328
|
+
session.missingReasons.push(reason);
|
|
329
|
+
}
|
|
330
|
+
markIncomplete(meta, reason) {
|
|
331
|
+
if (!meta.incomplete.includes(reason))
|
|
332
|
+
meta.incomplete.push(reason);
|
|
333
|
+
}
|
|
334
|
+
async loadMetadata() {
|
|
335
|
+
if (this.metadata)
|
|
336
|
+
return this.metadata;
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(await boundedRead((0, node_path_1.join)(this.directory, META_FILE), MAX_METADATA_BYTES));
|
|
339
|
+
if (parsed.version !== 1 || !parsed.sessions || typeof parsed.sessions !== "object" || Array.isArray(parsed.sessions) || !validReasons(parsed.incomplete) || Object.keys(parsed.sessions).length > exports.SESSION_ARCHIVE_LIMITS.maxSessions)
|
|
340
|
+
throw new Error("归档目录格式无效");
|
|
341
|
+
for (const [id, session] of Object.entries(parsed.sessions)) {
|
|
342
|
+
if (!session || session.sessionId !== id || !Number.isSafeInteger(session.turnCount) || session.turnCount < 0 || session.turnCount > exports.SESSION_ARCHIVE_LIMITS.maxTurnsPerSession || !validTime(session.firstSeenAt) || !validTime(session.updatedAt) ||
|
|
343
|
+
(session.title !== undefined && (typeof session.title !== "string" || session.title.length > 512)) ||
|
|
344
|
+
(session.incomplete !== undefined && typeof session.incomplete !== "boolean"))
|
|
345
|
+
throw new Error("归档目录格式无效");
|
|
346
|
+
validId(session.sessionId, "目录字段");
|
|
347
|
+
validSource(session.source, "目录字段");
|
|
348
|
+
validSource(session.principal, "目录字段");
|
|
349
|
+
validId(session.agentPlugin, "目录字段");
|
|
350
|
+
validId(session.channel, "目录字段");
|
|
351
|
+
if (session.missingReasons !== undefined && !validReasons(session.missingReasons))
|
|
352
|
+
throw new Error("归档目录格式无效");
|
|
353
|
+
}
|
|
354
|
+
this.metadata = parsed;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (error.code === "ENOENT") {
|
|
358
|
+
const files = await (0, promises_1.readdir)(this.directory).catch((failure) => { if (failure.code === "ENOENT")
|
|
359
|
+
return []; throw failure; });
|
|
360
|
+
this.metadataCorrupt = files.some((name) => name.endsWith(".jsonl"));
|
|
361
|
+
this.metadata = { version: 1, sessions: {}, incomplete: this.metadataCorrupt ? ["归档目录缺失,存在未关联正文"] : [] };
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
this.metadataCorrupt = true;
|
|
365
|
+
this.metadata = { version: 1, sessions: {}, incomplete: ["归档目录损坏"] };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return this.metadata;
|
|
369
|
+
}
|
|
370
|
+
async saveMetadata(meta) {
|
|
371
|
+
if (this.metadataCorrupt)
|
|
372
|
+
throw new Error("归档目录损坏,拒绝覆盖");
|
|
373
|
+
await this.enforceLimits(meta);
|
|
374
|
+
await (0, promises_1.mkdir)(this.directory, { recursive: true, mode: 0o700 });
|
|
375
|
+
await (0, promises_1.chmod)(this.directory, 0o700);
|
|
376
|
+
const temporary = (0, node_path_1.join)(this.directory, `${META_FILE}.${process.pid}.${Date.now()}.tmp`);
|
|
377
|
+
try {
|
|
378
|
+
await (0, promises_1.writeFile)(temporary, `${JSON.stringify(meta)}\n`, { encoding: "utf8", mode: FILE_MODE });
|
|
379
|
+
await (0, promises_1.chmod)(temporary, FILE_MODE);
|
|
380
|
+
await (0, promises_1.rename)(temporary, (0, node_path_1.join)(this.directory, META_FILE));
|
|
381
|
+
await (0, promises_1.chmod)((0, node_path_1.join)(this.directory, META_FILE), FILE_MODE);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
this.markIncomplete(meta, "归档目录写入失败");
|
|
385
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
this.metadata = meta;
|
|
389
|
+
}
|
|
390
|
+
async readTurns(path, meta, sessionId) {
|
|
391
|
+
let text;
|
|
392
|
+
try {
|
|
393
|
+
text = await boundedRead(path, this.limits.maxSessionBytes);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (error.code === "ENOENT") {
|
|
397
|
+
if (meta.sessions[sessionId]?.turnCount)
|
|
398
|
+
return { turns: [], missingReasons: ["归档正文缺失"] };
|
|
399
|
+
return { turns: [], missingReasons: [] };
|
|
400
|
+
}
|
|
401
|
+
return { turns: [], missingReasons: [error.message.includes("大小") ? "归档正文大小超出上限" : "归档正文读取失败"] };
|
|
402
|
+
}
|
|
403
|
+
const turns = [];
|
|
404
|
+
const missingReasons = [];
|
|
405
|
+
for (const line of text.split("\n")) {
|
|
406
|
+
if (!line.trim())
|
|
407
|
+
continue;
|
|
408
|
+
try {
|
|
409
|
+
const parsed = JSON.parse(line);
|
|
410
|
+
if (parsed.sessionId !== sessionId || (parsed.role !== "user" && parsed.role !== "assistant") || typeof parsed.text !== "string" || Buffer.byteLength(parsed.text) > this.limits.maxTextBytes || !validTime(parsed.time) || !validAttachments(parsed.attachments) ||
|
|
411
|
+
(parsed.truncated !== undefined && typeof parsed.truncated !== "boolean"))
|
|
412
|
+
throw new Error("字段无效");
|
|
413
|
+
if (parsed.messageId !== undefined)
|
|
414
|
+
validId(parsed.messageId, "消息标识");
|
|
415
|
+
if (parsed.requestId !== undefined)
|
|
416
|
+
validId(parsed.requestId, "请求标识");
|
|
417
|
+
if (turns.length >= this.limits.maxTurnsPerSession)
|
|
418
|
+
throw new Error("轮数超限");
|
|
419
|
+
if (parsed.truncated)
|
|
420
|
+
missingReasons.push("单条文本已截断");
|
|
421
|
+
turns.push({ sessionId, role: parsed.role, time: parsed.time, text: parsed.text, attachments: safeAttachments(parsed.attachments), messageId: parsed.messageId, requestId: parsed.requestId, ...(parsed.truncated ? { truncated: true } : {}) });
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
missingReasons.push("归档存在损坏记录");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (turns.length !== meta.sessions[sessionId]?.turnCount)
|
|
428
|
+
missingReasons.push("归档记录数量与目录不一致");
|
|
429
|
+
return { turns, missingReasons: [...new Set(missingReasons)] };
|
|
430
|
+
}
|
|
431
|
+
async replaceTurns(path, turns) {
|
|
432
|
+
if (!turns.length) {
|
|
433
|
+
try {
|
|
434
|
+
await (0, promises_1.unlink)(path);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (error.code !== "ENOENT")
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
443
|
+
await (0, promises_1.writeFile)(temporary, turns.map((turn) => `${JSON.stringify(turn)}\n`).join(""), { encoding: "utf8", mode: FILE_MODE });
|
|
444
|
+
await (0, promises_1.rename)(temporary, path);
|
|
445
|
+
await (0, promises_1.chmod)(path, FILE_MODE);
|
|
446
|
+
}
|
|
447
|
+
async pruneRecords(meta) {
|
|
448
|
+
const cutoff = this.now() - this.limits.retentionDays * 86400_000;
|
|
449
|
+
this.nextExpiryAt = Infinity;
|
|
450
|
+
for (const session of Object.values(meta.sessions)) {
|
|
451
|
+
const path = fileFor(this.directory, session.sessionId);
|
|
452
|
+
const parsed = await this.readTurns(path, meta, session.sessionId);
|
|
453
|
+
for (const reason of parsed.missingReasons)
|
|
454
|
+
this.markSessionIncomplete(session, reason);
|
|
455
|
+
const kept = parsed.turns.filter((turn) => Date.parse(turn.time) >= cutoff);
|
|
456
|
+
for (const turn of kept)
|
|
457
|
+
this.nextExpiryAt = Math.min(this.nextExpiryAt, Date.parse(turn.time) + this.limits.retentionDays * 86400_000 + 1);
|
|
458
|
+
if (kept.length !== parsed.turns.length) {
|
|
459
|
+
this.markSessionIncomplete(session, "历史记录因保留期过期而清理");
|
|
460
|
+
await this.replaceTurns(path, kept);
|
|
461
|
+
session.turnCount = kept.length;
|
|
462
|
+
if (!kept.length) {
|
|
463
|
+
delete meta.sessions[session.sessionId];
|
|
464
|
+
this.markIncomplete(meta, "历史会话因保留期过期而清理");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
this.lastPrunedAt = this.now();
|
|
469
|
+
}
|
|
470
|
+
async fileSize(path) {
|
|
471
|
+
try {
|
|
472
|
+
return (await (0, promises_1.stat)(path)).size;
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
if (error.code === "ENOENT")
|
|
476
|
+
return 0;
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async enforceLimits(meta) {
|
|
481
|
+
const sessions = Object.values(meta.sessions).sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
|
482
|
+
const files = await (0, promises_1.readdir)(this.directory).catch(() => []);
|
|
483
|
+
let total = Buffer.byteLength(`${JSON.stringify(meta)}\n`);
|
|
484
|
+
for (const name of files.filter((item) => item.endsWith(".jsonl")))
|
|
485
|
+
total += await this.fileSize((0, node_path_1.join)(this.directory, name));
|
|
486
|
+
for (const session of sessions) {
|
|
487
|
+
const metadataBytes = Buffer.byteLength(`${JSON.stringify(meta)}\n`);
|
|
488
|
+
if (total <= this.limits.maxTotalBytes && metadataBytes <= MAX_METADATA_BYTES)
|
|
489
|
+
break;
|
|
490
|
+
if (!Object.hasOwn(meta.sessions, session.sessionId))
|
|
491
|
+
continue;
|
|
492
|
+
const path = fileFor(this.directory, session.sessionId);
|
|
493
|
+
const size = await this.fileSize(path);
|
|
494
|
+
try {
|
|
495
|
+
await (0, promises_1.unlink)(path);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (error.code !== "ENOENT")
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
const before = Buffer.byteLength(`${JSON.stringify(meta)}\n`);
|
|
502
|
+
delete meta.sessions[session.sessionId];
|
|
503
|
+
const reason = metadataBytes > MAX_METADATA_BYTES ? "目录元数据超出上限,旧会话已清理" : "归档总大小超出上限,旧会话已清理";
|
|
504
|
+
this.markIncomplete(meta, reason);
|
|
505
|
+
total -= size + before - Buffer.byteLength(`${JSON.stringify(meta)}\n`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
exports.SessionArchive = SessionArchive;
|