@awak-app/simy-cli 0.5.34 → 0.5.36

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.
@@ -0,0 +1,940 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, mkdir, open, opendir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, relative, resolve, sep } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+
8
+ import { webApiErrorMessage, webApiHeaders, webApiUrl } from "./web-api.js";
9
+
10
+ const MAX_THREADS = 10;
11
+ const MAX_MESSAGES = 24;
12
+ const MAX_MESSAGE_CHARS = 1_500;
13
+ const MAX_CANDIDATE_FILES = 200;
14
+ export const MAX_GATEWAY_BODY_BYTES = 512 * 1024;
15
+ const DEFAULT_LOOKBACK_DAYS = 90;
16
+ const POLL_INTERVAL_MS = 15 * 60 * 1_000;
17
+ const STATE_SCHEMA_VERSION = "work-pattern-history-sync.v6";
18
+ export const HISTORY_EXTRACTION_POLICY = "user_conversation.v2";
19
+ const PROVIDER_CODEX = "codex";
20
+ const PROVIDER_CLAUDE = "claude";
21
+
22
+ export function createCodexWorkPatternSync(options = {}) {
23
+ let inFlight = null;
24
+ let abortController = null;
25
+ let closed = false;
26
+ let lastPollAt = 0;
27
+ return {
28
+ async poll(context) {
29
+ if (closed) return { status: "closed", uploaded: 0 };
30
+ const now = Number(options.now?.() ?? Date.now());
31
+ if (inFlight || now - lastPollAt < (options.pollIntervalMs ?? POLL_INTERVAL_MS)) {
32
+ return inFlight;
33
+ }
34
+ lastPollAt = now;
35
+ const currentController = new AbortController();
36
+ abortController = currentController;
37
+ inFlight = syncCodexWorkPatterns({
38
+ ...options,
39
+ ...context,
40
+ now,
41
+ signal: currentController.signal,
42
+ }).finally(() => {
43
+ if (abortController === currentController) abortController = null;
44
+ inFlight = null;
45
+ });
46
+ return inFlight;
47
+ },
48
+ async close() {
49
+ closed = true;
50
+ abortController?.abort();
51
+ await inFlight?.catch(() => {});
52
+ },
53
+ };
54
+ }
55
+
56
+ // History roots follow the providers' own configuration variables so a test
57
+ // can point both at synthetic temp directories without touching real history.
58
+ export function workPatternHistoryHomes({ codexHome, claudeHome, env = process.env } = {}) {
59
+ return {
60
+ codexHome: resolve(codexHome || env.CODEX_HOME?.trim() || join(homedir(), ".codex")),
61
+ claudeHome: resolve(claudeHome || env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude")),
62
+ };
63
+ }
64
+
65
+ export async function syncCodexWorkPatterns({
66
+ apiOrigin,
67
+ apiBaseUrl,
68
+ token,
69
+ deviceId,
70
+ accountId,
71
+ sessionRoot,
72
+ codexHome = workPatternHistoryHomes().codexHome,
73
+ claudeHome = workPatternHistoryHomes().claudeHome,
74
+ now = Date.now(),
75
+ fetchImpl = fetch,
76
+ onDirectoryVisited,
77
+ onBeforeExtract,
78
+ signal,
79
+ }) {
80
+ if (!token || !deviceId || !sessionRoot) return { status: "not_connected", uploaded: 0 };
81
+ signal?.throwIfAborted();
82
+ // SIMY Web runs with trailingSlash=true; use the canonical slash form so the
83
+ // POST body never depends on a 308 redirect being replayed.
84
+ const endpoint = webApiUrl("work-pattern-observations/", {
85
+ webOrigin: apiOrigin,
86
+ apiBaseUrl,
87
+ });
88
+ const statusResponse = await fetchImpl(endpoint, {
89
+ method: "GET",
90
+ headers: webApiHeaders({ token }),
91
+ signal: signalWithTimeout(signal, 5_000),
92
+ });
93
+ const statusPayload = await statusResponse.json().catch(() => null);
94
+ if (!statusResponse.ok) {
95
+ throw new Error(webApiErrorMessage(statusPayload, statusResponse.status));
96
+ }
97
+ if (
98
+ !statusPayload ||
99
+ typeof statusPayload !== "object" ||
100
+ Array.isArray(statusPayload) ||
101
+ (Object.hasOwn(statusPayload, "enabled") &&
102
+ typeof statusPayload.enabled !== "boolean")
103
+ ) {
104
+ throw new Error("Work Pattern sync status response is invalid");
105
+ }
106
+ if (statusPayload?.enabled === false) return { status: "disabled", uploaded: 0 };
107
+
108
+ const lookbackDays = boundedInteger(statusPayload.lookback_days, 1, 365, DEFAULT_LOOKBACK_DAYS);
109
+ const statePath = workPatternSyncStatePath({
110
+ sessionRoot,
111
+ apiOrigin,
112
+ accountId,
113
+ deviceId,
114
+ });
115
+ const persistedState = await readSyncState(statePath);
116
+ const previousState = statusPayload.reset_cursor === true ? {} : persistedState;
117
+ const state = previousState.lookback_days === lookbackDays ? previousState : {};
118
+ const lookbackCutoffMs = now - lookbackDays * 24 * 60 * 60 * 1_000;
119
+ const providerSpecs = [
120
+ {
121
+ provider: PROVIDER_CODEX,
122
+ roots: [resolve(codexHome, "sessions"), resolve(codexHome, "archived_sessions")],
123
+ extract: extractCodexThreadChunks,
124
+ includeRelativePath: () => true,
125
+ },
126
+ {
127
+ provider: PROVIDER_CLAUDE,
128
+ roots: [resolve(claudeHome, "projects")],
129
+ extract: extractClaudeThreadChunks,
130
+ // Claude stores delegated agent transcripts below `subagents/`. They are
131
+ // machine-to-machine work, not a user's conversation, so exclude them.
132
+ includeRelativePath: (filePath) => !filePath.split(sep).includes("subagents"),
133
+ },
134
+ ];
135
+ const inventories = new Map();
136
+ let incompleteReplayRoot = false;
137
+ let hasCompleteRoot = false;
138
+ const files = (await Promise.all(providerSpecs.map(async (spec) => {
139
+ const providerState = providerCursor(state, spec.provider);
140
+ // Do not use the history window as a pre-read mtime filter: restored or
141
+ // migrated files can have mtimes that disagree with their message dates.
142
+ // The extracted record timestamp is the authority for lookback filtering.
143
+ const scans = await Promise.all(spec.roots.map((root) => recentSessionFiles(root, {
144
+ provider: spec.provider,
145
+ processedFiles: providerState.processed_files,
146
+ includeRelativePath: spec.includeRelativePath,
147
+ onDirectoryVisited,
148
+ })));
149
+ const priorFiles = Object.values(providerCursor(persistedState, spec.provider).processed_files);
150
+ scans.forEach((scan, index) => {
151
+ const rootKey = basename(spec.roots[index]);
152
+ if (!scan.complete && priorFiles.some((file) => !file.root_key || file.root_key === rootKey)) {
153
+ incompleteReplayRoot = true;
154
+ }
155
+ });
156
+ if (scans.some((scan) => scan.complete)) hasCompleteRoot = true;
157
+ if (scans.every((scan) => scan.complete)) {
158
+ inventories.set(spec.provider, new Set(scans.flatMap((scan) => [...scan.fileHashes])));
159
+ }
160
+ return scans.flatMap((scan) => scan.files);
161
+ }))).flat();
162
+ const selectedFiles = selectSessionFiles(files);
163
+ const threads = [];
164
+ const acknowledgedRevisions = new Set(providerSpecs.flatMap((spec) =>
165
+ Object.values(providerCursor(state, spec.provider).processed_files)
166
+ .filter((file) => file.lineage_ref && file.revision_ref)
167
+ .map((file) => `${file.lineage_ref}\0${file.revision_ref}`)));
168
+ const successfullyReadFiles = [];
169
+ const unreadableFiles = [];
170
+ for (const file of selectedFiles) {
171
+ const spec = providerSpecs.find((item) => item.provider === file.provider);
172
+ await onBeforeExtract?.(file);
173
+ const extracted = await spec.extract(file.path, {
174
+ deviceId,
175
+ fallbackObservedAt: new Date(file.mtimeMs).toISOString(),
176
+ lookbackCutoffMs,
177
+ });
178
+ if (extracted.readOk === false) {
179
+ unreadableFiles.push(file);
180
+ continue;
181
+ }
182
+ const withinLookback = extracted.length > 0
183
+ && Date.parse(extracted[0].observed_at) >= lookbackCutoffMs;
184
+ const revisionKey = withinLookback
185
+ ? `${extracted[0].lineage_ref}\0${extracted[0].revision_ref}`
186
+ : null;
187
+ successfullyReadFiles.push({
188
+ ...file,
189
+ lineage_ref: withinLookback ? extracted[0].lineage_ref : undefined,
190
+ revision_ref: withinLookback ? extracted[0].revision_ref : undefined,
191
+ });
192
+ // Archiving moves a transcript; it does not create another conversation.
193
+ // Reuse only a complete revision acknowledged in this account/device scope.
194
+ if (revisionKey && acknowledgedRevisions.has(revisionKey)) continue;
195
+ if (revisionKey) acknowledgedRevisions.add(revisionKey);
196
+ for (const chunk of extracted) {
197
+ if (
198
+ chunk.messages.length > 0
199
+ && Date.parse(chunk.observed_at) >= lookbackCutoffMs
200
+ ) {
201
+ threads.push(chunk);
202
+ }
203
+ }
204
+ }
205
+ const shouldAcknowledgeEmptyReset =
206
+ statusPayload.reset_cursor === true &&
207
+ hasCompleteRoot &&
208
+ !incompleteReplayRoot &&
209
+ selectedFiles.length === successfullyReadFiles.length &&
210
+ threads.length === 0;
211
+ if (selectedFiles.length === 0 && !shouldAcknowledgeEmptyReset) {
212
+ return { status: "up_to_date", uploaded: 0 };
213
+ }
214
+
215
+ const uploadResult = threads.length > 0
216
+ ? await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal })
217
+ : { uploaded: 0 };
218
+ if (shouldAcknowledgeEmptyReset) {
219
+ const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal });
220
+ validateThreadBatchAcknowledgement(payload, []);
221
+ }
222
+ if (selectedFiles.length === 0) return { status: "up_to_date", uploaded: 0 };
223
+ if (unreadableFiles.length > 0) {
224
+ return {
225
+ status: uploadResult.uploaded > 0 ? "uploaded" : "up_to_date",
226
+ uploaded: uploadResult.uploaded,
227
+ };
228
+ }
229
+
230
+ const providers = {};
231
+ for (const spec of providerSpecs) {
232
+ providers[spec.provider] = advanceProviderCursor(
233
+ providerCursor(state, spec.provider),
234
+ successfullyReadFiles.filter((file) => file.provider === spec.provider),
235
+ inventories.get(spec.provider),
236
+ );
237
+ }
238
+ signal?.throwIfAborted();
239
+ await writeSyncState(statePath, {
240
+ schema_version: STATE_SCHEMA_VERSION,
241
+ lookback_days: lookbackDays,
242
+ providers,
243
+ last_synced_at: new Date(now).toISOString(),
244
+ });
245
+ return {
246
+ status: uploadResult.uploaded > 0 ? "uploaded" : "up_to_date",
247
+ uploaded: uploadResult.uploaded,
248
+ };
249
+ }
250
+
251
+ export function workPatternSyncStatePath({ sessionRoot, apiOrigin, accountId, deviceId }) {
252
+ const normalizedOrigin = new URL(apiOrigin).origin;
253
+ const authenticatedSubject = cleanString(accountId, 500) || String(deviceId);
254
+ const scopeHash = createHash("sha256")
255
+ .update(`${normalizedOrigin}\0${authenticatedSubject}\0${String(deviceId)}`)
256
+ .digest("hex");
257
+ return join(sessionRoot, "work-pattern-history-sync", `${scopeHash}.json`);
258
+ }
259
+
260
+ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal }) {
261
+ const batches = [];
262
+ let batch = [];
263
+ for (const thread of threads) {
264
+ if (bodyByteLength([thread]) > MAX_GATEWAY_BODY_BYTES) {
265
+ throw new Error(
266
+ "Work-pattern observation chunk exceeds the local CLI gateway limit; "
267
+ + "the complete revision was not acknowledged.",
268
+ );
269
+ }
270
+ if (batch.length >= MAX_THREADS
271
+ || (batch.length > 0 && bodyByteLength([...batch, thread]) > MAX_GATEWAY_BODY_BYTES)) {
272
+ batches.push(batch);
273
+ batch = [];
274
+ }
275
+ batch.push(thread);
276
+ }
277
+ if (batch.length > 0) batches.push(batch);
278
+
279
+ let uploaded = 0;
280
+ for (const current of batches) {
281
+ const result = await postBatchWithBounded413Recovery({
282
+ endpoint,
283
+ token,
284
+ threads: current,
285
+ fetchImpl,
286
+ signal,
287
+ });
288
+ uploaded += result.uploaded;
289
+ }
290
+ return { uploaded };
291
+ }
292
+
293
+ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal }) {
294
+ const { response, payload } = await postThreadBatch({
295
+ endpoint,
296
+ token,
297
+ threads,
298
+ fetchImpl,
299
+ signal,
300
+ allow413: true,
301
+ });
302
+ if (response.status !== 413) {
303
+ validateThreadBatchAcknowledgement(payload, threads);
304
+ return { uploaded: threads.length };
305
+ }
306
+ if (threads.length > 1) {
307
+ const middle = Math.ceil(threads.length / 2);
308
+ const left = await postBatchWithBounded413Recovery({
309
+ endpoint,
310
+ token,
311
+ threads: threads.slice(0, middle),
312
+ fetchImpl,
313
+ signal,
314
+ });
315
+ const right = await postBatchWithBounded413Recovery({
316
+ endpoint,
317
+ token,
318
+ threads: threads.slice(middle),
319
+ fetchImpl,
320
+ signal,
321
+ });
322
+ return { uploaded: left.uploaded + right.uploaded };
323
+ }
324
+ throw new Error(
325
+ "Work-pattern observation chunk was rejected as too large; "
326
+ + "the complete revision was not acknowledged and will be retried unchanged.",
327
+ );
328
+ }
329
+
330
+ async function postThreadBatch({
331
+ endpoint,
332
+ token,
333
+ threads,
334
+ fetchImpl,
335
+ signal,
336
+ allow413 = false,
337
+ }) {
338
+ const body = JSON.stringify({ threads });
339
+ if (Buffer.byteLength(body, "utf8") > MAX_GATEWAY_BODY_BYTES) {
340
+ throw new Error("Work-pattern observation payload exceeds the local CLI gateway limit.");
341
+ }
342
+ const response = await fetchImpl(endpoint, {
343
+ method: "POST",
344
+ headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
345
+ body,
346
+ signal: signalWithTimeout(signal, 120_000),
347
+ });
348
+ const payload = await response.json().catch(() => null);
349
+ if (!response.ok && !(allow413 && response.status === 413)) {
350
+ throw new Error(webApiErrorMessage(payload, response.status));
351
+ }
352
+ return { response, payload };
353
+ }
354
+
355
+ function validateThreadBatchAcknowledgement(payload, threads) {
356
+ if (
357
+ payload?.raw_content_persisted !== false ||
358
+ !Number.isInteger(payload?.accepted) ||
359
+ payload.accepted !== threads.length ||
360
+ !Array.isArray(payload.accepted_receipts) ||
361
+ payload.accepted_receipts.length !== threads.length
362
+ ) {
363
+ throwIncompleteAcknowledgement();
364
+ }
365
+ const expected = threads.map(threadReceiptKey).sort();
366
+ const actual = payload.accepted_receipts.map(receiptKey).sort();
367
+ for (let index = 0; index < expected.length; index += 1) {
368
+ if (actual[index] !== expected[index]) throwIncompleteAcknowledgement();
369
+ }
370
+ }
371
+
372
+ function threadReceiptKey(thread) {
373
+ return receiptKey({
374
+ source_ref: thread.source_ref,
375
+ revision_ref: thread.revision_ref,
376
+ lineage_ref: thread.lineage_ref,
377
+ chunk_index: thread.chunk_index,
378
+ chunk_count: thread.chunk_count,
379
+ message_offset: thread.message_offset,
380
+ message_count: Array.isArray(thread.messages) ? thread.messages.length : -1,
381
+ });
382
+ }
383
+
384
+ function receiptKey(receipt) {
385
+ return [
386
+ stringPart(receipt?.source_ref),
387
+ stringPart(receipt?.revision_ref),
388
+ stringPart(receipt?.lineage_ref),
389
+ integerPart(receipt?.chunk_index),
390
+ integerPart(receipt?.chunk_count),
391
+ integerPart(receipt?.message_offset),
392
+ integerPart(receipt?.message_count),
393
+ ].join("\0");
394
+ }
395
+
396
+ function stringPart(value) {
397
+ return typeof value === "string" ? value : "";
398
+ }
399
+
400
+ function integerPart(value) {
401
+ return Number.isInteger(value) ? String(value) : "";
402
+ }
403
+
404
+ function throwIncompleteAcknowledgement() {
405
+ throw new Error(
406
+ "Work-pattern observation acknowledgement was incomplete; "
407
+ + "the revision cursor was not advanced and the exact payload will be retried.",
408
+ );
409
+ }
410
+
411
+ function signalWithTimeout(signal, timeoutMs) {
412
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
413
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
414
+ }
415
+
416
+ function bodyByteLength(threads) {
417
+ return Buffer.byteLength(JSON.stringify({ threads }), "utf8");
418
+ }
419
+
420
+ export async function extractCodexThreadChunks(filePath, { deviceId, fallbackObservedAt, lookbackCutoffMs }) {
421
+ let providerThreadId = basename(filePath, ".jsonl");
422
+ // The history record timestamp is authoritative for lookback filtering. The
423
+ // file mtime is only a fallback for legacy records without timestamps.
424
+ let observedAt = null;
425
+ let streamEntries = [];
426
+ let delegated = false;
427
+ const file = await readSessionRecords(filePath, (record) => {
428
+ if (record?.type === "session_meta") {
429
+ providerThreadId = cleanString(record.payload?.id, 200) || providerThreadId;
430
+ const source = record.payload?.source;
431
+ delegated ||= source === "subagent"
432
+ || (source && typeof source === "object" && Object.hasOwn(source, "subagent"));
433
+ return;
434
+ }
435
+ if (delegated) return;
436
+ if (record?.type === "event_msg") {
437
+ const type = record.payload?.type;
438
+ if (type === "user_message" || type === "agent_message") {
439
+ if (isInternalCodexMessage(record.payload)) return;
440
+ const messages = cleanMessageSegments(codexConversationText(record.payload?.message)).map((content) => ({
441
+ role: type === "user_message" ? "user" : "assistant",
442
+ content,
443
+ }));
444
+ if (messages.length > 0) streamEntries.push({
445
+ id: "", kind: "event", messages, observedAt: validIso(record.timestamp),
446
+ });
447
+ observedAt = latestIso(observedAt, record.timestamp);
448
+ }
449
+ return;
450
+ }
451
+ if (record?.type === "response_item" && record.payload?.type === "message") {
452
+ const role = record.payload?.role;
453
+ if (role !== "user" && role !== "assistant") return;
454
+ if (isInternalCodexMessage(record.payload)) return;
455
+ const contents = responseItemTextSegments(record.payload?.content);
456
+ if (contents.length > 0) {
457
+ streamEntries.push({
458
+ id: cleanString(record.payload?.id, 200),
459
+ kind: "response",
460
+ observedAt: validIso(record.timestamp),
461
+ messages: contents.map((content) => ({ role, content })),
462
+ });
463
+ observedAt = latestIso(observedAt, record.timestamp);
464
+ }
465
+ return;
466
+ }
467
+ if (record?.type === "compacted" && Array.isArray(record.payload?.replacement_history)) {
468
+ const nextEntries = codexReplacementHistoryEntries(record.payload.replacement_history);
469
+ if (nextEntries.length > 0) {
470
+ streamEntries = mergeCodexSnapshot(streamEntries, nextEntries);
471
+ observedAt = latestIso(observedAt, record.timestamp);
472
+ }
473
+ }
474
+ });
475
+ if (!file || file.readOk === false) return markReadStatus([], false);
476
+ if (delegated) return markReadStatus([], true);
477
+ // Legacy transcripts can have no record timestamps at all. In that case
478
+ // retain the documented file-mtime fallback; mixed dated/undated streams
479
+ // never let an undated entry borrow a recent timestamp from another turn.
480
+ if (!streamEntries.some((entry) => entry.observedAt)) {
481
+ const fallback = validIso(fallbackObservedAt) || new Date(file.mtimeMs).toISOString();
482
+ streamEntries = streamEntries.map((entry) => ({ ...entry, observedAt: fallback }));
483
+ }
484
+ const messages = mergeCodexHistoryEntries(streamEntries, lookbackCutoffMs);
485
+ if (!providerThreadId || messages.length === 0) return markReadStatus([], true);
486
+ return markReadStatus(chunkThread({
487
+ provider: PROVIDER_CODEX,
488
+ deviceId,
489
+ providerThreadId,
490
+ observedAt: validIso(observedAt)
491
+ || validIso(fallbackObservedAt)
492
+ || new Date(file.mtimeMs).toISOString(),
493
+ messages,
494
+ }), true);
495
+ }
496
+
497
+ export async function extractCodexThread(filePath, options) {
498
+ const chunks = await extractCodexThreadChunks(filePath, options);
499
+ if (chunks.length === 0) return null;
500
+ return {
501
+ source_ref: chunks.legacySourceRef,
502
+ observed_at: chunks.at(-1).observed_at,
503
+ messages: chunks.flatMap((chunk) => chunk.messages).slice(-MAX_MESSAGES),
504
+ };
505
+ }
506
+
507
+ export async function extractClaudeThreadChunks(filePath, { deviceId, fallbackObservedAt, lookbackCutoffMs }) {
508
+ let providerThreadId = basename(filePath, ".jsonl");
509
+ // Do not let a recent file rewrite make old conversation content look new.
510
+ let observedAt = null;
511
+ const messageEntries = [];
512
+ const file = await readSessionRecords(filePath, (record) => {
513
+ const role = record?.type;
514
+ if (role !== "user" && role !== "assistant") return;
515
+ if (record.isMeta === true || record.message?.role !== role) return;
516
+ providerThreadId = cleanString(record.sessionId, 200) || providerThreadId;
517
+ const contents = claudeMessageTextSegments(record.message?.content, role);
518
+ if (contents.length === 0) return;
519
+ messageEntries.push(...contents.map((content) => ({
520
+ role, content, observedAt: validIso(record.timestamp),
521
+ })));
522
+ observedAt = latestIso(observedAt, record.timestamp);
523
+ });
524
+ if (!file || file.readOk === false) return markReadStatus([], false);
525
+ const allUndated = !messageEntries.some((entry) => entry.observedAt);
526
+ const fallback = validIso(fallbackObservedAt) || new Date(file.mtimeMs).toISOString();
527
+ const messages = messageEntries
528
+ .filter((entry) => lookbackCutoffMs === undefined || Date.parse(entry.observedAt || (allUndated ? fallback : "")) >= lookbackCutoffMs)
529
+ .map(({ role, content }) => ({ role, content }));
530
+ if (!providerThreadId || messages.length === 0) return markReadStatus([], true);
531
+ return markReadStatus(chunkThread({
532
+ provider: PROVIDER_CLAUDE,
533
+ deviceId,
534
+ providerThreadId,
535
+ observedAt: validIso(observedAt)
536
+ || validIso(fallbackObservedAt)
537
+ || new Date(file.mtimeMs).toISOString(),
538
+ messages,
539
+ }), true);
540
+ }
541
+
542
+ export async function extractClaudeThread(filePath, options) {
543
+ const chunks = await extractClaudeThreadChunks(filePath, options);
544
+ if (chunks.length === 0) return null;
545
+ return {
546
+ source_ref: chunks.legacySourceRef,
547
+ observed_at: chunks.at(-1).observed_at,
548
+ messages: chunks.flatMap((chunk) => chunk.messages).slice(-MAX_MESSAGES),
549
+ };
550
+ }
551
+
552
+ export function opaqueCodexRef(deviceId, providerThreadId) {
553
+ return opaqueProviderRef(PROVIDER_CODEX, deviceId, providerThreadId);
554
+ }
555
+
556
+ export function opaqueClaudeRef(deviceId, providerThreadId) {
557
+ return opaqueProviderRef(PROVIDER_CLAUDE, deviceId, providerThreadId);
558
+ }
559
+
560
+ function opaqueProviderRef(provider, deviceId, providerThreadId) {
561
+ const digest = createHash("sha256")
562
+ // Preserve the already-shipped Codex digest while separating providers by
563
+ // the non-secret prefix. Identical provider-local ids cannot collide.
564
+ .update(`${String(deviceId)}:${String(providerThreadId)}`)
565
+ .digest("hex");
566
+ return `${provider}:${digest}`;
567
+ }
568
+
569
+ async function recentSessionFiles(root, {
570
+ provider,
571
+ processedFiles,
572
+ includeRelativePath,
573
+ onDirectoryVisited,
574
+ }) {
575
+ const absoluteRoot = resolve(root);
576
+ let complete = true;
577
+ const fileHashes = new Set();
578
+ const rootMetadata = await lstat(absoluteRoot).catch(() => {
579
+ complete = false;
580
+ return null;
581
+ });
582
+ if (!rootMetadata?.isDirectory() || rootMetadata.isSymbolicLink()) {
583
+ // A missing or unavailable root may return later. It is not a complete
584
+ // empty inventory and must not erase acknowledgements for its histories.
585
+ return { files: [], fileHashes, complete: false };
586
+ }
587
+ const oldest = [];
588
+ const newest = [];
589
+ async function walk(directory) {
590
+ onDirectoryVisited?.(relative(absoluteRoot, directory));
591
+ let entries;
592
+ try { entries = await opendir(directory); } catch { complete = false; return; }
593
+ for await (const entry of entries) {
594
+ const candidate = resolve(directory, entry.name);
595
+ if (candidate !== absoluteRoot && !candidate.startsWith(absoluteRoot + sep)) continue;
596
+ const relativePath = relative(absoluteRoot, candidate);
597
+ // Reject excluded directory branches before lstat/opendir. Claude's
598
+ // delegated-agent trees can be large and are never user conversations.
599
+ if (entry.isDirectory() && !includeRelativePath(relativePath)) continue;
600
+ const metadata = await lstat(candidate).catch(() => { complete = false; return null; });
601
+ if (!metadata || metadata.isSymbolicLink()) continue;
602
+ if (metadata.isDirectory()) {
603
+ if (!includeRelativePath(relativePath)) continue;
604
+ await walk(candidate);
605
+ } else if (metadata.isFile() && entry.name.endsWith(".jsonl")) {
606
+ if (!includeRelativePath(relativePath)) continue;
607
+ const fileHash = createHash("sha256")
608
+ .update(`${provider}\0${basename(absoluteRoot)}\0${relativePath}`)
609
+ .digest("hex");
610
+ fileHashes.add(fileHash);
611
+ // A global mtime cursor hides restored files. Per-file fingerprints also
612
+ // detect rewrites that preserve mtime without persisting local paths.
613
+ const fingerprint = `${metadata.mtimeMs}:${metadata.ctimeMs}:${metadata.size}`;
614
+ if (processedFiles[fileHash]?.fingerprint === fingerprint) continue;
615
+ const file = { path: candidate, provider, mtimeMs: metadata.mtimeMs, fileHash, fingerprint, root_key: basename(absoluteRoot) };
616
+ oldest.push(file);
617
+ oldest.sort(compareSessionFiles);
618
+ if (oldest.length > MAX_CANDIDATE_FILES / 2) oldest.pop();
619
+ newest.push(file);
620
+ newest.sort((left, right) => compareSessionFiles(right, left));
621
+ if (newest.length > MAX_CANDIDATE_FILES / 2) newest.pop();
622
+ }
623
+ }
624
+ }
625
+ await walk(absoluteRoot);
626
+ return {
627
+ files: [...new Map([...oldest, ...newest].map((file) => [file.fileHash, file])).values()],
628
+ fileHashes,
629
+ complete,
630
+ };
631
+ }
632
+
633
+ function selectSessionFiles(files) {
634
+ const recent = [...files].sort((left, right) => compareSessionFiles(right, left));
635
+ const older = [...files].sort(compareSessionFiles);
636
+ const selected = recent.slice(0, MAX_THREADS - 2);
637
+ const seen = new Set(selected.map((file) => file.fileHash));
638
+ for (const file of older) {
639
+ if (selected.length >= MAX_THREADS) break;
640
+ if (!seen.has(file.fileHash)) {
641
+ selected.push(file);
642
+ seen.add(file.fileHash);
643
+ }
644
+ }
645
+ for (const file of recent) {
646
+ if (selected.length >= MAX_THREADS) break;
647
+ if (!seen.has(file.fileHash)) {
648
+ selected.push(file);
649
+ seen.add(file.fileHash);
650
+ }
651
+ }
652
+ return selected;
653
+ }
654
+
655
+ function compareSessionFiles(left, right) {
656
+ return left.mtimeMs - right.mtimeMs
657
+ || left.provider.localeCompare(right.provider)
658
+ || left.fileHash.localeCompare(right.fileHash);
659
+ }
660
+
661
+ async function readSessionRecords(filePath, consume) {
662
+ let handle;
663
+ try {
664
+ handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
665
+ const metadata = await handle.stat();
666
+ if (!metadata.isFile()) return null;
667
+ const lines = createInterface({
668
+ input: handle.createReadStream({ encoding: "utf8", autoClose: false }),
669
+ crlfDelay: Infinity,
670
+ });
671
+ for await (const line of lines) {
672
+ if (!line) continue;
673
+ let record;
674
+ try { record = JSON.parse(line); } catch { return markFileReadStatus(metadata, false); }
675
+ consume(record);
676
+ }
677
+ return markFileReadStatus(metadata, true);
678
+ } catch {
679
+ return null;
680
+ } finally {
681
+ await handle?.close().catch(() => {});
682
+ }
683
+ }
684
+
685
+ function markFileReadStatus(metadata, readOk) {
686
+ Object.defineProperty(metadata, "readOk", {
687
+ value: readOk,
688
+ enumerable: false,
689
+ });
690
+ return metadata;
691
+ }
692
+
693
+ function responseItemTextSegments(content) {
694
+ if (!Array.isArray(content)) return [];
695
+ const text = content
696
+ .filter((item) => item?.type === "input_text" || item?.type === "output_text")
697
+ .map((item) => String(item.text || ""))
698
+ .join("\n");
699
+ return cleanMessageSegments(codexConversationText(text));
700
+ }
701
+
702
+ function isInternalCodexMessage(message) {
703
+ return message?.channel === "analysis"
704
+ || message?.phase === "analysis"
705
+ || (message?.role === "assistant" && message.recipient && message.recipient !== "all");
706
+ }
707
+
708
+ function codexConversationText(value) {
709
+ if (typeof value !== "string") return "";
710
+ const text = value.trim();
711
+ // These are runtime-generated context records, even when the persisted
712
+ // message envelope uses the user role. They are not personal instructions.
713
+ if (text.startsWith("# AGENTS.md instructions for ")
714
+ || text.startsWith("<environment_context>")
715
+ || text.startsWith("<recommended_plugins>")
716
+ || text.startsWith("<turn_aborted>")
717
+ || text.startsWith("Another language model started to solve this problem")) return "";
718
+ return text;
719
+ }
720
+
721
+ function codexReplacementHistoryEntries(history) {
722
+ const entries = [];
723
+ for (const item of history) {
724
+ if (isInternalCodexMessage(item)) continue;
725
+ const role = item?.type === "agent_message"
726
+ ? "assistant"
727
+ : item?.type === "message" && (item.role === "user" || item.role === "assistant")
728
+ ? item.role
729
+ : null;
730
+ if (!role) continue;
731
+ const contents = responseItemTextSegments(item.content);
732
+ if (contents.length === 0) continue;
733
+ entries.push({
734
+ id: cleanString(item.id, 200),
735
+ kind: "snapshot",
736
+ observedAt: validIso(item.timestamp),
737
+ messages: contents.map((content) => ({ role, content })),
738
+ });
739
+ }
740
+ return entries;
741
+ }
742
+
743
+ function entryKey(entry) {
744
+ return JSON.stringify(entry.messages);
745
+ }
746
+
747
+ function mergeCodexSnapshot(streamEntries, snapshotEntries) {
748
+ const merged = [...streamEntries];
749
+ let position = 0;
750
+ for (const entry of snapshotEntries) {
751
+ // Match in order, so genuinely repeated instructions remain repeated. A
752
+ // snapshot is a replacement view of prior history, not a fresh user turn.
753
+ const key = entryKey(entry);
754
+ const index = merged.findIndex((existing, i) => i >= position
755
+ && ((entry.id && existing.id === entry.id) || entryKey(existing) === key));
756
+ if (index >= 0) position = index + 1;
757
+ else {
758
+ merged.splice(position, 0, entry);
759
+ position += 1;
760
+ }
761
+ }
762
+ return merged;
763
+ }
764
+
765
+ function mergeCodexHistoryEntries(entries, lookbackCutoffMs) {
766
+ const seenIds = new Set();
767
+ const merged = [];
768
+ for (const entry of entries) {
769
+ if (entry.id) {
770
+ if (seenIds.has(entry.id)) continue;
771
+ seenIds.add(entry.id);
772
+ }
773
+ const previous = merged.at(-1);
774
+ const complementary = previous && !previous.paired && previous.kind !== entry.kind;
775
+ if (complementary && entryKey(previous) === entryKey(entry)) {
776
+ previous.paired = true;
777
+ previous.observedAt = latestIso(previous.observedAt, entry.observedAt);
778
+ continue;
779
+ }
780
+ merged.push({ ...entry });
781
+ }
782
+ return merged
783
+ .filter((entry) => lookbackCutoffMs === undefined
784
+ || (entry.observedAt && Date.parse(entry.observedAt) >= lookbackCutoffMs))
785
+ .flatMap((entry) => entry.messages);
786
+ }
787
+
788
+ function markReadStatus(chunks, readOk) {
789
+ Object.defineProperty(chunks, "readOk", {
790
+ value: readOk,
791
+ enumerable: false,
792
+ });
793
+ return chunks;
794
+ }
795
+
796
+ function claudeMessageTextSegments(content, role) {
797
+ const text = typeof content === "string"
798
+ ? content
799
+ : Array.isArray(content)
800
+ ? content
801
+ .filter((item) => item?.type === "text" && typeof item.text === "string")
802
+ .map((item) => item.text)
803
+ .join("\n")
804
+ : "";
805
+ return cleanMessageSegments(role === "user" ? stripClaudeInternalBlocks(text) : text);
806
+ }
807
+
808
+ function stripClaudeInternalBlocks(value) {
809
+ return String(value)
810
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, "")
811
+ .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi, "")
812
+ .replace(/<command-(?:name|message|args)>[\s\S]*?<\/command-(?:name|message|args)>/gi, "");
813
+ }
814
+
815
+ function cleanMessageSegments(value) {
816
+ if (typeof value !== "string") return [];
817
+ const cleaned = value.replaceAll("\u0000", "").trim();
818
+ if (!cleaned) return [];
819
+ const points = Array.from(cleaned);
820
+ const segments = [];
821
+ for (let offset = 0; offset < points.length; offset += MAX_MESSAGE_CHARS) {
822
+ segments.push(points.slice(offset, offset + MAX_MESSAGE_CHARS).join(""));
823
+ }
824
+ return segments;
825
+ }
826
+
827
+ function chunkThread({ provider, deviceId, providerThreadId, observedAt, messages }) {
828
+ const revisionRef = createHash("sha256")
829
+ .update(JSON.stringify({ extraction_policy: HISTORY_EXTRACTION_POLICY, messages }))
830
+ .digest("hex");
831
+ const chunkCount = Math.ceil(messages.length / MAX_MESSAGES);
832
+ const chunks = Array.from({ length: chunkCount }, (_, chunkIndex) => {
833
+ const messageOffset = chunkIndex * MAX_MESSAGES;
834
+ const chunkIdentity = `${providerThreadId}:revision:${revisionRef}:chunk:${chunkIndex}`;
835
+ return {
836
+ extraction_policy: HISTORY_EXTRACTION_POLICY,
837
+ source_ref: opaqueProviderRef(provider, deviceId, chunkIdentity),
838
+ lineage_ref: opaqueProviderRef(provider, deviceId, providerThreadId),
839
+ observed_at: observedAt,
840
+ revision_ref: revisionRef,
841
+ chunk_index: chunkIndex,
842
+ chunk_count: chunkCount,
843
+ message_offset: messageOffset,
844
+ messages: messages.slice(messageOffset, messageOffset + MAX_MESSAGES),
845
+ };
846
+ });
847
+ Object.defineProperty(chunks, "legacySourceRef", {
848
+ value: opaqueProviderRef(provider, deviceId, providerThreadId),
849
+ enumerable: false,
850
+ });
851
+ return chunks;
852
+ }
853
+
854
+ function cleanString(value, maxLength) {
855
+ if (typeof value !== "string") return "";
856
+ return value.replaceAll("\u0000", "").trim().slice(0, maxLength);
857
+ }
858
+
859
+ function validIso(value) {
860
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return null;
861
+ return new Date(value).toISOString();
862
+ }
863
+
864
+ function latestIso(current, candidate) {
865
+ const left = validIso(current);
866
+ const right = validIso(candidate);
867
+ if (!right) return left || current;
868
+ if (!left) return right;
869
+ return Date.parse(right) > Date.parse(left) ? right : left;
870
+ }
871
+
872
+ function boundedInteger(value, min, max, fallback) {
873
+ const parsed = Number(value);
874
+ return Number.isInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
875
+ }
876
+
877
+ async function readSyncState(filePath) {
878
+ try {
879
+ const parsed = JSON.parse(await readFile(filePath, "utf8"));
880
+ // Earlier schemas acknowledge truncated or unfiltered history. Replay with
881
+ // the corrected extraction policy rather than inheriting those receipts.
882
+ return parsed?.schema_version === STATE_SCHEMA_VERSION ? parsed : {};
883
+ } catch {
884
+ return {};
885
+ }
886
+ }
887
+
888
+ function providerCursor(state, provider) {
889
+ const cursor = state?.providers?.[provider];
890
+ const files = cursor?.processed_files;
891
+ const processedFiles = {};
892
+ if (files && typeof files === "object" && !Array.isArray(files)) {
893
+ for (const [hash, file] of Object.entries(files)) {
894
+ if (!/^[0-9a-f]{64}$/.test(hash) || typeof file?.fingerprint !== "string") continue;
895
+ processedFiles[hash] = { fingerprint: file.fingerprint };
896
+ if (["sessions", "archived_sessions", "projects"].includes(file.root_key)) {
897
+ processedFiles[hash].root_key = file.root_key;
898
+ }
899
+ if (new RegExp(`^${provider}:[0-9a-f]{64}$`).test(file.lineage_ref)
900
+ && /^[0-9a-f]{64}$/.test(file.revision_ref)) {
901
+ processedFiles[hash].lineage_ref = file.lineage_ref;
902
+ processedFiles[hash].revision_ref = file.revision_ref;
903
+ }
904
+ }
905
+ }
906
+ return {
907
+ last_uploaded_mtime_ms: Number(cursor?.last_uploaded_mtime_ms || 0),
908
+ processed_files: processedFiles,
909
+ };
910
+ }
911
+
912
+ function advanceProviderCursor(cursor, selectedFiles, currentFileHashes) {
913
+ // Drop deleted/moved path fingerprints only after a complete inventory.
914
+ // Inaccessible directories must not erase acknowledgements for their files.
915
+ const processedFiles = Object.fromEntries(Object.entries(cursor.processed_files)
916
+ .filter(([hash]) => !currentFileHashes || currentFileHashes.has(hash)));
917
+ for (const file of selectedFiles) {
918
+ processedFiles[file.fileHash] = {
919
+ fingerprint: file.fingerprint,
920
+ root_key: file.root_key,
921
+ ...(file.lineage_ref && file.revision_ref
922
+ ? { lineage_ref: file.lineage_ref, revision_ref: file.revision_ref }
923
+ : {}),
924
+ };
925
+ }
926
+ return {
927
+ last_uploaded_mtime_ms: Math.max(
928
+ cursor.last_uploaded_mtime_ms,
929
+ ...selectedFiles.map((file) => file.mtimeMs),
930
+ ),
931
+ processed_files: processedFiles,
932
+ };
933
+ }
934
+
935
+ async function writeSyncState(filePath, state) {
936
+ await mkdir(resolve(filePath, ".."), { recursive: true, mode: 0o700 });
937
+ const temporary = `${filePath}.tmp`;
938
+ await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
939
+ await rename(temporary, filePath);
940
+ }