parallel-codex-tui 0.2.8 → 0.2.10
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 +9 -7
- package/dist/cli.js +11 -1
- package/dist/core/session-manager.js +569 -13
- package/dist/domain/schemas.js +8 -0
- package/dist/tui/App.js +399 -9
- package/dist/tui/InputBar.js +30 -4
- package/dist/tui/MainConversationsView.js +162 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { cp, mkdir, mkdtemp, open, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
4
6
|
import { z } from "zod";
|
|
5
7
|
import { appendJsonLine, appendText, ensureDir, pathExists, readJson, readRecentJsonLines, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
|
|
6
8
|
import { runWithLeaseFinalization } from "./lease-finalization.js";
|
|
@@ -11,7 +13,7 @@ import { loadCollaborationTimeline } from "./collaboration-timeline.js";
|
|
|
11
13
|
import { taskStateTransitionAllowed } from "./task-state-machine.js";
|
|
12
14
|
import { processIsAlive, readProcessStartToken } from "./process-identity.js";
|
|
13
15
|
import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
|
|
14
|
-
import { ChatRecordSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
16
|
+
import { ChatRecordSchema, ConversationIdSchema, MainConversationArchiveSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
15
17
|
export class InterruptedTaskRecoveryBlockedError extends Error {
|
|
16
18
|
taskId;
|
|
17
19
|
blocks;
|
|
@@ -42,6 +44,9 @@ const ACTIVE_FEATURE_STATES = new Set([
|
|
|
42
44
|
]);
|
|
43
45
|
const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
|
|
44
46
|
const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
|
|
47
|
+
const MAIN_CONVERSATION_ARCHIVE_DIRECTORY = "conversations";
|
|
48
|
+
const LEGACY_MAIN_CONVERSATION_DIRECTORY = "legacy";
|
|
49
|
+
const SAFE_MAIN_WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
45
50
|
const CompletionContractSchema = z.object({
|
|
46
51
|
version: z.literal(1),
|
|
47
52
|
final_judge_required: z.literal(true)
|
|
@@ -180,24 +185,99 @@ export class SessionManager {
|
|
|
180
185
|
}
|
|
181
186
|
return readJson(path, MainConversationStateSchema);
|
|
182
187
|
}
|
|
188
|
+
async listMainConversations(limit = 100, options = {}) {
|
|
189
|
+
const boundedLimit = Number.isFinite(limit)
|
|
190
|
+
? Math.min(500, Math.max(0, Math.trunc(limit)))
|
|
191
|
+
: 100;
|
|
192
|
+
if (boundedLimit === 0) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const [current, records, archives] = await Promise.all([
|
|
196
|
+
this.readMainConversationState(),
|
|
197
|
+
readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, 10000, { filter: (record) => !record.task_id }),
|
|
198
|
+
this.readMainConversationArchives()
|
|
199
|
+
]);
|
|
200
|
+
const currentId = current?.id ?? null;
|
|
201
|
+
const conversations = new Map();
|
|
202
|
+
const ensureConversation = (id, createdAt, lastActivityAt = createdAt) => {
|
|
203
|
+
const key = mainConversationScopeKey(id);
|
|
204
|
+
const existing = conversations.get(key);
|
|
205
|
+
if (existing) {
|
|
206
|
+
if (createdAt < existing.createdAt) {
|
|
207
|
+
existing.createdAt = createdAt;
|
|
208
|
+
}
|
|
209
|
+
if (lastActivityAt > existing.lastActivityAt) {
|
|
210
|
+
existing.lastActivityAt = lastActivityAt;
|
|
211
|
+
}
|
|
212
|
+
return existing;
|
|
213
|
+
}
|
|
214
|
+
const summary = {
|
|
215
|
+
id,
|
|
216
|
+
title: id ? "Untitled conversation" : "Legacy conversation",
|
|
217
|
+
createdAt,
|
|
218
|
+
lastActivityAt,
|
|
219
|
+
messageCount: 0,
|
|
220
|
+
userMessageCount: 0,
|
|
221
|
+
nativeSessionCount: 0,
|
|
222
|
+
current: id === currentId
|
|
223
|
+
};
|
|
224
|
+
conversations.set(key, summary);
|
|
225
|
+
return summary;
|
|
226
|
+
};
|
|
227
|
+
for (const archive of archives) {
|
|
228
|
+
const conversation = ensureConversation(archive.id, archive.created_at, archive.last_activated_at);
|
|
229
|
+
if (archive.title) {
|
|
230
|
+
conversation.title = archive.title;
|
|
231
|
+
}
|
|
232
|
+
if (archive.archived_at) {
|
|
233
|
+
conversation.archivedAt = archive.archived_at;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const record of records) {
|
|
237
|
+
const id = record.conversation_id ?? null;
|
|
238
|
+
const conversation = ensureConversation(id, record.time, record.time);
|
|
239
|
+
conversation.messageCount += 1;
|
|
240
|
+
if (record.from === "user") {
|
|
241
|
+
conversation.userMessageCount += 1;
|
|
242
|
+
if (conversation.title === "Untitled conversation" || conversation.title === "Legacy conversation") {
|
|
243
|
+
conversation.title = mainConversationTitle(record.text, id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (current) {
|
|
248
|
+
ensureConversation(current.id, current.created_at, current.created_at).current = true;
|
|
249
|
+
}
|
|
250
|
+
else if (!conversations.has(LEGACY_MAIN_CONVERSATION_DIRECTORY)) {
|
|
251
|
+
const createdAt = this.now().toISOString();
|
|
252
|
+
ensureConversation(null, createdAt, createdAt).current = true;
|
|
253
|
+
}
|
|
254
|
+
const activeNativeSessions = await this.activeMainNativeSessionIds();
|
|
255
|
+
await Promise.all([...conversations.values()].map(async (conversation) => {
|
|
256
|
+
const archived = await this.archivedMainNativeSessionIds(conversation.id);
|
|
257
|
+
if (conversation.current) {
|
|
258
|
+
for (const sessionId of activeNativeSessions) {
|
|
259
|
+
archived.add(sessionId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
conversation.nativeSessionCount = archived.size;
|
|
263
|
+
}));
|
|
264
|
+
return [...conversations.values()]
|
|
265
|
+
.filter((conversation) => options.includeArchived || !conversation.archivedAt || conversation.current)
|
|
266
|
+
.sort((left, right) => (Number(right.current) - Number(left.current)
|
|
267
|
+
|| Number(Boolean(left.archivedAt)) - Number(Boolean(right.archivedAt))
|
|
268
|
+
|| right.lastActivityAt.localeCompare(left.lastActivityAt)
|
|
269
|
+
|| mainConversationScopeKey(right.id).localeCompare(mainConversationScopeKey(left.id))))
|
|
270
|
+
.slice(0, boundedLimit);
|
|
271
|
+
}
|
|
183
272
|
async startNewMainConversation() {
|
|
184
273
|
const main = this.taskFromId("main");
|
|
185
274
|
await ensureDir(main.dir);
|
|
186
275
|
const lease = await this.claimTaskRunLease(main.dir);
|
|
187
276
|
return runWithLeaseFinalization("Main conversation reset", lease, async () => {
|
|
188
277
|
const previous = await this.readMainConversationState();
|
|
189
|
-
const entries = await readdir(main.dir, { withFileTypes: true });
|
|
190
|
-
for (const entry of entries) {
|
|
191
|
-
if (!entry.isDirectory()) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
const worker = { dir: join(main.dir, entry.name) };
|
|
195
|
-
const nativeSession = await this.readNativeSession(worker);
|
|
196
|
-
if (nativeSession?.scope === "main") {
|
|
197
|
-
await this.retireNativeSession(worker, "new Main conversation");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
278
|
const createdAt = this.now();
|
|
279
|
+
const currentSummary = (await this.listMainConversations(500)).find((conversation) => conversation.current);
|
|
280
|
+
await this.archiveAndRetireMainConversation(main, previous?.id ?? null, currentSummary?.createdAt ?? previous?.created_at ?? createdAt.toISOString(), "new Main conversation", Boolean(previous) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
|
|
201
281
|
const baseId = `conversation-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
|
|
202
282
|
const state = MainConversationStateSchema.parse({
|
|
203
283
|
version: 1,
|
|
@@ -206,10 +286,198 @@ export class SessionManager {
|
|
|
206
286
|
...(previous ? { previous_id: previous.id } : {})
|
|
207
287
|
});
|
|
208
288
|
await writeJson(join(main.dir, "conversation.json"), state);
|
|
289
|
+
await this.writeMainConversationArchive({
|
|
290
|
+
version: 1,
|
|
291
|
+
id: state.id,
|
|
292
|
+
created_at: state.created_at,
|
|
293
|
+
last_activated_at: state.created_at
|
|
294
|
+
});
|
|
209
295
|
await this.appendEvent(main, "main.conversation_started", `Started ${state.id}`);
|
|
210
296
|
return state;
|
|
211
297
|
});
|
|
212
298
|
}
|
|
299
|
+
async activateMainConversation(conversationId) {
|
|
300
|
+
const id = conversationId === null ? null : ConversationIdSchema.parse(conversationId);
|
|
301
|
+
const main = this.taskFromId("main");
|
|
302
|
+
await ensureDir(main.dir);
|
|
303
|
+
const lease = await this.claimTaskRunLease(main.dir);
|
|
304
|
+
return runWithLeaseFinalization("Main conversation restore", lease, async () => {
|
|
305
|
+
const conversations = await this.listMainConversations(500, { includeArchived: true });
|
|
306
|
+
const target = conversations.find((conversation) => conversation.id === id);
|
|
307
|
+
if (!target) {
|
|
308
|
+
throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
|
|
309
|
+
}
|
|
310
|
+
if (target.archivedAt) {
|
|
311
|
+
throw new Error(`Cannot restore archived Main conversation ${id ?? "legacy"}. Unarchive it first.`);
|
|
312
|
+
}
|
|
313
|
+
const current = await this.readMainConversationState();
|
|
314
|
+
const currentId = current?.id ?? null;
|
|
315
|
+
if (currentId === id) {
|
|
316
|
+
return { conversation: target, restoredNativeSessions: 0, changed: false };
|
|
317
|
+
}
|
|
318
|
+
const currentSummary = conversations.find((conversation) => conversation.current);
|
|
319
|
+
const activatedAt = this.now().toISOString();
|
|
320
|
+
await this.archiveAndRetireMainConversation(main, currentId, currentSummary?.createdAt ?? current?.created_at ?? activatedAt, "switch Main conversation", Boolean(current) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
|
|
321
|
+
if (id) {
|
|
322
|
+
await writeJson(join(main.dir, "conversation.json"), MainConversationStateSchema.parse({
|
|
323
|
+
version: 1,
|
|
324
|
+
id,
|
|
325
|
+
created_at: target.createdAt,
|
|
326
|
+
...(current?.id ? { previous_id: current.id } : {})
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
await removeIfExists(join(main.dir, "conversation.json"));
|
|
331
|
+
}
|
|
332
|
+
await this.writeMainConversationArchive({
|
|
333
|
+
version: 1,
|
|
334
|
+
id,
|
|
335
|
+
created_at: target.createdAt,
|
|
336
|
+
last_activated_at: activatedAt
|
|
337
|
+
});
|
|
338
|
+
const restoredNativeSessions = await this.restoreMainConversationNativeSessions(main, id);
|
|
339
|
+
await this.appendEvent(main, "main.conversation_restored", `Restored ${id ?? "legacy conversation"}; ${restoredNativeSessions} native session(s)`);
|
|
340
|
+
const restored = (await this.listMainConversations(500)).find((conversation) => conversation.id === id);
|
|
341
|
+
if (!restored) {
|
|
342
|
+
throw new Error(`Restored Main conversation disappeared from its catalog: ${id ?? "legacy"}`);
|
|
343
|
+
}
|
|
344
|
+
return { conversation: restored, restoredNativeSessions, changed: true };
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
async renameMainConversation(conversationId, title) {
|
|
348
|
+
const normalizedTitle = normalizeMainConversationTitle(title);
|
|
349
|
+
return this.withMainConversationManagementLease(conversationId, "rename", async (main, conversation) => {
|
|
350
|
+
const metadata = await this.mainConversationArchiveMetadata(conversation);
|
|
351
|
+
await this.writeMainConversationArchive({ ...metadata, title: normalizedTitle });
|
|
352
|
+
await this.appendEvent(main, "main.conversation_renamed", `Renamed ${conversation.id ?? "legacy conversation"} to ${normalizedTitle}`);
|
|
353
|
+
return this.requireMainConversationSummary(conversation.id);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
async setMainConversationArchived(conversationId, archived) {
|
|
357
|
+
return this.withMainConversationManagementLease(conversationId, archived ? "archive" : "unarchive", async (main, conversation) => {
|
|
358
|
+
if (archived && conversation.current) {
|
|
359
|
+
throw new Error("Cannot archive the current Main conversation. Restore another conversation first.");
|
|
360
|
+
}
|
|
361
|
+
if (Boolean(conversation.archivedAt) === archived) {
|
|
362
|
+
return conversation;
|
|
363
|
+
}
|
|
364
|
+
const metadata = await this.mainConversationArchiveMetadata(conversation);
|
|
365
|
+
const next = archived
|
|
366
|
+
? MainConversationArchiveSchema.parse({
|
|
367
|
+
...metadata,
|
|
368
|
+
archived_at: this.now().toISOString()
|
|
369
|
+
})
|
|
370
|
+
: withoutMainConversationArchiveTime(metadata);
|
|
371
|
+
await writeJson(join(this.mainConversationArchiveDir(conversation.id), "meta.json"), next);
|
|
372
|
+
await this.appendEvent(main, archived ? "main.conversation_archived" : "main.conversation_unarchived", archived
|
|
373
|
+
? `Archived ${conversation.id ?? "legacy conversation"}`
|
|
374
|
+
: `Unarchived ${conversation.id ?? "legacy conversation"}`);
|
|
375
|
+
return this.requireMainConversationSummary(conversation.id);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async deleteMainConversation(conversationId) {
|
|
379
|
+
await this.withMainConversationManagementLease(conversationId, "delete", async (main, conversation) => {
|
|
380
|
+
if (conversation.current) {
|
|
381
|
+
throw new Error("Cannot delete the current Main conversation. Restore another conversation first.");
|
|
382
|
+
}
|
|
383
|
+
const staging = await mkdtemp(join(main.dir, ".conversation-delete-"));
|
|
384
|
+
const chatPath = join(main.dir, "chat.jsonl");
|
|
385
|
+
const replacementPath = join(staging, "chat.next.jsonl");
|
|
386
|
+
const originalPath = join(staging, "chat.original.jsonl");
|
|
387
|
+
const archivePath = this.mainConversationArchiveDir(conversation.id);
|
|
388
|
+
const stagedArchivePath = join(staging, "archive");
|
|
389
|
+
let archiveMoved = false;
|
|
390
|
+
let originalMoved = false;
|
|
391
|
+
let replacementInstalled = false;
|
|
392
|
+
try {
|
|
393
|
+
await writeConversationChatFile(chatPath, replacementPath, conversation.id, "exclude");
|
|
394
|
+
if (await pathExists(archivePath)) {
|
|
395
|
+
await rename(archivePath, stagedArchivePath);
|
|
396
|
+
archiveMoved = true;
|
|
397
|
+
}
|
|
398
|
+
if (await pathExists(chatPath)) {
|
|
399
|
+
await rename(chatPath, originalPath);
|
|
400
|
+
originalMoved = true;
|
|
401
|
+
}
|
|
402
|
+
await rename(replacementPath, chatPath);
|
|
403
|
+
replacementInstalled = true;
|
|
404
|
+
await this.appendEvent(main, "main.conversation_deleted", `Deleted ${conversation.id ?? "legacy conversation"}`);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
const rollbackErrors = [];
|
|
408
|
+
if (replacementInstalled) {
|
|
409
|
+
try {
|
|
410
|
+
await removeIfExists(chatPath);
|
|
411
|
+
}
|
|
412
|
+
catch (rollbackError) {
|
|
413
|
+
rollbackErrors.push(rollbackError);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (originalMoved) {
|
|
417
|
+
try {
|
|
418
|
+
await rename(originalPath, chatPath);
|
|
419
|
+
}
|
|
420
|
+
catch (rollbackError) {
|
|
421
|
+
rollbackErrors.push(rollbackError);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (archiveMoved) {
|
|
425
|
+
try {
|
|
426
|
+
await rename(stagedArchivePath, archivePath);
|
|
427
|
+
}
|
|
428
|
+
catch (rollbackError) {
|
|
429
|
+
rollbackErrors.push(rollbackError);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await rm(staging, { force: true, recursive: true }).catch(() => undefined);
|
|
433
|
+
if (rollbackErrors.length > 0) {
|
|
434
|
+
throw new Error(`Main conversation deletion failed and rollback also failed: ${errorMessage(error)}`, { cause: new AggregateError([error, ...rollbackErrors]) });
|
|
435
|
+
}
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
await rm(staging, { force: true, recursive: true }).catch(() => undefined);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async exportMainConversation(conversationId) {
|
|
442
|
+
return this.withMainConversationManagementLease(conversationId, "export", async (main, conversation) => {
|
|
443
|
+
const createdAt = this.now().toISOString();
|
|
444
|
+
const exportsRoot = join(this.projectRoot, this.dataDir, "exports");
|
|
445
|
+
await ensureDir(exportsRoot);
|
|
446
|
+
const scope = conversation.id ?? "main-legacy";
|
|
447
|
+
const staging = await mkdtemp(join(exportsRoot, `.${scope}-`));
|
|
448
|
+
const suffix = basename(staging).slice(-6);
|
|
449
|
+
const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
|
|
450
|
+
const destination = join(exportsRoot, `${scope}-${stamp}-${suffix}`);
|
|
451
|
+
try {
|
|
452
|
+
const messageCount = await writeConversationChatFile(join(main.dir, "chat.jsonl"), join(staging, "chat.jsonl"), conversation.id, "include");
|
|
453
|
+
const nativeSessions = await this.mainConversationNativeSessions(conversation.id, conversation.current);
|
|
454
|
+
for (const session of nativeSessions) {
|
|
455
|
+
await writeJson(join(staging, "native-sessions", `${session.worker_id}.json`), session);
|
|
456
|
+
}
|
|
457
|
+
await writeJson(join(staging, "manifest.json"), {
|
|
458
|
+
format: "parallel-codex-main-conversation-export-v1",
|
|
459
|
+
exported_at: createdAt,
|
|
460
|
+
source_workspace: this.projectRoot,
|
|
461
|
+
conversation: {
|
|
462
|
+
id: conversation.id,
|
|
463
|
+
title: conversation.title,
|
|
464
|
+
created_at: conversation.createdAt,
|
|
465
|
+
last_activity_at: conversation.lastActivityAt,
|
|
466
|
+
...(conversation.archivedAt ? { archived_at: conversation.archivedAt } : {})
|
|
467
|
+
},
|
|
468
|
+
message_count: messageCount,
|
|
469
|
+
native_session_count: nativeSessions.length
|
|
470
|
+
});
|
|
471
|
+
await this.appendEvent(main, "main.conversation_exported", `Exported ${conversation.id ?? "legacy conversation"}`);
|
|
472
|
+
await rename(staging, destination);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
await rm(staging, { force: true, recursive: true });
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
return { conversationId: conversation.id, path: destination, createdAt };
|
|
479
|
+
});
|
|
480
|
+
}
|
|
213
481
|
async reconcileInterruptedMainSession() {
|
|
214
482
|
const main = this.taskFromId("main");
|
|
215
483
|
if (!(await pathExists(main.dir))) {
|
|
@@ -803,6 +1071,179 @@ export class SessionManager {
|
|
|
803
1071
|
await this.clearWorkerStatusNativeSession(worker);
|
|
804
1072
|
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
|
|
805
1073
|
}
|
|
1074
|
+
async mainConversationArchiveMetadata(conversation) {
|
|
1075
|
+
const existing = await readMainConversationArchiveIfValid(join(this.mainConversationArchiveDir(conversation.id), "meta.json"));
|
|
1076
|
+
return MainConversationArchiveSchema.parse({
|
|
1077
|
+
...existing,
|
|
1078
|
+
version: 1,
|
|
1079
|
+
id: conversation.id,
|
|
1080
|
+
created_at: existing?.created_at ?? conversation.createdAt,
|
|
1081
|
+
last_activated_at: existing?.last_activated_at ?? conversation.lastActivityAt
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
async requireMainConversationSummary(conversationId) {
|
|
1085
|
+
const conversation = (await this.listMainConversations(500, { includeArchived: true }))
|
|
1086
|
+
.find((entry) => entry.id === conversationId);
|
|
1087
|
+
if (!conversation) {
|
|
1088
|
+
throw new Error(`Main conversation disappeared from its catalog: ${conversationId ?? "legacy"}`);
|
|
1089
|
+
}
|
|
1090
|
+
return conversation;
|
|
1091
|
+
}
|
|
1092
|
+
mainConversationArchiveDir(conversationId) {
|
|
1093
|
+
return join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY, mainConversationScopeKey(conversationId));
|
|
1094
|
+
}
|
|
1095
|
+
async readMainConversationArchives() {
|
|
1096
|
+
const root = join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY);
|
|
1097
|
+
if (!(await pathExists(root))) {
|
|
1098
|
+
return [];
|
|
1099
|
+
}
|
|
1100
|
+
const archives = [];
|
|
1101
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
1102
|
+
if (!entry.isDirectory()) {
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const metadata = await readMainConversationArchiveIfValid(join(root, entry.name, "meta.json"));
|
|
1106
|
+
if (metadata && mainConversationScopeKey(metadata.id) === entry.name) {
|
|
1107
|
+
archives.push(metadata);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return archives;
|
|
1111
|
+
}
|
|
1112
|
+
async writeMainConversationArchive(metadata) {
|
|
1113
|
+
const parsed = MainConversationArchiveSchema.parse(metadata);
|
|
1114
|
+
const path = join(this.mainConversationArchiveDir(parsed.id), "meta.json");
|
|
1115
|
+
const existing = await readMainConversationArchiveIfValid(path);
|
|
1116
|
+
await writeJson(path, MainConversationArchiveSchema.parse({
|
|
1117
|
+
...existing,
|
|
1118
|
+
...parsed,
|
|
1119
|
+
created_at: existing && existing.created_at < parsed.created_at
|
|
1120
|
+
? existing.created_at
|
|
1121
|
+
: parsed.created_at
|
|
1122
|
+
}));
|
|
1123
|
+
}
|
|
1124
|
+
async archiveAndRetireMainConversation(main, conversationId, createdAt, reason, persistEmpty = true) {
|
|
1125
|
+
const activatedAt = this.now().toISOString();
|
|
1126
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
1127
|
+
const entries = await readdir(main.dir, { withFileTypes: true });
|
|
1128
|
+
let archivedNativeSession = false;
|
|
1129
|
+
for (const entry of entries) {
|
|
1130
|
+
if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const worker = { dir: join(main.dir, entry.name) };
|
|
1134
|
+
const nativeSession = await this.readNativeSession(worker);
|
|
1135
|
+
if (nativeSession?.scope !== "main"
|
|
1136
|
+
|| nativeSession.role !== "main"
|
|
1137
|
+
|| !safeMainWorkerId(nativeSession.worker_id)) {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
await writeJson(join(archiveDir, `${nativeSession.worker_id}.json`), nativeSession);
|
|
1141
|
+
await this.retireNativeSession(worker, reason);
|
|
1142
|
+
archivedNativeSession = true;
|
|
1143
|
+
}
|
|
1144
|
+
if (persistEmpty || archivedNativeSession) {
|
|
1145
|
+
await this.writeMainConversationArchive({
|
|
1146
|
+
version: 1,
|
|
1147
|
+
id: conversationId,
|
|
1148
|
+
created_at: createdAt,
|
|
1149
|
+
last_activated_at: activatedAt
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async restoreMainConversationNativeSessions(main, conversationId) {
|
|
1154
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
1155
|
+
if (!(await pathExists(archiveDir))) {
|
|
1156
|
+
return 0;
|
|
1157
|
+
}
|
|
1158
|
+
let restored = 0;
|
|
1159
|
+
for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
|
|
1160
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
|
|
1164
|
+
if (!record
|
|
1165
|
+
|| record.scope !== "main"
|
|
1166
|
+
|| record.role !== "main"
|
|
1167
|
+
|| !safeMainWorkerId(record.worker_id)
|
|
1168
|
+
|| entry.name !== `${record.worker_id}.json`) {
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
const worker = { dir: join(main.dir, record.worker_id) };
|
|
1172
|
+
await ensureDir(worker.dir);
|
|
1173
|
+
await writeJson(join(worker.dir, "native-session.json"), record);
|
|
1174
|
+
await removeIfExists(join(worker.dir, "native-session.retired.json"));
|
|
1175
|
+
await this.syncNativeSessionProjection(worker, record);
|
|
1176
|
+
restored += 1;
|
|
1177
|
+
}
|
|
1178
|
+
return restored;
|
|
1179
|
+
}
|
|
1180
|
+
async archivedMainNativeSessionIds(conversationId) {
|
|
1181
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
1182
|
+
const ids = new Set();
|
|
1183
|
+
if (!(await pathExists(archiveDir))) {
|
|
1184
|
+
return ids;
|
|
1185
|
+
}
|
|
1186
|
+
for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
|
|
1187
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
|
|
1191
|
+
if (record?.scope === "main" && record.role === "main") {
|
|
1192
|
+
ids.add(`${record.engine}\u0000${record.session_id}`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return ids;
|
|
1196
|
+
}
|
|
1197
|
+
async activeMainNativeSessionIds() {
|
|
1198
|
+
const ids = new Set();
|
|
1199
|
+
const mainDir = this.mainSessionDir();
|
|
1200
|
+
if (!(await pathExists(mainDir))) {
|
|
1201
|
+
return ids;
|
|
1202
|
+
}
|
|
1203
|
+
for (const entry of await readdir(mainDir, { withFileTypes: true })) {
|
|
1204
|
+
if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
const record = await this.readNativeSession({ dir: join(mainDir, entry.name) });
|
|
1208
|
+
if (record?.scope === "main" && record.role === "main") {
|
|
1209
|
+
ids.add(`${record.engine}\u0000${record.session_id}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return ids;
|
|
1213
|
+
}
|
|
1214
|
+
async mainConversationNativeSessions(conversationId, includeActive) {
|
|
1215
|
+
const sessions = new Map();
|
|
1216
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
1217
|
+
if (await pathExists(archiveDir)) {
|
|
1218
|
+
for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
|
|
1219
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
|
|
1223
|
+
if (record?.scope === "main"
|
|
1224
|
+
&& record.role === "main"
|
|
1225
|
+
&& safeMainWorkerId(record.worker_id)
|
|
1226
|
+
&& entry.name === `${record.worker_id}.json`) {
|
|
1227
|
+
sessions.set(record.worker_id, record);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (includeActive) {
|
|
1232
|
+
const mainDir = this.mainSessionDir();
|
|
1233
|
+
for (const entry of await readdir(mainDir, { withFileTypes: true })) {
|
|
1234
|
+
if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const record = await this.readNativeSession({ dir: join(mainDir, entry.name) });
|
|
1238
|
+
if (record?.scope === "main"
|
|
1239
|
+
&& record.role === "main"
|
|
1240
|
+
&& safeMainWorkerId(record.worker_id)) {
|
|
1241
|
+
sessions.set(record.worker_id, record);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return [...sessions.values()].sort((left, right) => left.worker_id.localeCompare(right.worker_id));
|
|
1246
|
+
}
|
|
806
1247
|
async appendEvent(task, type, message) {
|
|
807
1248
|
const event = {
|
|
808
1249
|
time: this.now().toISOString(),
|
|
@@ -827,6 +1268,20 @@ export class SessionManager {
|
|
|
827
1268
|
return run(task, meta);
|
|
828
1269
|
});
|
|
829
1270
|
}
|
|
1271
|
+
async withMainConversationManagementLease(conversationId, operation, run) {
|
|
1272
|
+
const id = conversationId === null ? null : ConversationIdSchema.parse(conversationId);
|
|
1273
|
+
const main = this.taskFromId("main");
|
|
1274
|
+
await ensureDir(main.dir);
|
|
1275
|
+
const lease = await this.claimTaskRunLease(main.dir);
|
|
1276
|
+
return runWithLeaseFinalization(`Main conversation ${operation}`, lease, async () => {
|
|
1277
|
+
const conversation = (await this.listMainConversations(500, { includeArchived: true }))
|
|
1278
|
+
.find((entry) => entry.id === id);
|
|
1279
|
+
if (!conversation) {
|
|
1280
|
+
throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
|
|
1281
|
+
}
|
|
1282
|
+
return run(main, conversation);
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
830
1285
|
assertTerminalTask(meta, operation) {
|
|
831
1286
|
if (!TERMINAL_TASK_STATES.has(meta.status)) {
|
|
832
1287
|
throw new Error(`Cannot ${operation} task ${meta.id} while it is ${meta.status}.`);
|
|
@@ -1375,6 +1830,96 @@ function nextConversationId(baseId, previousId) {
|
|
|
1375
1830
|
? `${baseId}-${suffix + 1}`
|
|
1376
1831
|
: `${baseId}-2`;
|
|
1377
1832
|
}
|
|
1833
|
+
function mainConversationScopeKey(conversationId) {
|
|
1834
|
+
return conversationId ?? LEGACY_MAIN_CONVERSATION_DIRECTORY;
|
|
1835
|
+
}
|
|
1836
|
+
function withoutMainConversationArchiveTime(metadata) {
|
|
1837
|
+
const { archived_at: _archivedAt, ...active } = metadata;
|
|
1838
|
+
return MainConversationArchiveSchema.parse(active);
|
|
1839
|
+
}
|
|
1840
|
+
function mainConversationTitle(text, conversationId) {
|
|
1841
|
+
const sanitized = sanitizePersistedMainMessage("user", text)
|
|
1842
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
1843
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
1844
|
+
.replace(/\s+/g, " ")
|
|
1845
|
+
.trim();
|
|
1846
|
+
if (!sanitized) {
|
|
1847
|
+
return conversationId ? "Untitled conversation" : "Legacy conversation";
|
|
1848
|
+
}
|
|
1849
|
+
const characters = Array.from(sanitized);
|
|
1850
|
+
return characters.length > 80
|
|
1851
|
+
? `${characters.slice(0, 77).join("")}...`
|
|
1852
|
+
: sanitized;
|
|
1853
|
+
}
|
|
1854
|
+
function normalizeMainConversationTitle(title) {
|
|
1855
|
+
const normalized = title
|
|
1856
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
1857
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
1858
|
+
.replace(/\s+/g, " ")
|
|
1859
|
+
.trim();
|
|
1860
|
+
if (!normalized) {
|
|
1861
|
+
throw new Error("Main conversation title cannot be empty.");
|
|
1862
|
+
}
|
|
1863
|
+
if (Array.from(normalized).length > 160) {
|
|
1864
|
+
throw new Error("Main conversation title cannot exceed 160 characters.");
|
|
1865
|
+
}
|
|
1866
|
+
return normalized;
|
|
1867
|
+
}
|
|
1868
|
+
function safeMainWorkerId(workerId) {
|
|
1869
|
+
return SAFE_MAIN_WORKER_ID.test(workerId) && workerId !== "." && workerId !== "..";
|
|
1870
|
+
}
|
|
1871
|
+
async function writeConversationChatFile(sourcePath, destinationPath, conversationId, mode) {
|
|
1872
|
+
await ensureDir(dirname(destinationPath));
|
|
1873
|
+
const output = await open(destinationPath, "wx");
|
|
1874
|
+
let matched = 0;
|
|
1875
|
+
let buffer = "";
|
|
1876
|
+
try {
|
|
1877
|
+
if (!(await pathExists(sourcePath))) {
|
|
1878
|
+
await output.close();
|
|
1879
|
+
return 0;
|
|
1880
|
+
}
|
|
1881
|
+
const input = createReadStream(sourcePath, { encoding: "utf8" });
|
|
1882
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
1883
|
+
for await (const line of lines) {
|
|
1884
|
+
const belongs = chatLineBelongsToMainConversation(line, conversationId);
|
|
1885
|
+
if (belongs) {
|
|
1886
|
+
matched += 1;
|
|
1887
|
+
}
|
|
1888
|
+
if ((mode === "include") !== belongs) {
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
buffer += `${line}\n`;
|
|
1892
|
+
if (Buffer.byteLength(buffer, "utf8") >= 64 * 1024) {
|
|
1893
|
+
await output.write(buffer);
|
|
1894
|
+
buffer = "";
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (buffer) {
|
|
1898
|
+
await output.write(buffer);
|
|
1899
|
+
}
|
|
1900
|
+
await output.close();
|
|
1901
|
+
return matched;
|
|
1902
|
+
}
|
|
1903
|
+
catch (error) {
|
|
1904
|
+
await output.close().catch(() => undefined);
|
|
1905
|
+
await removeIfExists(destinationPath).catch(() => undefined);
|
|
1906
|
+
throw error;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
function chatLineBelongsToMainConversation(line, conversationId) {
|
|
1910
|
+
try {
|
|
1911
|
+
const parsed = ChatRecordSchema.safeParse(JSON.parse(line));
|
|
1912
|
+
if (!parsed.success || parsed.data.task_id) {
|
|
1913
|
+
return false;
|
|
1914
|
+
}
|
|
1915
|
+
return conversationId
|
|
1916
|
+
? parsed.data.conversation_id === conversationId
|
|
1917
|
+
: !parsed.data.conversation_id;
|
|
1918
|
+
}
|
|
1919
|
+
catch {
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1378
1923
|
function normalizeTaskTitle(title) {
|
|
1379
1924
|
const normalized = title
|
|
1380
1925
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
@@ -1486,6 +2031,17 @@ async function readRetiredNativeSessionIfValid(retiredSessionPath) {
|
|
|
1486
2031
|
return null;
|
|
1487
2032
|
}
|
|
1488
2033
|
}
|
|
2034
|
+
async function readMainConversationArchiveIfValid(path) {
|
|
2035
|
+
if (!(await pathExists(path))) {
|
|
2036
|
+
return null;
|
|
2037
|
+
}
|
|
2038
|
+
try {
|
|
2039
|
+
return await readJson(path, MainConversationArchiveSchema);
|
|
2040
|
+
}
|
|
2041
|
+
catch {
|
|
2042
|
+
return null;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
1489
2045
|
async function readWorkerStatusIfValid(statusPath) {
|
|
1490
2046
|
if (!(await pathExists(statusPath))) {
|
|
1491
2047
|
return null;
|
package/dist/domain/schemas.js
CHANGED
|
@@ -205,6 +205,14 @@ export const MainConversationStateSchema = z.object({
|
|
|
205
205
|
created_at: z.string().datetime(),
|
|
206
206
|
previous_id: ConversationIdSchema.optional()
|
|
207
207
|
});
|
|
208
|
+
export const MainConversationArchiveSchema = z.object({
|
|
209
|
+
version: z.literal(1),
|
|
210
|
+
id: ConversationIdSchema.nullable(),
|
|
211
|
+
created_at: z.string().datetime(),
|
|
212
|
+
last_activated_at: z.string().datetime(),
|
|
213
|
+
title: z.string().min(1).max(160).optional(),
|
|
214
|
+
archived_at: z.string().datetime().optional()
|
|
215
|
+
});
|
|
208
216
|
export const ChatRecordSchema = z.object({
|
|
209
217
|
time: z.string().datetime(),
|
|
210
218
|
from: z.enum(["user", "system"]),
|