@yeaft/webchat-agent 0.1.929 → 0.1.930
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/history.js +122 -1
- package/package.json +1 -1
package/history.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'os';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import ctx from './context.js';
|
|
5
5
|
import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
|
|
@@ -146,6 +146,116 @@ export async function getHistorySessions(workDir) {
|
|
|
146
146
|
return sessions;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// feat-chat-load-perf: tail-read helper used by loadSessionHistory.
|
|
150
|
+
// Reads the last `limit` user/assistant rows from a JSONL without slurping
|
|
151
|
+
// the whole file. Strategy: open the file, fstat to get size, then read
|
|
152
|
+
// fixed-size chunks from the end backwards into a Buffer. We split on the
|
|
153
|
+
// `\n` *byte* (0x0A) — NOT on a decoded string — because Buffer→string
|
|
154
|
+
// substitutes U+FFFD for any partial multi-byte sequence at chunk
|
|
155
|
+
// boundaries, and that corruption is undetectable downstream (JSON.parse
|
|
156
|
+
// happily accepts U+FFFD as valid string content). Splitting on the
|
|
157
|
+
// newline byte and carrying raw bytes between iterations means every
|
|
158
|
+
// complete line is decoded as a whole and the agent never feeds the LLM
|
|
159
|
+
// mangled history. A 42 MB / 100k-message JSONL with limit=500 reads
|
|
160
|
+
// roughly 1–4 MB instead of the entire file.
|
|
161
|
+
//
|
|
162
|
+
// Tradeoffs:
|
|
163
|
+
// - Falls back to full readFileSync if anything throws (defensive — a 200ms
|
|
164
|
+
// slow path beats a broken history load).
|
|
165
|
+
// - The TAIL_CHUNK_SIZE constant (256 KB) is sized so a single chunk almost
|
|
166
|
+
// always contains many complete lines from Claude CLI's per-message
|
|
167
|
+
// write pattern.
|
|
168
|
+
// - The carry Buffer is capped at TAIL_MAX_CARRY_BYTES — a pathological
|
|
169
|
+
// JSONL line longer than that triggers the fallback path rather than
|
|
170
|
+
// letting the agent OOM.
|
|
171
|
+
const TAIL_CHUNK_SIZE = 256 * 1024; // 256 KB
|
|
172
|
+
const TAIL_MAX_CARRY_BYTES = 4 * 1024 * 1024; // 4 MB — safety valve, see above
|
|
173
|
+
const NEWLINE_BYTE = 0x0a;
|
|
174
|
+
|
|
175
|
+
// Exported for tests so the UTF-8-boundary regression can splice a
|
|
176
|
+
// multi-byte character exactly at the chunk seam.
|
|
177
|
+
export const _TAIL_CHUNK_SIZE_FOR_TESTS = TAIL_CHUNK_SIZE;
|
|
178
|
+
|
|
179
|
+
function readTailMessages(filePath, limit) {
|
|
180
|
+
const fd = openSync(filePath, 'r');
|
|
181
|
+
try {
|
|
182
|
+
const { size } = fstatSync(fd);
|
|
183
|
+
if (size === 0) return [];
|
|
184
|
+
|
|
185
|
+
const collected = []; // newest-first while we build it; reverse before return
|
|
186
|
+
let carry = Buffer.alloc(0); // raw-byte tail from the previous (deeper-into-file) chunk
|
|
187
|
+
let position = size;
|
|
188
|
+
const chunkBuf = Buffer.alloc(TAIL_CHUNK_SIZE);
|
|
189
|
+
|
|
190
|
+
while (position > 0 && collected.length < limit) {
|
|
191
|
+
const readSize = Math.min(TAIL_CHUNK_SIZE, position);
|
|
192
|
+
const offset = position - readSize;
|
|
193
|
+
readSync(fd, chunkBuf, 0, readSize, offset);
|
|
194
|
+
position = offset;
|
|
195
|
+
const atHead = position === 0;
|
|
196
|
+
|
|
197
|
+
// Concatenate raw bytes — never decode partials, never split UTF-8.
|
|
198
|
+
const buf = Buffer.concat([chunkBuf.slice(0, readSize), carry]);
|
|
199
|
+
|
|
200
|
+
// If we're not yet at the head of the file, the first segment up to
|
|
201
|
+
// (but not including) the first newline is a potentially-partial
|
|
202
|
+
// line — stash its bytes for the next iteration. If there's no
|
|
203
|
+
// newline at all, the whole chunk is one partial line and we carry
|
|
204
|
+
// it forward.
|
|
205
|
+
let tailStart = 0;
|
|
206
|
+
if (!atHead) {
|
|
207
|
+
const firstNl = buf.indexOf(NEWLINE_BYTE);
|
|
208
|
+
if (firstNl === -1) {
|
|
209
|
+
if (buf.length > TAIL_MAX_CARRY_BYTES) {
|
|
210
|
+
// Refuse to grow the carry unbounded — propagate to fallback.
|
|
211
|
+
throw new Error(`tail-read carry exceeded ${TAIL_MAX_CARRY_BYTES} bytes`);
|
|
212
|
+
}
|
|
213
|
+
carry = buf;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
carry = buf.slice(0, firstNl);
|
|
217
|
+
tailStart = firstNl + 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Everything from tailStart to end is complete UTF-8 lines — decode
|
|
221
|
+
// safely as one block.
|
|
222
|
+
const text = buf.slice(tailStart).toString('utf-8');
|
|
223
|
+
const lines = text.split('\n');
|
|
224
|
+
|
|
225
|
+
// Walk lines newest-first (end to start).
|
|
226
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
227
|
+
const line = lines[i];
|
|
228
|
+
if (!line || !line.trim()) continue;
|
|
229
|
+
try {
|
|
230
|
+
const data = JSON.parse(line);
|
|
231
|
+
if (data.type === 'user' || data.type === 'assistant') {
|
|
232
|
+
collected.push(data);
|
|
233
|
+
if (collected.length >= limit) break;
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// If we ran out of file with leftover carry, try it as the head line.
|
|
240
|
+
if (collected.length < limit && carry.length > 0) {
|
|
241
|
+
const headLine = carry.toString('utf-8').trim();
|
|
242
|
+
if (headLine) {
|
|
243
|
+
try {
|
|
244
|
+
const data = JSON.parse(headLine);
|
|
245
|
+
if (data.type === 'user' || data.type === 'assistant') {
|
|
246
|
+
collected.push(data);
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// collected is newest-first; flip to chronological order for callers.
|
|
253
|
+
return collected.reverse();
|
|
254
|
+
} finally {
|
|
255
|
+
closeSync(fd);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
149
259
|
// 读取 session 文件中的历史消息
|
|
150
260
|
export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
|
|
151
261
|
const projectsDir = getClaudeProjectsDir();
|
|
@@ -159,6 +269,17 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
|
|
|
159
269
|
return [];
|
|
160
270
|
}
|
|
161
271
|
|
|
272
|
+
// Fast path: tail-read only the last `limit` user/assistant rows. Avoids
|
|
273
|
+
// slurping ~42 MB into memory + ~100k JSON.parse calls when we only need
|
|
274
|
+
// the last 500 entries on every chat resume.
|
|
275
|
+
if (limit && limit > 0) {
|
|
276
|
+
try {
|
|
277
|
+
return readTailMessages(sessionFile, limit);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
console.error(`Tail-read failed (${e.message}), falling back to full read for: ${sessionFile}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
162
283
|
const messages = [];
|
|
163
284
|
try {
|
|
164
285
|
const content = readFileSync(sessionFile, 'utf-8');
|