@averyyy/pi-coding-agent 0.82.1-piclient.6 → 0.82.1-piclient.7
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/CHANGELOG.md +0 -12
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +1 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +0 -5
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/agent-session.d.ts +1 -12
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +89 -317
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/pi-server-client.d.ts +2 -54
- package/dist/core/pi-server-client.d.ts.map +1 -1
- package/dist/core/pi-server-client.js +272 -2320
- package/dist/core/pi-server-client.js.map +1 -1
- package/dist/core/pi-server-protocol.d.ts +0 -2
- package/dist/core/pi-server-protocol.d.ts.map +1 -1
- package/dist/core/pi-server-protocol.js +1 -24
- package/dist/core/pi-server-protocol.js.map +1 -1
- package/dist/core/pi-server-request.d.ts +0 -28
- package/dist/core/pi-server-request.d.ts.map +1 -1
- package/dist/core/pi-server-request.js +75 -492
- package/dist/core/pi-server-request.js.map +1 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +53 -69
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +2 -86
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +144 -737
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +1 -3
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +26 -46
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +0 -13
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +0 -15
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +29 -98
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/package.json +4 -4
- package/dist/cli/tool-effects.d.ts +0 -6
- package/dist/cli/tool-effects.d.ts.map +0 -1
- package/dist/cli/tool-effects.js +0 -164
- package/dist/cli/tool-effects.js.map +0 -1
- package/dist/core/pi-server-compact-state.d.ts +0 -81
- package/dist/core/pi-server-compact-state.d.ts.map +0 -1
- package/dist/core/pi-server-compact-state.js +0 -439
- package/dist/core/pi-server-compact-state.js.map +0 -1
- package/dist/core/pi-server-run-state.d.ts +0 -42
- package/dist/core/pi-server-run-state.d.ts.map +0 -1
- package/dist/core/pi-server-run-state.js +0 -320
- package/dist/core/pi-server-run-state.js.map +0 -1
- package/dist/core/tool-effect-journal.d.ts +0 -103
- package/dist/core/tool-effect-journal.d.ts.map +0 -1
- package/dist/core/tool-effect-journal.js +0 -1442
- package/dist/core/tool-effect-journal.js.map +0 -1
|
@@ -1,61 +1,14 @@
|
|
|
1
1
|
import { uuidv7 } from "@earendil-works/pi-ai";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { closeSync, createReadStream, existsSync,
|
|
3
|
+
import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
|
|
4
4
|
import { readdir, stat } from "fs/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { join, resolve } from "path";
|
|
6
6
|
import { createInterface } from "readline";
|
|
7
7
|
import { StringDecoder } from "string_decoder";
|
|
8
|
-
import { isDeepStrictEqual } from "util";
|
|
9
|
-
import { serialize as serializeV8 } from "v8";
|
|
10
8
|
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
|
|
11
9
|
import { normalizePath, resolvePath } from "../utils/paths.js";
|
|
12
10
|
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
|
|
13
|
-
import { appendPiServerTreeHash, hashPiServerTreeEntry, PI_SERVER_EMPTY_TREE_HASH } from "./pi-server-protocol.js";
|
|
14
11
|
export const CURRENT_SESSION_VERSION = 3;
|
|
15
|
-
let sessionManagerPersistenceTestHooks;
|
|
16
|
-
export function setSessionManagerPersistenceTestHooks(hooks) {
|
|
17
|
-
sessionManagerPersistenceTestHooks = hooks;
|
|
18
|
-
}
|
|
19
|
-
export const DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS = Object.freeze({
|
|
20
|
-
maxEntries: 250_000,
|
|
21
|
-
maxLogicalBytes: 256 * 1024 * 1024,
|
|
22
|
-
});
|
|
23
|
-
export const ENV_SESSION_MAX_ENTRIES = "PI_SESSION_MAX_ENTRIES";
|
|
24
|
-
export const ENV_SESSION_MAX_LOGICAL_BYTES = "PI_SESSION_MAX_LOGICAL_BYTES";
|
|
25
|
-
export const ENV_SESSION_MAX_FILE_BYTES = "PI_SESSION_MAX_FILE_BYTES";
|
|
26
|
-
export class SessionManagerCapacityError extends Error {
|
|
27
|
-
code = "PI_SESSION_CAPACITY_EXCEEDED";
|
|
28
|
-
retryable = false;
|
|
29
|
-
resource;
|
|
30
|
-
sessionId;
|
|
31
|
-
path;
|
|
32
|
-
current;
|
|
33
|
-
requested;
|
|
34
|
-
limit;
|
|
35
|
-
constructor(input) {
|
|
36
|
-
super(`Session capacity exceeded: resource=${input.resource}, session=${input.sessionId}, path=${input.path ?? "<memory>"}, requested=${input.requested}, limit=${input.limit}`);
|
|
37
|
-
this.name = "SessionManagerCapacityError";
|
|
38
|
-
this.resource = input.resource;
|
|
39
|
-
this.sessionId = input.sessionId;
|
|
40
|
-
this.path = input.path;
|
|
41
|
-
this.current = input.current;
|
|
42
|
-
this.requested = input.requested;
|
|
43
|
-
this.limit = input.limit;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
let sessionManagerCapacityOverrides = {};
|
|
47
|
-
export function configureSessionManagerCapacityLimits(overrides) {
|
|
48
|
-
for (const [name, value] of Object.entries(overrides)) {
|
|
49
|
-
if (!Number.isSafeInteger(value) || (value ?? 0) <= 0) {
|
|
50
|
-
throw new RangeError(`${name} must be a positive safe integer`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
sessionManagerCapacityOverrides = { ...sessionManagerCapacityOverrides, ...overrides };
|
|
54
|
-
return getSessionManagerCapacityLimits();
|
|
55
|
-
}
|
|
56
|
-
export function resetSessionManagerCapacityLimits() {
|
|
57
|
-
sessionManagerCapacityOverrides = {};
|
|
58
|
-
}
|
|
59
12
|
function createSessionId() {
|
|
60
13
|
return uuidv7();
|
|
61
14
|
}
|
|
@@ -168,40 +121,6 @@ function buildEntryIndex(entries, byId) {
|
|
|
168
121
|
}
|
|
169
122
|
return index;
|
|
170
123
|
}
|
|
171
|
-
function validateSessionTreeShape(entries) {
|
|
172
|
-
const index = new Map();
|
|
173
|
-
for (const entry of entries) {
|
|
174
|
-
if (index.has(entry.id))
|
|
175
|
-
throw new Error(`Duplicate session entry id ${entry.id}`);
|
|
176
|
-
index.set(entry.id, entry);
|
|
177
|
-
}
|
|
178
|
-
for (const entry of entries) {
|
|
179
|
-
if (entry.parentId !== null && !index.has(entry.parentId)) {
|
|
180
|
-
throw new Error(`Parent entry ${entry.parentId} does not exist`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
const visitState = new Map();
|
|
184
|
-
for (const entry of entries) {
|
|
185
|
-
if (visitState.get(entry.id) === "visited")
|
|
186
|
-
continue;
|
|
187
|
-
const path = [];
|
|
188
|
-
let current = entry;
|
|
189
|
-
while (current) {
|
|
190
|
-
const state = visitState.get(current.id);
|
|
191
|
-
if (state === "visiting") {
|
|
192
|
-
throw new Error(`Session tree contains a parent cycle at entry ${current.id}`);
|
|
193
|
-
}
|
|
194
|
-
if (state === "visited")
|
|
195
|
-
break;
|
|
196
|
-
visitState.set(current.id, "visiting");
|
|
197
|
-
path.push(current);
|
|
198
|
-
current = current.parentId === null ? undefined : index.get(current.parentId);
|
|
199
|
-
}
|
|
200
|
-
for (const pathEntry of path)
|
|
201
|
-
visitState.set(pathEntry.id, "visited");
|
|
202
|
-
}
|
|
203
|
-
return index;
|
|
204
|
-
}
|
|
205
124
|
function buildSessionPath(entries, leafId, byId) {
|
|
206
125
|
const index = buildEntryIndex(entries, byId);
|
|
207
126
|
let leaf;
|
|
@@ -217,12 +136,7 @@ function buildSessionPath(entries, leafId, byId) {
|
|
|
217
136
|
}
|
|
218
137
|
const path = [];
|
|
219
138
|
let current = leaf;
|
|
220
|
-
const seen = new Set();
|
|
221
139
|
while (current) {
|
|
222
|
-
if (seen.has(current.id)) {
|
|
223
|
-
throw new Error(`Session tree contains a parent cycle at entry ${current.id}`);
|
|
224
|
-
}
|
|
225
|
-
seen.add(current.id);
|
|
226
140
|
path.push(current);
|
|
227
141
|
current = current.parentId ? index.get(current.parentId) : undefined;
|
|
228
142
|
}
|
|
@@ -269,10 +183,7 @@ export function sessionEntryToContextMessages(entry) {
|
|
|
269
183
|
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
|
|
270
184
|
}
|
|
271
185
|
if (entry.type === "compaction") {
|
|
272
|
-
return [
|
|
273
|
-
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
|
|
274
|
-
...(entry.retainedTail ?? []),
|
|
275
|
-
];
|
|
186
|
+
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
|
|
276
187
|
}
|
|
277
188
|
return [];
|
|
278
189
|
}
|
|
@@ -300,10 +211,6 @@ export function buildContextEntries(entries, leafId, byId) {
|
|
|
300
211
|
return path;
|
|
301
212
|
}
|
|
302
213
|
const contextEntries = [compaction];
|
|
303
|
-
if (compaction.retainedTail !== undefined) {
|
|
304
|
-
contextEntries.push(...path.slice(compactionIdx + 1));
|
|
305
|
-
return contextEntries;
|
|
306
|
-
}
|
|
307
214
|
let foundFirstKept = false;
|
|
308
215
|
for (let i = 0; i < compactionIdx; i++) {
|
|
309
216
|
const entry = path[i];
|
|
@@ -349,238 +256,21 @@ const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
|
|
|
349
256
|
const SESSION_HEADER_READ_BUFFER_SIZE = 4096;
|
|
350
257
|
/** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */
|
|
351
258
|
const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
|
|
352
|
-
const SESSION_FILE_ENVELOPE_OVERHEAD_BYTES = 1024 * 1024;
|
|
353
|
-
const WINDOWS_SESSION_RENAME_RETRIES = 4;
|
|
354
|
-
const MAX_WINDOWS_SESSION_RENAME_DELAY_MS = 80;
|
|
355
|
-
function readPositiveSafeIntegerEnvironment(name) {
|
|
356
|
-
const raw = process.env[name];
|
|
357
|
-
if (raw === undefined)
|
|
358
|
-
return undefined;
|
|
359
|
-
if (!/^[1-9][0-9]*$/u.test(raw)) {
|
|
360
|
-
throw new Error(`${name} must be a positive safe integer`);
|
|
361
|
-
}
|
|
362
|
-
const value = Number(raw);
|
|
363
|
-
if (!Number.isSafeInteger(value)) {
|
|
364
|
-
throw new Error(`${name} must be a positive safe integer`);
|
|
365
|
-
}
|
|
366
|
-
return value;
|
|
367
|
-
}
|
|
368
|
-
export function getSessionManagerCapacityLimits() {
|
|
369
|
-
const maxEntries = sessionManagerCapacityOverrides.maxEntries ??
|
|
370
|
-
readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_ENTRIES) ??
|
|
371
|
-
DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS.maxEntries;
|
|
372
|
-
const maxLogicalBytes = sessionManagerCapacityOverrides.maxLogicalBytes ??
|
|
373
|
-
readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_LOGICAL_BYTES) ??
|
|
374
|
-
DEFAULT_SESSION_MANAGER_CAPACITY_LIMITS.maxLogicalBytes;
|
|
375
|
-
const derivedMaxFileBytes = maxLogicalBytes + maxEntries + SESSION_FILE_ENVELOPE_OVERHEAD_BYTES;
|
|
376
|
-
if (!Number.isSafeInteger(derivedMaxFileBytes)) {
|
|
377
|
-
throw new Error("Session capacity limits are too large to derive a safe session file limit");
|
|
378
|
-
}
|
|
379
|
-
const maxFileBytes = sessionManagerCapacityOverrides.maxFileBytes ??
|
|
380
|
-
readPositiveSafeIntegerEnvironment(ENV_SESSION_MAX_FILE_BYTES) ??
|
|
381
|
-
derivedMaxFileBytes;
|
|
382
|
-
return { maxEntries, maxLogicalBytes, maxFileBytes };
|
|
383
|
-
}
|
|
384
|
-
function sessionCapacityError(resource, sessionId, path, current, requested, limit) {
|
|
385
|
-
return new SessionManagerCapacityError({ resource, sessionId, path, current, requested, limit });
|
|
386
|
-
}
|
|
387
|
-
function serializeEntryJson(entry) {
|
|
388
|
-
const serialized = JSON.stringify(entry);
|
|
389
|
-
if (serialized === undefined) {
|
|
390
|
-
throw new Error(`Session entry ${entry.type} could not be serialized`);
|
|
391
|
-
}
|
|
392
|
-
return serialized;
|
|
393
|
-
}
|
|
394
|
-
function measureSessionEntryLogicalBytes(entry) {
|
|
395
|
-
try {
|
|
396
|
-
return Buffer.byteLength(serializeEntryJson(entry), "utf8");
|
|
397
|
-
}
|
|
398
|
-
catch (error) {
|
|
399
|
-
if (entry.type !== "custom")
|
|
400
|
-
throw error;
|
|
401
|
-
// Native in-memory custom entries intentionally support cyclic structured
|
|
402
|
-
// data. They have no JSON representation, so account their exact V8
|
|
403
|
-
// structured-clone bytes; all transportable entries retain JSON byte parity.
|
|
404
|
-
return serializeV8(entry).byteLength;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
function measureSessionEntriesLogicalBytes(entries) {
|
|
408
|
-
let logicalBytes = 0;
|
|
409
|
-
for (const entry of entries)
|
|
410
|
-
logicalBytes += measureSessionEntryLogicalBytes(entry);
|
|
411
|
-
return logicalBytes;
|
|
412
|
-
}
|
|
413
|
-
/**
|
|
414
|
-
* Measure durable tree content as the UTF-8 bytes of each independently
|
|
415
|
-
* serialized session entry. File headers and JSONL newlines are envelope bytes.
|
|
416
|
-
*/
|
|
417
|
-
export function calculateSessionManagerLogicalBytes(entries) {
|
|
418
|
-
return measureSessionEntriesLogicalBytes(entries);
|
|
419
|
-
}
|
|
420
|
-
function assertSessionTreeCapacity(sessionId, path, currentEntryCount, currentLogicalBytes, nextEntryCount, nextLogicalBytes) {
|
|
421
|
-
const limits = getSessionManagerCapacityLimits();
|
|
422
|
-
if (nextEntryCount > limits.maxEntries) {
|
|
423
|
-
throw sessionCapacityError("entries", sessionId, path, currentEntryCount, nextEntryCount, limits.maxEntries);
|
|
424
|
-
}
|
|
425
|
-
if (nextLogicalBytes > limits.maxLogicalBytes) {
|
|
426
|
-
throw sessionCapacityError("logical_bytes", sessionId, path, currentLogicalBytes, nextLogicalBytes, limits.maxLogicalBytes);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
function measureSessionFileBytes(entries) {
|
|
430
|
-
let fileBytes = 0;
|
|
431
|
-
for (const entry of entries) {
|
|
432
|
-
fileBytes += Buffer.byteLength(serializeEntryJson(entry), "utf8") + 1;
|
|
433
|
-
}
|
|
434
|
-
return fileBytes;
|
|
435
|
-
}
|
|
436
|
-
function assertSessionFileSize(sessionId, path, current, requested) {
|
|
437
|
-
const limit = getSessionManagerCapacityLimits().maxFileBytes;
|
|
438
|
-
if (requested > limit) {
|
|
439
|
-
throw sessionCapacityError("file_bytes", sessionId, path, current, requested, limit);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
function getFilesystemErrorCode(error) {
|
|
443
|
-
if (typeof error !== "object" || error === null || !("code" in error))
|
|
444
|
-
return undefined;
|
|
445
|
-
return typeof error.code === "string" ? error.code : undefined;
|
|
446
|
-
}
|
|
447
|
-
function waitForWindowsSessionRenameRetry(attempt) {
|
|
448
|
-
const delay = Math.min(2 ** attempt * 5, MAX_WINDOWS_SESSION_RENAME_DELAY_MS);
|
|
449
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
450
|
-
}
|
|
451
|
-
function replaceSessionFile(tempPath, targetPath) {
|
|
452
|
-
for (let attempt = 0;; attempt++) {
|
|
453
|
-
try {
|
|
454
|
-
sessionManagerPersistenceTestHooks?.beforeRenameAttempt?.(attempt);
|
|
455
|
-
renameSync(tempPath, targetPath);
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
catch (error) {
|
|
459
|
-
const useWindowsRetries = process.platform === "win32" || sessionManagerPersistenceTestHooks?.simulateWindowsRenameRetries === true;
|
|
460
|
-
if (!useWindowsRetries ||
|
|
461
|
-
getFilesystemErrorCode(error) !== "EPERM" ||
|
|
462
|
-
attempt >= WINDOWS_SESSION_RENAME_RETRIES) {
|
|
463
|
-
throw error;
|
|
464
|
-
}
|
|
465
|
-
waitForWindowsSessionRenameRetry(attempt);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
function fsyncSessionDirectory(targetPath) {
|
|
470
|
-
if (process.platform === "win32") {
|
|
471
|
-
// libuv cannot open a Windows directory for fsync. Re-open the replaced
|
|
472
|
-
// file and fsync it after rename so the replacement reaches stable storage.
|
|
473
|
-
const handle = openSync(targetPath, "r+");
|
|
474
|
-
try {
|
|
475
|
-
fsyncSync(handle);
|
|
476
|
-
}
|
|
477
|
-
finally {
|
|
478
|
-
closeSync(handle);
|
|
479
|
-
}
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
const handle = openSync(dirname(targetPath), "r");
|
|
483
|
-
try {
|
|
484
|
-
fsyncSync(handle);
|
|
485
|
-
}
|
|
486
|
-
finally {
|
|
487
|
-
closeSync(handle);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
function serializeFileEntry(entry) {
|
|
491
|
-
return Buffer.from(`${serializeEntryJson(entry)}\n`);
|
|
492
|
-
}
|
|
493
|
-
function writeBufferFully(handle, buffer) {
|
|
494
|
-
let written = 0;
|
|
495
|
-
while (written < buffer.byteLength) {
|
|
496
|
-
const bytesWritten = writeSync(handle, buffer, written, buffer.byteLength - written);
|
|
497
|
-
if (bytesWritten === 0) {
|
|
498
|
-
throw new Error("Session file write made no progress");
|
|
499
|
-
}
|
|
500
|
-
written += bytesWritten;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
function writeSessionFileAtomically(targetPath, entries) {
|
|
504
|
-
const header = entries.find((entry) => entry.type === "session");
|
|
505
|
-
const currentBytes = existsSync(targetPath) ? statSync(targetPath).size : 0;
|
|
506
|
-
assertSessionFileSize(header?.id ?? "<unresolved>", targetPath, currentBytes, measureSessionFileBytes(entries));
|
|
507
|
-
const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.rewrite-${process.pid}-${randomUUID()}.tmp`);
|
|
508
|
-
let tempHandle;
|
|
509
|
-
let replaced = false;
|
|
510
|
-
let primaryError;
|
|
511
|
-
const cleanupErrors = [];
|
|
512
|
-
try {
|
|
513
|
-
tempHandle = openSync(tempPath, "wx", 0o600);
|
|
514
|
-
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_open");
|
|
515
|
-
for (const entry of entries) {
|
|
516
|
-
writeBufferFully(tempHandle, serializeFileEntry(entry));
|
|
517
|
-
}
|
|
518
|
-
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_write");
|
|
519
|
-
fsyncSync(tempHandle);
|
|
520
|
-
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_temp_fsync");
|
|
521
|
-
closeSync(tempHandle);
|
|
522
|
-
tempHandle = undefined;
|
|
523
|
-
replaceSessionFile(tempPath, targetPath);
|
|
524
|
-
replaced = true;
|
|
525
|
-
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_replace");
|
|
526
|
-
fsyncSessionDirectory(targetPath);
|
|
527
|
-
sessionManagerPersistenceTestHooks?.onAtomicRewriteStage?.("after_directory_fsync");
|
|
528
|
-
}
|
|
529
|
-
catch (error) {
|
|
530
|
-
primaryError = error;
|
|
531
|
-
}
|
|
532
|
-
finally {
|
|
533
|
-
if (tempHandle !== undefined) {
|
|
534
|
-
try {
|
|
535
|
-
closeSync(tempHandle);
|
|
536
|
-
}
|
|
537
|
-
catch (error) {
|
|
538
|
-
cleanupErrors.push(error);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (!replaced && existsSync(tempPath)) {
|
|
542
|
-
try {
|
|
543
|
-
unlinkSync(tempPath);
|
|
544
|
-
}
|
|
545
|
-
catch (error) {
|
|
546
|
-
cleanupErrors.push(error);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
if (primaryError !== undefined && cleanupErrors.length === 0) {
|
|
551
|
-
throw primaryError;
|
|
552
|
-
}
|
|
553
|
-
if (primaryError !== undefined || cleanupErrors.length > 0) {
|
|
554
|
-
throw new AggregateError(primaryError === undefined ? cleanupErrors : [primaryError, ...cleanupErrors], `Failed to atomically rewrite session file ${targetPath}`);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
function persistedEntriesEqual(left, right) {
|
|
558
|
-
return isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
559
|
-
}
|
|
560
259
|
class SessionHeaderScanLimitError extends Error {
|
|
561
260
|
constructor(filePath) {
|
|
562
261
|
super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`);
|
|
563
262
|
this.name = "SessionHeaderScanLimitError";
|
|
564
263
|
}
|
|
565
264
|
}
|
|
566
|
-
|
|
567
|
-
path;
|
|
568
|
-
line;
|
|
569
|
-
constructor(filePath, line, cause) {
|
|
570
|
-
super(`Malformed session JSONL at ${filePath}:${line}`, { cause });
|
|
571
|
-
this.name = "SessionFileCorruptionError";
|
|
572
|
-
this.path = filePath;
|
|
573
|
-
this.line = line;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
function parseSessionEntryLineStrict(line, filePath, lineNumber) {
|
|
265
|
+
function parseSessionEntryLine(line) {
|
|
577
266
|
if (!line.trim())
|
|
578
267
|
return null;
|
|
579
268
|
try {
|
|
580
269
|
return JSON.parse(line);
|
|
581
270
|
}
|
|
582
|
-
catch
|
|
583
|
-
|
|
271
|
+
catch {
|
|
272
|
+
// Skip malformed lines
|
|
273
|
+
return null;
|
|
584
274
|
}
|
|
585
275
|
}
|
|
586
276
|
/** Exported for testing */
|
|
@@ -588,17 +278,12 @@ export function loadEntriesFromFile(filePath) {
|
|
|
588
278
|
const resolvedFilePath = normalizePath(filePath);
|
|
589
279
|
if (!existsSync(resolvedFilePath))
|
|
590
280
|
return [];
|
|
591
|
-
const fileBytes = statSync(resolvedFilePath).size;
|
|
592
|
-
assertSessionFileSize("<unresolved>", resolvedFilePath, 0, fileBytes);
|
|
593
281
|
const entries = [];
|
|
594
|
-
let treeEntryCount = 0;
|
|
595
|
-
let treeLogicalBytes = 0;
|
|
596
282
|
const fd = openSync(resolvedFilePath, "r");
|
|
597
283
|
try {
|
|
598
284
|
const decoder = new StringDecoder("utf8");
|
|
599
285
|
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
|
|
600
286
|
let pending = "";
|
|
601
|
-
let lineNumber = 1;
|
|
602
287
|
while (true) {
|
|
603
288
|
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
|
604
289
|
if (bytesRead === 0)
|
|
@@ -607,33 +292,18 @@ export function loadEntriesFromFile(filePath) {
|
|
|
607
292
|
let lineStart = 0;
|
|
608
293
|
let newlineIndex = pending.indexOf("\n", lineStart);
|
|
609
294
|
while (newlineIndex !== -1) {
|
|
610
|
-
const entry =
|
|
611
|
-
if (entry)
|
|
612
|
-
if (entry.type !== "session") {
|
|
613
|
-
const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
|
|
614
|
-
assertSessionTreeCapacity(entries[0]?.type === "session" ? entries[0].id : "<unresolved>", resolvedFilePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
|
|
615
|
-
treeEntryCount++;
|
|
616
|
-
treeLogicalBytes = nextLogicalBytes;
|
|
617
|
-
}
|
|
295
|
+
const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
|
|
296
|
+
if (entry)
|
|
618
297
|
entries.push(entry);
|
|
619
|
-
}
|
|
620
298
|
lineStart = newlineIndex + 1;
|
|
621
|
-
lineNumber++;
|
|
622
299
|
newlineIndex = pending.indexOf("\n", lineStart);
|
|
623
300
|
}
|
|
624
301
|
pending = pending.slice(lineStart);
|
|
625
302
|
}
|
|
626
303
|
pending += decoder.end();
|
|
627
|
-
const finalEntry =
|
|
628
|
-
if (finalEntry)
|
|
629
|
-
if (finalEntry.type !== "session") {
|
|
630
|
-
const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(finalEntry);
|
|
631
|
-
assertSessionTreeCapacity(entries[0]?.type === "session" ? entries[0].id : "<unresolved>", resolvedFilePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
|
|
632
|
-
treeEntryCount++;
|
|
633
|
-
treeLogicalBytes = nextLogicalBytes;
|
|
634
|
-
}
|
|
304
|
+
const finalEntry = parseSessionEntryLine(pending);
|
|
305
|
+
if (finalEntry)
|
|
635
306
|
entries.push(finalEntry);
|
|
636
|
-
}
|
|
637
307
|
}
|
|
638
308
|
finally {
|
|
639
309
|
closeSync(fd);
|
|
@@ -649,13 +319,13 @@ export function loadEntriesFromFile(filePath) {
|
|
|
649
319
|
}
|
|
650
320
|
/**
|
|
651
321
|
* Inspect a physical line while searching for the first parsed session entry.
|
|
652
|
-
* Blank lines are skipped
|
|
322
|
+
* Blank and malformed lines are skipped to match loadEntriesFromFile().
|
|
653
323
|
* Returns undefined to keep scanning, null for a parsed non-header entry, or the header.
|
|
654
324
|
*/
|
|
655
|
-
function parseSessionHeaderCandidate(line
|
|
325
|
+
function parseSessionHeaderCandidate(line) {
|
|
656
326
|
if (!line.trim())
|
|
657
327
|
return undefined;
|
|
658
|
-
const entry =
|
|
328
|
+
const entry = parseSessionEntryLine(line);
|
|
659
329
|
if (!entry)
|
|
660
330
|
return undefined;
|
|
661
331
|
if (entry.type !== "session" || typeof entry.id !== "string")
|
|
@@ -663,21 +333,18 @@ function parseSessionHeaderCandidate(line, filePath, lineNumber) {
|
|
|
663
333
|
return entry;
|
|
664
334
|
}
|
|
665
335
|
function readSessionHeader(filePath) {
|
|
666
|
-
const fileBytes = statSync(filePath).size;
|
|
667
|
-
assertSessionFileSize("<unresolved>", filePath, 0, fileBytes);
|
|
668
336
|
const fd = openSync(filePath, "r");
|
|
669
337
|
try {
|
|
670
338
|
const decoder = new StringDecoder("utf8");
|
|
671
339
|
const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE);
|
|
672
340
|
const lineChunks = [];
|
|
673
341
|
let scannedBytes = 0;
|
|
674
|
-
let lineNumber = 1;
|
|
675
342
|
while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) {
|
|
676
343
|
const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes);
|
|
677
344
|
const bytesRead = readSync(fd, buffer, 0, readLength, null);
|
|
678
345
|
if (bytesRead === 0) {
|
|
679
346
|
lineChunks.push(decoder.end());
|
|
680
|
-
return parseSessionHeaderCandidate(lineChunks.join("")
|
|
347
|
+
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
|
|
681
348
|
}
|
|
682
349
|
scannedBytes += bytesRead;
|
|
683
350
|
const chunk = decoder.write(buffer.subarray(0, bytesRead));
|
|
@@ -685,12 +352,11 @@ function readSessionHeader(filePath) {
|
|
|
685
352
|
let newlineIndex = chunk.indexOf("\n", lineStart);
|
|
686
353
|
while (newlineIndex !== -1) {
|
|
687
354
|
lineChunks.push(chunk.slice(lineStart, newlineIndex));
|
|
688
|
-
const header = parseSessionHeaderCandidate(lineChunks.join("")
|
|
355
|
+
const header = parseSessionHeaderCandidate(lineChunks.join(""));
|
|
689
356
|
if (header !== undefined)
|
|
690
357
|
return header;
|
|
691
358
|
lineChunks.length = 0;
|
|
692
359
|
lineStart = newlineIndex + 1;
|
|
693
|
-
lineNumber++;
|
|
694
360
|
newlineIndex = chunk.indexOf("\n", lineStart);
|
|
695
361
|
}
|
|
696
362
|
lineChunks.push(chunk.slice(lineStart));
|
|
@@ -700,7 +366,7 @@ function readSessionHeader(filePath) {
|
|
|
700
366
|
const probe = Buffer.allocUnsafe(1);
|
|
701
367
|
if (readSync(fd, probe, 0, probe.length, null) === 0) {
|
|
702
368
|
lineChunks.push(decoder.end());
|
|
703
|
-
return parseSessionHeaderCandidate(lineChunks.join("")
|
|
369
|
+
return parseSessionHeaderCandidate(lineChunks.join("")) ?? null;
|
|
704
370
|
}
|
|
705
371
|
throw new SessionHeaderScanLimitError(filePath);
|
|
706
372
|
}
|
|
@@ -712,10 +378,9 @@ function readSessionHeaderForDiscovery(filePath) {
|
|
|
712
378
|
try {
|
|
713
379
|
return readSessionHeader(filePath);
|
|
714
380
|
}
|
|
715
|
-
catch
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// Discovery remains best-effort for transient filesystem failures.
|
|
381
|
+
catch {
|
|
382
|
+
// Discovery is best-effort: unreadable or oversized files are not sessions,
|
|
383
|
+
// and one corrupt file must not prevent other sessions from being found.
|
|
719
384
|
return null;
|
|
720
385
|
}
|
|
721
386
|
}
|
|
@@ -741,9 +406,7 @@ export function findMostRecentSession(sessionDir, cwd) {
|
|
|
741
406
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
742
407
|
return files[0]?.path || null;
|
|
743
408
|
}
|
|
744
|
-
catch
|
|
745
|
-
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
746
|
-
throw error;
|
|
409
|
+
catch {
|
|
747
410
|
// Directory access and stat races make recent-session discovery unavailable.
|
|
748
411
|
return null;
|
|
749
412
|
}
|
|
@@ -777,23 +440,18 @@ function getMessageActivityTime(entry) {
|
|
|
777
440
|
async function buildSessionInfo(filePath) {
|
|
778
441
|
try {
|
|
779
442
|
const stats = await stat(filePath);
|
|
780
|
-
assertSessionFileSize("<unresolved>", filePath, 0, stats.size);
|
|
781
443
|
let header = null;
|
|
782
444
|
let messageCount = 0;
|
|
783
445
|
let firstMessage = "";
|
|
784
446
|
const allMessages = [];
|
|
785
447
|
let name;
|
|
786
448
|
let lastActivityTime;
|
|
787
|
-
let treeEntryCount = 0;
|
|
788
|
-
let treeLogicalBytes = 0;
|
|
789
|
-
let lineNumber = 0;
|
|
790
449
|
const rl = createInterface({
|
|
791
450
|
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
792
451
|
crlfDelay: Infinity,
|
|
793
452
|
});
|
|
794
453
|
for await (const line of rl) {
|
|
795
|
-
|
|
796
|
-
const entry = parseSessionEntryLineStrict(line, filePath, lineNumber);
|
|
454
|
+
const entry = parseSessionEntryLine(line);
|
|
797
455
|
if (!entry)
|
|
798
456
|
continue;
|
|
799
457
|
if (!header) {
|
|
@@ -802,12 +460,6 @@ async function buildSessionInfo(filePath) {
|
|
|
802
460
|
header = entry;
|
|
803
461
|
continue;
|
|
804
462
|
}
|
|
805
|
-
if (entry.type !== "session") {
|
|
806
|
-
const nextLogicalBytes = treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
|
|
807
|
-
assertSessionTreeCapacity(header.id, filePath, treeEntryCount, treeLogicalBytes, treeEntryCount + 1, nextLogicalBytes);
|
|
808
|
-
treeEntryCount++;
|
|
809
|
-
treeLogicalBytes = nextLogicalBytes;
|
|
810
|
-
}
|
|
811
463
|
// Extract session name (use latest, including explicit clears)
|
|
812
464
|
if (entry.type === "session_info") {
|
|
813
465
|
name = entry.name?.trim() || undefined;
|
|
@@ -855,9 +507,7 @@ async function buildSessionInfo(filePath) {
|
|
|
855
507
|
allMessagesText: allMessages.join(" "),
|
|
856
508
|
};
|
|
857
509
|
}
|
|
858
|
-
catch
|
|
859
|
-
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
860
|
-
throw error;
|
|
510
|
+
catch {
|
|
861
511
|
return null;
|
|
862
512
|
}
|
|
863
513
|
}
|
|
@@ -876,9 +526,7 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
|
|
|
876
526
|
.then((info) => {
|
|
877
527
|
results[index] = info;
|
|
878
528
|
})
|
|
879
|
-
.catch((
|
|
880
|
-
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
881
|
-
throw error;
|
|
529
|
+
.catch(() => {
|
|
882
530
|
results[index] = null;
|
|
883
531
|
})
|
|
884
532
|
.finally(() => {
|
|
@@ -892,13 +540,7 @@ async function buildSessionInfosWithConcurrency(files, onLoaded) {
|
|
|
892
540
|
startNext();
|
|
893
541
|
}
|
|
894
542
|
if (inFlight.size > 0) {
|
|
895
|
-
|
|
896
|
-
await Promise.race(inFlight);
|
|
897
|
-
}
|
|
898
|
-
catch (error) {
|
|
899
|
-
await Promise.allSettled([...inFlight]);
|
|
900
|
-
throw error;
|
|
901
|
-
}
|
|
543
|
+
await Promise.race(inFlight);
|
|
902
544
|
}
|
|
903
545
|
}
|
|
904
546
|
return results;
|
|
@@ -923,9 +565,7 @@ async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progress
|
|
|
923
565
|
}
|
|
924
566
|
}
|
|
925
567
|
}
|
|
926
|
-
catch
|
|
927
|
-
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
928
|
-
throw error;
|
|
568
|
+
catch {
|
|
929
569
|
// Return empty list on error
|
|
930
570
|
}
|
|
931
571
|
return sessions;
|
|
@@ -953,11 +593,6 @@ export class SessionManager {
|
|
|
953
593
|
labelsById = new Map();
|
|
954
594
|
labelTimestampsById = new Map();
|
|
955
595
|
leafId = null;
|
|
956
|
-
treeEntryCount = 0;
|
|
957
|
-
treeLogicalBytes = 0;
|
|
958
|
-
treeHash = PI_SERVER_EMPTY_TREE_HASH;
|
|
959
|
-
treeHashError;
|
|
960
|
-
appendPersistenceFailure;
|
|
961
596
|
constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) {
|
|
962
597
|
this.cwd = resolvePath(cwd);
|
|
963
598
|
this.sessionDir = normalizePath(sessionDir);
|
|
@@ -977,51 +612,34 @@ export class SessionManager {
|
|
|
977
612
|
this._setSessionFile(sessionFile);
|
|
978
613
|
}
|
|
979
614
|
_setSessionFile(sessionFile, preloadedFileEntries) {
|
|
980
|
-
|
|
981
|
-
if (existsSync(
|
|
982
|
-
|
|
615
|
+
this.sessionFile = resolvePath(sessionFile);
|
|
616
|
+
if (existsSync(this.sessionFile)) {
|
|
617
|
+
this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile);
|
|
983
618
|
// If file was empty, initialize it with a valid session header. If it was
|
|
984
619
|
// non-empty but did not parse as a pi session, fail without modifying it.
|
|
985
|
-
if (
|
|
986
|
-
|
|
987
|
-
|
|
620
|
+
if (this.fileEntries.length === 0) {
|
|
621
|
+
const explicitPath = this.sessionFile;
|
|
622
|
+
if (statSync(explicitPath).size > 0) {
|
|
623
|
+
throw new Error(`Session file is not a valid pi session: ${explicitPath}`);
|
|
988
624
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
version: CURRENT_SESSION_VERSION,
|
|
993
|
-
id: nextSessionId,
|
|
994
|
-
timestamp: new Date().toISOString(),
|
|
995
|
-
cwd: this.cwd,
|
|
996
|
-
};
|
|
997
|
-
writeSessionFileAtomically(resolvedSessionFile, [header]);
|
|
998
|
-
this.sessionId = nextSessionId;
|
|
999
|
-
this.sessionFile = resolvedSessionFile;
|
|
1000
|
-
this.fileEntries = [header];
|
|
1001
|
-
this._buildIndex();
|
|
1002
|
-
this.appendPersistenceFailure = undefined;
|
|
625
|
+
this.newSession();
|
|
626
|
+
this.sessionFile = explicitPath;
|
|
627
|
+
this._rewriteFile();
|
|
1003
628
|
this.flushed = true;
|
|
1004
629
|
return;
|
|
1005
630
|
}
|
|
1006
|
-
const header =
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
validateSessionTreeShape(treeEntries);
|
|
1011
|
-
assertSessionTreeCapacity(nextSessionId, resolvedSessionFile, 0, 0, treeEntries.length, measureSessionEntriesLogicalBytes(treeEntries));
|
|
1012
|
-
if (migrated) {
|
|
1013
|
-
writeSessionFileAtomically(resolvedSessionFile, nextFileEntries);
|
|
631
|
+
const header = this.fileEntries.find((e) => e.type === "session");
|
|
632
|
+
this.sessionId = header?.id ?? createSessionId();
|
|
633
|
+
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
634
|
+
this._rewriteFile();
|
|
1014
635
|
}
|
|
1015
|
-
this.sessionId = nextSessionId;
|
|
1016
|
-
this.sessionFile = resolvedSessionFile;
|
|
1017
|
-
this.fileEntries = nextFileEntries;
|
|
1018
636
|
this._buildIndex();
|
|
1019
|
-
this.appendPersistenceFailure = undefined;
|
|
1020
637
|
this.flushed = true;
|
|
1021
638
|
}
|
|
1022
639
|
else {
|
|
640
|
+
const explicitPath = this.sessionFile;
|
|
1023
641
|
this.newSession();
|
|
1024
|
-
this.sessionFile =
|
|
642
|
+
this.sessionFile = explicitPath; // preserve explicit path from --session flag
|
|
1025
643
|
}
|
|
1026
644
|
}
|
|
1027
645
|
newSession(options) {
|
|
@@ -1043,11 +661,6 @@ export class SessionManager {
|
|
|
1043
661
|
this.labelsById.clear();
|
|
1044
662
|
this.labelTimestampsById.clear();
|
|
1045
663
|
this.leafId = null;
|
|
1046
|
-
this.treeEntryCount = 0;
|
|
1047
|
-
this.treeLogicalBytes = 0;
|
|
1048
|
-
this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
|
|
1049
|
-
this.treeHashError = undefined;
|
|
1050
|
-
this.appendPersistenceFailure = undefined;
|
|
1051
664
|
this.flushed = false;
|
|
1052
665
|
if (this.persist) {
|
|
1053
666
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
@@ -1056,26 +669,15 @@ export class SessionManager {
|
|
|
1056
669
|
return this.sessionFile;
|
|
1057
670
|
}
|
|
1058
671
|
_buildIndex() {
|
|
1059
|
-
const entries = this.fileEntries.filter((entry) => entry.type !== "session");
|
|
1060
|
-
validateSessionTreeShape(entries);
|
|
1061
|
-
const logicalBytes = measureSessionEntriesLogicalBytes(entries);
|
|
1062
|
-
assertSessionTreeCapacity(this.sessionId, this.sessionFile, 0, 0, entries.length, logicalBytes);
|
|
1063
672
|
this.byId.clear();
|
|
1064
673
|
this.labelsById.clear();
|
|
1065
674
|
this.labelTimestampsById.clear();
|
|
1066
675
|
this.leafId = null;
|
|
1067
|
-
this.treeEntryCount = 0;
|
|
1068
|
-
this.treeLogicalBytes = 0;
|
|
1069
|
-
this.treeHash = PI_SERVER_EMPTY_TREE_HASH;
|
|
1070
|
-
this.treeHashError = undefined;
|
|
1071
676
|
for (const entry of this.fileEntries) {
|
|
1072
677
|
if (entry.type === "session")
|
|
1073
678
|
continue;
|
|
1074
|
-
this.treeEntryCount++;
|
|
1075
|
-
this.treeLogicalBytes += measureSessionEntryLogicalBytes(entry);
|
|
1076
679
|
this.byId.set(entry.id, entry);
|
|
1077
680
|
this.leafId = entry.id;
|
|
1078
|
-
this._updateTreeHash(entry);
|
|
1079
681
|
if (entry.type === "label") {
|
|
1080
682
|
if (entry.label) {
|
|
1081
683
|
this.labelsById.set(entry.targetId, entry.label);
|
|
@@ -1088,32 +690,17 @@ export class SessionManager {
|
|
|
1088
690
|
}
|
|
1089
691
|
}
|
|
1090
692
|
}
|
|
1091
|
-
|
|
1092
|
-
if (this.treeHash === undefined)
|
|
1093
|
-
return;
|
|
1094
|
-
try {
|
|
1095
|
-
this.treeHash = appendPiServerTreeHash(this.treeHash, hashPiServerTreeEntry(entry));
|
|
1096
|
-
}
|
|
1097
|
-
catch (error) {
|
|
1098
|
-
this.treeHash = undefined;
|
|
1099
|
-
this.treeHashError = error;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
_rewriteFile(entries = this.fileEntries) {
|
|
693
|
+
_rewriteFile() {
|
|
1103
694
|
if (!this.persist || !this.sessionFile)
|
|
1104
695
|
return;
|
|
1105
|
-
|
|
1106
|
-
throw new Error("Session persistence has an indeterminate prior failure; reopen the session before retrying", {
|
|
1107
|
-
cause: this.appendPersistenceFailure,
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
696
|
+
const fd = openSync(this.sessionFile, "w");
|
|
1110
697
|
try {
|
|
1111
|
-
|
|
698
|
+
for (const entry of this.fileEntries) {
|
|
699
|
+
writeFileSync(fd, `${JSON.stringify(entry)}\n`);
|
|
700
|
+
}
|
|
1112
701
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
const details = error instanceof Error ? error.message : String(error);
|
|
1116
|
-
throw new Error(`Session file rewrite may have replaced ${this.sessionFile} without a durable in-memory commit; reopen the session before retrying: ${details}`, { cause: error });
|
|
702
|
+
finally {
|
|
703
|
+
closeSync(fd);
|
|
1117
704
|
}
|
|
1118
705
|
}
|
|
1119
706
|
isPersisted() {
|
|
@@ -1134,118 +721,41 @@ export class SessionManager {
|
|
|
1134
721
|
getSessionFile() {
|
|
1135
722
|
return this.sessionFile;
|
|
1136
723
|
}
|
|
1137
|
-
|
|
1138
|
-
* Durably flush the persisted session file before an external acknowledgement.
|
|
1139
|
-
* In-memory sessions and sessions without a materialized file have nothing to flush.
|
|
1140
|
-
*/
|
|
1141
|
-
flushSessionFile() {
|
|
1142
|
-
if (!this.persist || !this.sessionFile || !existsSync(this.sessionFile))
|
|
1143
|
-
return;
|
|
1144
|
-
const handle = openSync(this.sessionFile, "r+");
|
|
1145
|
-
try {
|
|
1146
|
-
fsyncSync(handle);
|
|
1147
|
-
}
|
|
1148
|
-
finally {
|
|
1149
|
-
closeSync(handle);
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
_appendMaterializedEntry(entry) {
|
|
1153
|
-
if (!this.sessionFile)
|
|
1154
|
-
throw new Error("Cannot append without a session file");
|
|
1155
|
-
const handle = openSync(this.sessionFile, fsConstants.O_WRONLY | fsConstants.O_APPEND);
|
|
1156
|
-
let appendError;
|
|
1157
|
-
try {
|
|
1158
|
-
writeBufferFully(handle, serializeFileEntry(entry));
|
|
1159
|
-
sessionManagerPersistenceTestHooks?.onAppendStage?.("after_append");
|
|
1160
|
-
fsyncSync(handle);
|
|
1161
|
-
sessionManagerPersistenceTestHooks?.onAppendStage?.("after_fsync");
|
|
1162
|
-
}
|
|
1163
|
-
catch (error) {
|
|
1164
|
-
appendError = error;
|
|
1165
|
-
}
|
|
1166
|
-
try {
|
|
1167
|
-
closeSync(handle);
|
|
1168
|
-
}
|
|
1169
|
-
catch (error) {
|
|
1170
|
-
appendError =
|
|
1171
|
-
appendError === undefined
|
|
1172
|
-
? error
|
|
1173
|
-
: new AggregateError([appendError, error], `Failed to close session file ${this.sessionFile}`);
|
|
1174
|
-
}
|
|
1175
|
-
if (appendError !== undefined) {
|
|
1176
|
-
this.appendPersistenceFailure = appendError;
|
|
1177
|
-
throw new Error(`Session entry ${entry.id} may have reached disk but was not committed in memory; reopen the session before retrying`, { cause: appendError });
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
_persistBeforeCommit(entry, nextFileEntries) {
|
|
1181
|
-
if (!this.persist || !this.sessionFile)
|
|
1182
|
-
return this.flushed;
|
|
1183
|
-
const hasAssistant = nextFileEntries.some((candidate) => {
|
|
1184
|
-
return candidate.type === "message" && candidate.message.role === "assistant";
|
|
1185
|
-
});
|
|
1186
|
-
if (!hasAssistant) {
|
|
1187
|
-
if (this.flushed)
|
|
1188
|
-
this._appendMaterializedEntry(entry);
|
|
1189
|
-
return this.flushed;
|
|
1190
|
-
}
|
|
1191
|
-
if (this.flushed) {
|
|
1192
|
-
this._appendMaterializedEntry(entry);
|
|
1193
|
-
return true;
|
|
1194
|
-
}
|
|
1195
|
-
try {
|
|
1196
|
-
writeSessionFileAtomically(this.sessionFile, nextFileEntries);
|
|
1197
|
-
}
|
|
1198
|
-
catch (error) {
|
|
1199
|
-
this.appendPersistenceFailure = error;
|
|
1200
|
-
throw new Error(`Session entry ${entry.id} persistence failed before the in-memory commit; reopen the session before retrying`, { cause: error });
|
|
1201
|
-
}
|
|
1202
|
-
return true;
|
|
1203
|
-
}
|
|
1204
|
-
_preflightPersistEntry(entry) {
|
|
724
|
+
_persist(entry) {
|
|
1205
725
|
if (!this.persist || !this.sessionFile)
|
|
1206
726
|
return;
|
|
1207
|
-
const
|
|
1208
|
-
if (!
|
|
1209
|
-
|
|
727
|
+
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
|
728
|
+
if (!hasAssistant) {
|
|
729
|
+
if (this.flushed) {
|
|
730
|
+
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
// Mark as not flushed so when assistant arrives, all entries get written
|
|
734
|
+
this.flushed = false;
|
|
735
|
+
}
|
|
1210
736
|
return;
|
|
1211
737
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
}
|
|
1215
|
-
_preflightAppendEntry(entry) {
|
|
1216
|
-
const nextLogicalBytes = this.treeLogicalBytes + measureSessionEntryLogicalBytes(entry);
|
|
1217
|
-
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, this.treeEntryCount + 1, nextLogicalBytes);
|
|
1218
|
-
this._preflightPersistEntry(entry);
|
|
1219
|
-
return nextLogicalBytes;
|
|
1220
|
-
}
|
|
1221
|
-
_appendEntry(entry) {
|
|
1222
|
-
if (this.appendPersistenceFailure !== undefined) {
|
|
1223
|
-
throw new Error("Session append persistence has an indeterminate prior failure; reopen the session before retrying", {
|
|
1224
|
-
cause: this.appendPersistenceFailure,
|
|
1225
|
-
});
|
|
1226
|
-
}
|
|
1227
|
-
const nextLogicalBytes = this._preflightAppendEntry(entry);
|
|
1228
|
-
const nextFileEntries = [...this.fileEntries, entry];
|
|
1229
|
-
let nextTreeHash = this.treeHash;
|
|
1230
|
-
let nextTreeHashError = this.treeHashError;
|
|
1231
|
-
if (nextTreeHash !== undefined) {
|
|
738
|
+
if (!this.flushed) {
|
|
739
|
+
const fd = openSync(this.sessionFile, "wx");
|
|
1232
740
|
try {
|
|
1233
|
-
|
|
741
|
+
for (const e of this.fileEntries) {
|
|
742
|
+
writeFileSync(fd, `${JSON.stringify(e)}\n`);
|
|
743
|
+
}
|
|
1234
744
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
nextTreeHashError = error;
|
|
745
|
+
finally {
|
|
746
|
+
closeSync(fd);
|
|
1238
747
|
}
|
|
748
|
+
this.flushed = true;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
1239
752
|
}
|
|
1240
|
-
|
|
753
|
+
}
|
|
754
|
+
_appendEntry(entry) {
|
|
1241
755
|
this.fileEntries.push(entry);
|
|
1242
|
-
this.treeEntryCount++;
|
|
1243
|
-
this.treeLogicalBytes = nextLogicalBytes;
|
|
1244
756
|
this.byId.set(entry.id, entry);
|
|
1245
757
|
this.leafId = entry.id;
|
|
1246
|
-
this.
|
|
1247
|
-
this.treeHashError = nextTreeHashError;
|
|
1248
|
-
this.flushed = nextFlushed;
|
|
758
|
+
this._persist(entry);
|
|
1249
759
|
}
|
|
1250
760
|
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
1251
761
|
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
@@ -1284,12 +794,9 @@ export class SessionManager {
|
|
|
1284
794
|
parentId = entry.id;
|
|
1285
795
|
return entry;
|
|
1286
796
|
});
|
|
1287
|
-
|
|
1288
|
-
const nextTreeEntries = nextFileEntries.filter((entry) => entry.type !== "session");
|
|
1289
|
-
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, nextTreeEntries.length, measureSessionEntriesLogicalBytes(nextTreeEntries));
|
|
1290
|
-
this._rewriteFile(nextFileEntries);
|
|
1291
|
-
this.fileEntries = nextFileEntries;
|
|
797
|
+
this.fileEntries = [...retainedEntries, ...messageEntries];
|
|
1292
798
|
this._buildIndex();
|
|
799
|
+
this._rewriteFile();
|
|
1293
800
|
this.flushed = this.persist;
|
|
1294
801
|
}
|
|
1295
802
|
replaceTree(entries, leafId) {
|
|
@@ -1297,145 +804,27 @@ export class SessionManager {
|
|
|
1297
804
|
if (!header) {
|
|
1298
805
|
throw new Error("Session header is missing");
|
|
1299
806
|
}
|
|
1300
|
-
const
|
|
1301
|
-
const
|
|
1302
|
-
|
|
807
|
+
const ids = new Set();
|
|
808
|
+
for (const entry of entries) {
|
|
809
|
+
if (ids.has(entry.id)) {
|
|
810
|
+
throw new Error(`Duplicate session entry id ${entry.id}`);
|
|
811
|
+
}
|
|
812
|
+
ids.add(entry.id);
|
|
813
|
+
}
|
|
814
|
+
for (const entry of entries) {
|
|
815
|
+
if (entry.parentId !== null && !ids.has(entry.parentId)) {
|
|
816
|
+
throw new Error(`Parent entry ${entry.parentId} does not exist`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (leafId !== null && !ids.has(leafId)) {
|
|
1303
820
|
throw new Error(`Leaf entry ${leafId} does not exist`);
|
|
1304
821
|
}
|
|
1305
|
-
|
|
1306
|
-
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, savedEntries.length, nextLogicalBytes);
|
|
1307
|
-
const nextFileEntries = [header, ...savedEntries];
|
|
1308
|
-
this._rewriteFile(nextFileEntries);
|
|
1309
|
-
this.fileEntries = nextFileEntries;
|
|
822
|
+
this.fileEntries = [header, ...entries.map((entry) => ({ ...entry }))];
|
|
1310
823
|
this._buildIndex();
|
|
1311
824
|
this.leafId = leafId;
|
|
825
|
+
this._rewriteFile();
|
|
1312
826
|
this.flushed = this.persist;
|
|
1313
827
|
}
|
|
1314
|
-
/**
|
|
1315
|
-
* Durably append one authoritative compaction entry without rewriting the
|
|
1316
|
-
* existing session history. The caller must provide the exact local base
|
|
1317
|
-
* identity used by the compaction request.
|
|
1318
|
-
*
|
|
1319
|
-
* Re-applying the exact already-durable entry is an idempotent no-op. Any
|
|
1320
|
-
* other base, leaf, parent, or entry-id divergence fails closed.
|
|
1321
|
-
*/
|
|
1322
|
-
appendCompactionEntry(entry, base) {
|
|
1323
|
-
if (!Number.isSafeInteger(base.baseEntryCount) ||
|
|
1324
|
-
base.baseEntryCount < 0 ||
|
|
1325
|
-
(base.baseLeafId !== null && (typeof base.baseLeafId !== "string" || base.baseLeafId.length === 0)) ||
|
|
1326
|
-
typeof base.baseTreeHash !== "string" ||
|
|
1327
|
-
!/^[a-f0-9]{64}$/u.test(base.baseTreeHash)) {
|
|
1328
|
-
throw new Error("Compaction append base identity is invalid");
|
|
1329
|
-
}
|
|
1330
|
-
if (entry.type !== "compaction" ||
|
|
1331
|
-
typeof entry.id !== "string" ||
|
|
1332
|
-
entry.id.length === 0 ||
|
|
1333
|
-
typeof entry.timestamp !== "string" ||
|
|
1334
|
-
entry.timestamp.length === 0 ||
|
|
1335
|
-
typeof entry.summary !== "string" ||
|
|
1336
|
-
typeof entry.firstKeptEntryId !== "string" ||
|
|
1337
|
-
entry.firstKeptEntryId.length === 0 ||
|
|
1338
|
-
!Number.isFinite(entry.tokensBefore) ||
|
|
1339
|
-
entry.tokensBefore < 0 ||
|
|
1340
|
-
(entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail))) {
|
|
1341
|
-
throw new Error("Compaction entry is invalid");
|
|
1342
|
-
}
|
|
1343
|
-
let entryHash;
|
|
1344
|
-
try {
|
|
1345
|
-
entryHash = hashPiServerTreeEntry(entry);
|
|
1346
|
-
}
|
|
1347
|
-
catch (error) {
|
|
1348
|
-
throw new Error(`Compaction entry ${entry.id} is not serializable`, { cause: error });
|
|
1349
|
-
}
|
|
1350
|
-
const currentTreeHash = this.treeHash;
|
|
1351
|
-
if (currentTreeHash === undefined) {
|
|
1352
|
-
throw new Error("Cannot validate compaction base because the local tree contains a non-serializable entry", {
|
|
1353
|
-
cause: this.treeHashError,
|
|
1354
|
-
});
|
|
1355
|
-
}
|
|
1356
|
-
const currentEntryCount = this.treeEntryCount;
|
|
1357
|
-
if (currentEntryCount === base.baseEntryCount + 1) {
|
|
1358
|
-
const existing = this.fileEntries.at(-1);
|
|
1359
|
-
if (existing?.type === "compaction" &&
|
|
1360
|
-
existing.id === entry.id &&
|
|
1361
|
-
this.leafId === entry.id &&
|
|
1362
|
-
currentTreeHash === appendPiServerTreeHash(base.baseTreeHash, entryHash) &&
|
|
1363
|
-
persistedEntriesEqual(existing, entry)) {
|
|
1364
|
-
if (this.persist && (!this.sessionFile || !this.flushed || !existsSync(this.sessionFile))) {
|
|
1365
|
-
throw new Error("Cannot verify an already-applied compaction without its materialized session file");
|
|
1366
|
-
}
|
|
1367
|
-
this.flushSessionFile();
|
|
1368
|
-
return entry.id;
|
|
1369
|
-
}
|
|
1370
|
-
throw new Error(`Compaction entry ${entry.id} cannot be replayed because the local tree has a divergent entry after the requested base`);
|
|
1371
|
-
}
|
|
1372
|
-
if (currentEntryCount !== base.baseEntryCount) {
|
|
1373
|
-
throw new Error(`Compaction append base entry count mismatch: expected ${base.baseEntryCount}, current ${currentEntryCount}`);
|
|
1374
|
-
}
|
|
1375
|
-
if (currentTreeHash !== base.baseTreeHash) {
|
|
1376
|
-
throw new Error(`Compaction append base tree hash mismatch`);
|
|
1377
|
-
}
|
|
1378
|
-
if (this.leafId !== base.baseLeafId) {
|
|
1379
|
-
throw new Error(`Compaction append base leaf mismatch: expected ${String(base.baseLeafId)}, current ${String(this.leafId)}`);
|
|
1380
|
-
}
|
|
1381
|
-
if (entry.parentId !== base.baseLeafId) {
|
|
1382
|
-
throw new Error(`Compaction entry parent mismatch: expected ${String(base.baseLeafId)}, received ${String(entry.parentId)}`);
|
|
1383
|
-
}
|
|
1384
|
-
if (base.baseLeafId !== null && !this.byId.has(base.baseLeafId)) {
|
|
1385
|
-
throw new Error(`Compaction append base leaf ${base.baseLeafId} does not exist`);
|
|
1386
|
-
}
|
|
1387
|
-
if (!this.byId.has(entry.firstKeptEntryId)) {
|
|
1388
|
-
throw new Error(`Compaction first-kept entry ${entry.firstKeptEntryId} does not exist`);
|
|
1389
|
-
}
|
|
1390
|
-
if (this.byId.has(entry.id)) {
|
|
1391
|
-
throw new Error(`Compaction entry id ${entry.id} already exists`);
|
|
1392
|
-
}
|
|
1393
|
-
if (this.appendPersistenceFailure !== undefined) {
|
|
1394
|
-
throw new Error("Compaction append persistence has an indeterminate prior failure; reopen the session before retrying", { cause: this.appendPersistenceFailure });
|
|
1395
|
-
}
|
|
1396
|
-
const savedEntry = { ...entry };
|
|
1397
|
-
const nextLogicalBytes = this.treeLogicalBytes + measureSessionEntryLogicalBytes(savedEntry);
|
|
1398
|
-
assertSessionTreeCapacity(this.sessionId, this.sessionFile, this.treeEntryCount, this.treeLogicalBytes, this.treeEntryCount + 1, nextLogicalBytes);
|
|
1399
|
-
const serializedEntry = serializeFileEntry(savedEntry);
|
|
1400
|
-
if (this.persist) {
|
|
1401
|
-
if (!this.sessionFile || !this.flushed || !existsSync(this.sessionFile)) {
|
|
1402
|
-
throw new Error("Cannot durably append compaction before the session file is materialized");
|
|
1403
|
-
}
|
|
1404
|
-
const currentFileBytes = statSync(this.sessionFile).size;
|
|
1405
|
-
assertSessionFileSize(this.sessionId, this.sessionFile, currentFileBytes, currentFileBytes + serializedEntry.byteLength);
|
|
1406
|
-
const handle = openSync(this.sessionFile, fsConstants.O_WRONLY | fsConstants.O_APPEND);
|
|
1407
|
-
let appendError;
|
|
1408
|
-
try {
|
|
1409
|
-
writeBufferFully(handle, serializedEntry);
|
|
1410
|
-
sessionManagerPersistenceTestHooks?.onCompactionAppendStage?.("after_append");
|
|
1411
|
-
fsyncSync(handle);
|
|
1412
|
-
sessionManagerPersistenceTestHooks?.onCompactionAppendStage?.("after_fsync");
|
|
1413
|
-
}
|
|
1414
|
-
catch (error) {
|
|
1415
|
-
appendError = error;
|
|
1416
|
-
}
|
|
1417
|
-
try {
|
|
1418
|
-
closeSync(handle);
|
|
1419
|
-
}
|
|
1420
|
-
catch (error) {
|
|
1421
|
-
appendError =
|
|
1422
|
-
appendError === undefined
|
|
1423
|
-
? error
|
|
1424
|
-
: new AggregateError([appendError, error], `Failed to close session file ${this.sessionFile}`);
|
|
1425
|
-
}
|
|
1426
|
-
if (appendError !== undefined) {
|
|
1427
|
-
this.appendPersistenceFailure = appendError;
|
|
1428
|
-
throw new Error(`Compaction entry ${entry.id} may have reached disk but was not committed in memory; reopen the session before retrying`, { cause: appendError });
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
this.fileEntries.push(savedEntry);
|
|
1432
|
-
this.treeEntryCount++;
|
|
1433
|
-
this.treeLogicalBytes = nextLogicalBytes;
|
|
1434
|
-
this.byId.set(savedEntry.id, savedEntry);
|
|
1435
|
-
this.leafId = savedEntry.id;
|
|
1436
|
-
this.treeHash = appendPiServerTreeHash(currentTreeHash, entryHash);
|
|
1437
|
-
return savedEntry.id;
|
|
1438
|
-
}
|
|
1439
828
|
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
1440
829
|
appendThinkingLevelChange(thinkingLevel) {
|
|
1441
830
|
const entry = {
|
|
@@ -1462,7 +851,7 @@ export class SessionManager {
|
|
|
1462
851
|
return entry.id;
|
|
1463
852
|
}
|
|
1464
853
|
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
1465
|
-
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage
|
|
854
|
+
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
|
|
1466
855
|
const entry = {
|
|
1467
856
|
type: "compaction",
|
|
1468
857
|
id: generateId(this.byId),
|
|
@@ -1471,7 +860,6 @@ export class SessionManager {
|
|
|
1471
860
|
summary,
|
|
1472
861
|
firstKeptEntryId,
|
|
1473
862
|
tokensBefore,
|
|
1474
|
-
retainedTail,
|
|
1475
863
|
details,
|
|
1476
864
|
usage,
|
|
1477
865
|
fromHook,
|
|
@@ -1717,6 +1105,7 @@ export class SessionManager {
|
|
|
1717
1105
|
if (branchFromId !== null && !this.byId.has(branchFromId)) {
|
|
1718
1106
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
1719
1107
|
}
|
|
1108
|
+
this.leafId = branchFromId;
|
|
1720
1109
|
const entry = {
|
|
1721
1110
|
type: "branch_summary",
|
|
1722
1111
|
id: generateId(this.byId),
|
|
@@ -1773,42 +1162,62 @@ export class SessionManager {
|
|
|
1773
1162
|
labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) });
|
|
1774
1163
|
}
|
|
1775
1164
|
}
|
|
1165
|
+
if (this.persist) {
|
|
1166
|
+
// Build label entries
|
|
1167
|
+
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
1168
|
+
let parentId = lastEntryId;
|
|
1169
|
+
const labelEntries = [];
|
|
1170
|
+
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
|
|
1171
|
+
const labelEntry = {
|
|
1172
|
+
type: "label",
|
|
1173
|
+
id: generateId(new Set(pathEntryIds)),
|
|
1174
|
+
parentId,
|
|
1175
|
+
timestamp: labelTimestamp,
|
|
1176
|
+
targetId,
|
|
1177
|
+
label,
|
|
1178
|
+
};
|
|
1179
|
+
pathEntryIds.add(labelEntry.id);
|
|
1180
|
+
labelEntries.push(labelEntry);
|
|
1181
|
+
parentId = labelEntry.id;
|
|
1182
|
+
}
|
|
1183
|
+
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
1184
|
+
this.sessionId = newSessionId;
|
|
1185
|
+
this.sessionFile = newSessionFile;
|
|
1186
|
+
this._buildIndex();
|
|
1187
|
+
// Only write the file now if it contains an assistant message.
|
|
1188
|
+
// Otherwise defer to _persist(), which creates the file on the
|
|
1189
|
+
// first assistant response, matching the newSession() contract
|
|
1190
|
+
// and avoiding the duplicate-header bug when _persist()'s
|
|
1191
|
+
// no-assistant guard later resets flushed to false.
|
|
1192
|
+
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
|
1193
|
+
if (hasAssistant) {
|
|
1194
|
+
this._rewriteFile();
|
|
1195
|
+
this.flushed = true;
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
this.flushed = false;
|
|
1199
|
+
}
|
|
1200
|
+
return newSessionFile;
|
|
1201
|
+
}
|
|
1202
|
+
// In-memory mode: replace current session with the path + labels
|
|
1776
1203
|
const labelEntries = [];
|
|
1777
|
-
const labelEntryIds = new Set(pathEntryIds);
|
|
1778
1204
|
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
1779
1205
|
for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
|
|
1780
1206
|
const labelEntry = {
|
|
1781
1207
|
type: "label",
|
|
1782
|
-
id: generateId(
|
|
1208
|
+
id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
|
|
1783
1209
|
parentId,
|
|
1784
1210
|
timestamp: labelTimestamp,
|
|
1785
1211
|
targetId,
|
|
1786
1212
|
label,
|
|
1787
1213
|
};
|
|
1788
|
-
labelEntryIds.add(labelEntry.id);
|
|
1789
1214
|
labelEntries.push(labelEntry);
|
|
1790
1215
|
parentId = labelEntry.id;
|
|
1791
1216
|
}
|
|
1792
|
-
|
|
1793
|
-
const nextFileEntries = [header, ...nextTreeEntries];
|
|
1794
|
-
assertSessionTreeCapacity(newSessionId, this.persist ? newSessionFile : undefined, this.treeEntryCount, this.treeLogicalBytes, nextTreeEntries.length, measureSessionEntriesLogicalBytes(nextTreeEntries));
|
|
1795
|
-
const hasAssistant = nextFileEntries.some((entry) => {
|
|
1796
|
-
return entry.type === "message" && entry.message.role === "assistant";
|
|
1797
|
-
});
|
|
1798
|
-
if (this.persist) {
|
|
1799
|
-
assertSessionFileSize(newSessionId, newSessionFile, existsSync(newSessionFile) ? statSync(newSessionFile).size : 0, measureSessionFileBytes(nextFileEntries));
|
|
1800
|
-
if (hasAssistant) {
|
|
1801
|
-
writeSessionFileAtomically(newSessionFile, nextFileEntries);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
this.fileEntries = nextFileEntries;
|
|
1217
|
+
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
1805
1218
|
this.sessionId = newSessionId;
|
|
1806
|
-
if (this.persist)
|
|
1807
|
-
this.sessionFile = newSessionFile;
|
|
1808
1219
|
this._buildIndex();
|
|
1809
|
-
|
|
1810
|
-
this.flushed = this.persist && hasAssistant;
|
|
1811
|
-
return this.persist ? newSessionFile : undefined;
|
|
1220
|
+
return undefined;
|
|
1812
1221
|
}
|
|
1813
1222
|
/**
|
|
1814
1223
|
* Create a new session.
|
|
@@ -1884,6 +1293,10 @@ export class SessionManager {
|
|
|
1884
1293
|
if (!sourceHeader) {
|
|
1885
1294
|
throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
|
|
1886
1295
|
}
|
|
1296
|
+
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
|
|
1297
|
+
if (!existsSync(dir)) {
|
|
1298
|
+
mkdirSync(dir, { recursive: true });
|
|
1299
|
+
}
|
|
1887
1300
|
// Create new session file with new ID but forked content
|
|
1888
1301
|
if (options?.id !== undefined) {
|
|
1889
1302
|
assertValidSessionId(options.id);
|
|
@@ -1891,7 +1304,6 @@ export class SessionManager {
|
|
|
1891
1304
|
const newSessionId = options?.id ?? createSessionId();
|
|
1892
1305
|
const timestamp = new Date().toISOString();
|
|
1893
1306
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
1894
|
-
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDirPath(resolvedTargetCwd);
|
|
1895
1307
|
const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
|
|
1896
1308
|
// Write new header pointing to source as parent, with updated cwd
|
|
1897
1309
|
const newHeader = {
|
|
@@ -1902,16 +1314,13 @@ export class SessionManager {
|
|
|
1902
1314
|
cwd: resolvedTargetCwd,
|
|
1903
1315
|
parentSession: resolvedSourcePath,
|
|
1904
1316
|
};
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
if (!existsSync(dir)) {
|
|
1912
|
-
mkdirSync(dir, { recursive: true });
|
|
1317
|
+
writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
|
|
1318
|
+
// Copy all non-header entries from source
|
|
1319
|
+
for (const entry of sourceEntries) {
|
|
1320
|
+
if (entry.type !== "session") {
|
|
1321
|
+
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
|
1322
|
+
}
|
|
1913
1323
|
}
|
|
1914
|
-
writeSessionFileAtomically(newSessionFile, nextFileEntries);
|
|
1915
1324
|
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
|
|
1916
1325
|
}
|
|
1917
1326
|
/**
|
|
@@ -1972,9 +1381,7 @@ export class SessionManager {
|
|
|
1972
1381
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1973
1382
|
return sessions;
|
|
1974
1383
|
}
|
|
1975
|
-
catch
|
|
1976
|
-
if (error instanceof SessionManagerCapacityError || error instanceof SessionFileCorruptionError)
|
|
1977
|
-
throw error;
|
|
1384
|
+
catch {
|
|
1978
1385
|
return [];
|
|
1979
1386
|
}
|
|
1980
1387
|
}
|