memax-cli 0.1.3 → 0.2.0-alpha.17

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.
@@ -1,1513 +0,0 @@
1
- import chalk from "chalk";
2
- import { createHash } from "node:crypto";
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
4
- import { basename, dirname, join, relative } from "node:path";
5
- import { homedir } from "node:os";
6
- import { getClient } from "../lib/client.js";
7
- import { getProjectScope, resolveClaudeProjectPath, resolveClaudeProjectFolder, resolveProjectRootPath, resolveProjectScope, normalizeFilePath, } from "../lib/project-context.js";
8
- import { getOrCreateDeviceID, loadConfig } from "../lib/config.js";
9
- import { ask, confirmDefault } from "../lib/prompt.js";
10
- import { moveFileToTrash } from "../lib/trash.js";
11
- const PROJECT_ROOT_PLACEHOLDER = "__MEMAX_PROJECT_ROOT__";
12
- const PORTABLE_SESSION_AGENTS = new Set(["claude-code", "codex", "gemini"]);
13
- function isScopeValue(value) {
14
- return value === "global" || value.startsWith("project:");
15
- }
16
- function isWithinRoot(candidate, root) {
17
- return candidate === root || candidate.startsWith(`${root}/`);
18
- }
19
- function replaceProjectRootPrefix(value, fromRoot, toRoot) {
20
- if (!isWithinRoot(value, fromRoot))
21
- return value;
22
- if (value === fromRoot)
23
- return toRoot;
24
- return `${toRoot}${value.slice(fromRoot.length)}`;
25
- }
26
- function transformStructuredCwdFields(value, map) {
27
- if (Array.isArray(value)) {
28
- return value.map((item) => transformStructuredCwdFields(item, map));
29
- }
30
- if (!value || typeof value !== "object") {
31
- return value;
32
- }
33
- const next = {};
34
- for (const [key, child] of Object.entries(value)) {
35
- if (key === "cwd" && typeof child === "string") {
36
- next[key] = map(child);
37
- continue;
38
- }
39
- next[key] = transformStructuredCwdFields(child, map);
40
- }
41
- return next;
42
- }
43
- function transformJsonLinesByCwd(content, map) {
44
- const lines = content.toString("utf-8").split("\n");
45
- const transformed = lines.map((line) => {
46
- if (!line.trim())
47
- return line;
48
- try {
49
- const parsed = JSON.parse(line);
50
- return JSON.stringify(transformStructuredCwdFields(parsed, map));
51
- }
52
- catch {
53
- return line;
54
- }
55
- });
56
- return Buffer.from(transformed.join("\n"), "utf-8");
57
- }
58
- function transformJsonObjectByCwd(content, map) {
59
- try {
60
- const parsed = JSON.parse(content.toString("utf-8"));
61
- return Buffer.from(`${JSON.stringify(transformStructuredCwdFields(parsed, map), null, 2)}\n`, "utf-8");
62
- }
63
- catch {
64
- return content;
65
- }
66
- }
67
- function canonicalizeSessionContent(agent, content, projectRoot) {
68
- if (!projectRoot)
69
- return content;
70
- const map = (cwd) => replaceProjectRootPrefix(cwd, projectRoot, PROJECT_ROOT_PLACEHOLDER);
71
- switch (agent) {
72
- case "claude-code":
73
- case "codex":
74
- return transformJsonLinesByCwd(content, map);
75
- case "gemini":
76
- return transformJsonObjectByCwd(content, map);
77
- default:
78
- return content;
79
- }
80
- }
81
- function renderPortableSessionContent(agent, content, sourceProjectRoot, targetProjectRoot) {
82
- if (!sourceProjectRoot || !targetProjectRoot)
83
- return content;
84
- if (sourceProjectRoot === targetProjectRoot)
85
- return content;
86
- const map = (cwd) => replaceProjectRootPrefix(cwd, sourceProjectRoot, targetProjectRoot);
87
- switch (agent) {
88
- case "claude-code":
89
- case "codex":
90
- return transformJsonLinesByCwd(content, map);
91
- case "gemini":
92
- return transformJsonObjectByCwd(content, map);
93
- default:
94
- return content;
95
- }
96
- }
97
- export function hashPortableSessionContent(agent, content, projectRoot) {
98
- const canonical = canonicalizeSessionContent(agent, content, projectRoot);
99
- return createHash("sha256").update(canonical).digest("hex");
100
- }
101
- export function computeSessionSyncHash(agent, scope, content, currentProjectRootPath) {
102
- return hashPortableSessionContent(agent, content, scope.startsWith("project:") ? currentProjectRootPath : undefined);
103
- }
104
- export function isLegacyGlobalSessionShadowed(action, projectScopedKeys) {
105
- if (action.scope !== "global")
106
- return false;
107
- if (action.reason !== "cloud_only" &&
108
- action.reason !== "deleted_everywhere") {
109
- return false;
110
- }
111
- if (!PORTABLE_SESSION_AGENTS.has(action.agent))
112
- return false;
113
- if (action.agent === "codex" &&
114
- normalizeFilePath(action.file_path) === "history.jsonl") {
115
- return false;
116
- }
117
- return projectScopedKeys.has(`${action.agent}|${normalizeFilePath(action.file_path)}`);
118
- }
119
- export function findShadowedGlobalSessions(sessions) {
120
- const projectScopedByKey = new Map();
121
- for (const session of sessions) {
122
- if (!PORTABLE_SESSION_AGENTS.has(session.agent))
123
- continue;
124
- if (!session.scope.startsWith("project:"))
125
- continue;
126
- const normalized = normalizeFilePath(session.file_path);
127
- if (session.agent === "codex" && normalized === "history.jsonl")
128
- continue;
129
- const key = `${session.agent}|${normalized}`;
130
- const group = projectScopedByKey.get(key) ?? [];
131
- group.push(session);
132
- projectScopedByKey.set(key, group);
133
- }
134
- const pairs = [];
135
- for (const session of sessions) {
136
- if (!PORTABLE_SESSION_AGENTS.has(session.agent))
137
- continue;
138
- if (session.scope !== "global")
139
- continue;
140
- const normalized = normalizeFilePath(session.file_path);
141
- if (session.agent === "codex" && normalized === "history.jsonl")
142
- continue;
143
- const siblings = projectScopedByKey.get(`${session.agent}|${normalized}`);
144
- if (!siblings)
145
- continue;
146
- for (const sibling of siblings) {
147
- pairs.push({ global: session, project: sibling });
148
- }
149
- }
150
- return pairs;
151
- }
152
- function computeDownloadedPortableHash(agent, content) {
153
- const projectRoot = readStructuredSessionRootFromContent(agent, content);
154
- return hashPortableSessionContent(agent, content, projectRoot ?? undefined);
155
- }
156
- function readStructuredSessionRootFromContent(agent, content) {
157
- try {
158
- const raw = content.toString("utf-8");
159
- switch (agent) {
160
- case "claude-code":
161
- case "codex": {
162
- for (const line of raw.split("\n")) {
163
- if (!line.trim())
164
- continue;
165
- try {
166
- const parsed = JSON.parse(line);
167
- const cwd = typeof parsed?.cwd === "string"
168
- ? parsed.cwd
169
- : typeof parsed?.payload?.cwd === "string"
170
- ? parsed.payload.cwd
171
- : "";
172
- if (cwd) {
173
- return resolveProjectRootPath(cwd) ?? cwd;
174
- }
175
- }
176
- catch { }
177
- }
178
- return null;
179
- }
180
- case "gemini": {
181
- const parsed = JSON.parse(raw);
182
- const cwd = typeof parsed?.cwd === "string" ? parsed.cwd : "";
183
- if (cwd) {
184
- return resolveProjectRootPath(cwd) ?? cwd;
185
- }
186
- return null;
187
- }
188
- default:
189
- return null;
190
- }
191
- }
192
- catch {
193
- return null;
194
- }
195
- }
196
- function readStructuredSessionRoot(agent, path) {
197
- try {
198
- return readStructuredSessionRootFromContent(agent, readFileSync(path));
199
- }
200
- catch {
201
- return null;
202
- }
203
- }
204
- export function computeSessionProjectContext(agent, path, fallbackProjectRoot) {
205
- const projectRoot = fallbackProjectRoot ?? readStructuredSessionRoot(agent, path) ?? undefined;
206
- if (!projectRoot) {
207
- return { scope: "global" };
208
- }
209
- const resolvedRoot = resolveProjectRootPath(projectRoot) ?? projectRoot;
210
- const scope = getProjectScope(resolvedRoot);
211
- if (scope === "project" || !scope.startsWith("project:")) {
212
- return { scope: "global" };
213
- }
214
- return { scope, projectRoot: resolvedRoot };
215
- }
216
- function encodeClaudeProjectDir(projectRoot) {
217
- return projectRoot.replace(/\//g, "-");
218
- }
219
- function ensureGeminiProjectDir(home, currentProjectRootPath, scope) {
220
- const existing = findGeminiProjectDir(home, scope);
221
- if (existing)
222
- return existing;
223
- const dirName = `${basename(currentProjectRootPath)}-${createHash("sha256").update(scope).digest("hex").slice(0, 8)}`;
224
- const projectDir = join(home, ".gemini", "tmp", dirName);
225
- mkdirSync(projectDir, { recursive: true });
226
- return projectDir;
227
- }
228
- export function materializeAgentSessionContent(agent, content, options) {
229
- let next = content;
230
- if (options.scope.startsWith("project:")) {
231
- const sourceProjectRoot = readStructuredSessionRootFromContent(agent, content);
232
- next = renderPortableSessionContent(agent, next, sourceProjectRoot ?? undefined, options.currentProjectRootPath);
233
- }
234
- if (agent === "gemini" && options.scope.startsWith("project:")) {
235
- const projectDir = dirname(dirname(options.writePath));
236
- mkdirSync(projectDir, { recursive: true });
237
- writeFileSync(join(projectDir, ".project_root"), `${options.currentProjectRootPath}\n`);
238
- }
239
- return next;
240
- }
241
- export async function syncAgentSessionsCommand(options = {}) {
242
- console.log(chalk.bold("\n Memax Session Sync\n"));
243
- const cwd = process.cwd();
244
- const home = homedir();
245
- const deviceID = getOrCreateDeviceID();
246
- const projectScopeResolution = resolveProjectScope(cwd);
247
- const currentProjectScope = projectScopeResolution.scope;
248
- const currentProjectRootPath = resolveProjectRootPath(cwd) ?? cwd;
249
- const locations = discoverAgentSessions();
250
- const localSessions = locations
251
- .filter((loc) => existsSync(loc.path))
252
- .map((loc) => {
253
- const content = readFileSync(loc.path);
254
- return {
255
- loc,
256
- content,
257
- hash: hashPortableSessionContent(loc.agent, content, loc.projectRoot),
258
- size: statSync(loc.path).size,
259
- };
260
- });
261
- const manifest = localSessions.map((session) => ({
262
- agent: session.loc.agent,
263
- file_path: session.loc.filePath,
264
- scope: session.loc.scope,
265
- content_hash: session.hash,
266
- local_path: session.loc.path,
267
- }));
268
- let actions;
269
- try {
270
- const plan = await getClient().agentSessions.sync({
271
- device_id: deviceID,
272
- sessions: manifest,
273
- });
274
- actions = plan.actions;
275
- }
276
- catch (err) {
277
- console.error(chalk.red(` Sync failed: ${err.message}\n`));
278
- return;
279
- }
280
- // Note: diverged sessions are NOT rewritten to push/pull — they use
281
- // resolveDivergence() which atomically snapshots the losing branch.
282
- // --push and --pull set the resolution direction for diverged sessions.
283
- actions = actions.filter((action) => {
284
- if (!action.scope.startsWith("project:"))
285
- return true;
286
- if (action.scope === currentProjectScope)
287
- return true;
288
- if (action.action === "pull" && action.reason === "cloud_only")
289
- return false;
290
- return true;
291
- });
292
- const projectScopedKeys = new Set(actions
293
- .filter((action) => action.scope.startsWith("project:"))
294
- .map((action) => `${action.agent}|${normalizeFilePath(action.file_path)}`));
295
- actions = actions.filter((action) => !isLegacyGlobalSessionShadowed(action, projectScopedKeys));
296
- const localByKey = new Map();
297
- for (const session of localSessions) {
298
- localByKey.set(`${session.loc.agent}|${session.loc.filePath}|${session.loc.scope}`, session);
299
- }
300
- const locationByKey = new Map();
301
- for (const location of locations) {
302
- locationByKey.set(`${location.agent}|${location.filePath}|${location.scope}`, location);
303
- }
304
- const resolveWritePath = (agent, filePath, scope) => {
305
- const existing = locationByKey.get(`${agent}|${filePath}|${scope}`);
306
- if (existing)
307
- return existing.path;
308
- return resolveAgentSessionWritePath(agent, filePath, scope, {
309
- cwd,
310
- home,
311
- currentProjectScope,
312
- currentProjectRootPath,
313
- });
314
- };
315
- let pushed = 0;
316
- let pulled = 0;
317
- let deletedLocal = 0;
318
- let reconciled = 0;
319
- let unchanged = 0;
320
- let skipped = 0;
321
- let errors = 0;
322
- const ackSessions = [];
323
- const byAgent = new Map();
324
- for (const action of actions) {
325
- const group = byAgent.get(action.agent) ?? [];
326
- group.push(action);
327
- byAgent.set(action.agent, group);
328
- }
329
- for (const [agent, agentActions] of byAgent) {
330
- console.log(chalk.white(` ${formatAgentName(agent)}`));
331
- for (const action of agentActions) {
332
- const key = `${action.agent}|${action.file_path}|${action.scope}`;
333
- if (action.action === "unchanged") {
334
- const local = localByKey.get(key);
335
- console.log(chalk.gray(` = ${action.file_path}`), chalk.gray("unchanged"));
336
- if (local && action.version) {
337
- ackSessions.push({
338
- agent: action.agent,
339
- file_path: action.file_path,
340
- scope: action.scope,
341
- content_hash: local.hash,
342
- version: action.version,
343
- local_path: local.loc.path,
344
- });
345
- }
346
- unchanged++;
347
- continue;
348
- }
349
- if (action.action === "push") {
350
- const local = localByKey.get(key);
351
- if (!local) {
352
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray("local file not found for push"));
353
- errors++;
354
- continue;
355
- }
356
- try {
357
- const fileRef = await uploadLocalFile(local.loc.path, local.content);
358
- await getClient().agentSessions.upsert({
359
- agent: action.agent,
360
- file_path: action.file_path,
361
- scope: action.scope,
362
- session_type: local.loc.sessionType,
363
- content_hash: local.hash,
364
- device_id: deviceID,
365
- local_path: local.loc.path,
366
- file_ref: fileRef,
367
- });
368
- console.log(chalk.green(` ↑ ${action.file_path}`), chalk.gray(action.reason === "local_only"
369
- ? "pushing (new)"
370
- : "pushing (local newer)"));
371
- pushed++;
372
- }
373
- catch (err) {
374
- if (err instanceof SessionOversizeError) {
375
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
376
- skipped++;
377
- }
378
- else {
379
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
380
- errors++;
381
- }
382
- }
383
- continue;
384
- }
385
- if (action.action === "pull") {
386
- if (!action.session_id) {
387
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray("missing session ID from server"));
388
- errors++;
389
- continue;
390
- }
391
- const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
392
- if (!writePath) {
393
- console.log(chalk.yellow(` ? ${action.file_path}`), chalk.gray(action.scope !== "global" && action.scope !== currentProjectScope
394
- ? "different project — skipped"
395
- : "no safe restore path on this machine"));
396
- skipped++;
397
- continue;
398
- }
399
- try {
400
- const isNewLocally = action.reason === "cloud_only" && !existsSync(writePath);
401
- if (isNewLocally && !options.pull) {
402
- console.log(chalk.cyan(` New file: ${action.file_path}`));
403
- console.log(chalk.gray(` → ${writePath}`));
404
- const accept = await confirmDefault(` Download? [Y/n] `);
405
- if (!accept) {
406
- console.log(chalk.gray(` - ${action.file_path}`), chalk.gray("skipped"));
407
- skipped++;
408
- continue;
409
- }
410
- }
411
- const session = await getClient().agentSessions.get(action.session_id);
412
- const bytes = await downloadAgentSession(action.session_id);
413
- mkdirSync(dirname(writePath), { recursive: true });
414
- const materialized = materializeAgentSessionContent(action.agent, bytes, {
415
- scope: action.scope,
416
- currentProjectRootPath,
417
- writePath,
418
- });
419
- writeFileSync(writePath, materialized);
420
- if (action.version) {
421
- ackSessions.push({
422
- agent: action.agent,
423
- file_path: action.file_path,
424
- scope: action.scope,
425
- content_hash: computeSessionSyncHash(action.agent, action.scope, materialized, currentProjectRootPath),
426
- version: action.version,
427
- local_path: writePath,
428
- });
429
- }
430
- console.log(chalk.cyan(` ↓ ${action.file_path}`), chalk.gray(isNewLocally ? "restored" : "pulling (cloud newer)"));
431
- pulled++;
432
- }
433
- catch (err) {
434
- if (err instanceof SessionOversizeError) {
435
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
436
- skipped++;
437
- }
438
- else {
439
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
440
- errors++;
441
- }
442
- }
443
- continue;
444
- }
445
- if (action.action === "delete_local") {
446
- if (isLegacyGlobalSessionShadowed(action, projectScopedKeys)) {
447
- if (action.version) {
448
- ackSessions.push({
449
- agent: action.agent,
450
- file_path: action.file_path,
451
- scope: action.scope,
452
- version: action.version,
453
- deleted: true,
454
- });
455
- }
456
- console.log(chalk.gray(` = ${action.file_path}`), chalk.gray("legacy global duplicate removed"));
457
- reconciled++;
458
- continue;
459
- }
460
- if (!options.pull) {
461
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
462
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("cloud deleted this session artifact — skipped in non-interactive mode"));
463
- skipped++;
464
- continue;
465
- }
466
- const resolution = await promptSessionCloudDeletion(action.file_path);
467
- if (resolution === "skip") {
468
- console.log(chalk.gray(` - ${action.file_path}`), chalk.gray("skipped"));
469
- skipped++;
470
- continue;
471
- }
472
- if (resolution === "local") {
473
- const local = localByKey.get(key);
474
- if (!local) {
475
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("local file missing — skipped"));
476
- skipped++;
477
- continue;
478
- }
479
- try {
480
- const fileRef = await uploadLocalFile(local.loc.path, local.content);
481
- await getClient().agentSessions.upsert({
482
- agent: action.agent,
483
- file_path: action.file_path,
484
- scope: action.scope,
485
- session_type: local.loc.sessionType,
486
- content_hash: local.hash,
487
- device_id: deviceID,
488
- local_path: local.loc.path,
489
- file_ref: fileRef,
490
- });
491
- console.log(chalk.green(` ↑ ${action.file_path}`), chalk.gray("kept local and restored to cloud"));
492
- pushed++;
493
- }
494
- catch (err) {
495
- if (err instanceof SessionOversizeError) {
496
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
497
- skipped++;
498
- }
499
- else {
500
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
501
- errors++;
502
- }
503
- }
504
- continue;
505
- }
506
- }
507
- try {
508
- const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
509
- if (writePath && existsSync(writePath)) {
510
- moveFileToTrash(writePath, "agent-sessions");
511
- }
512
- if (action.version) {
513
- ackSessions.push({
514
- agent: action.agent,
515
- file_path: action.file_path,
516
- scope: action.scope,
517
- version: action.version,
518
- local_path: writePath ?? undefined,
519
- deleted: true,
520
- });
521
- }
522
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("deleted locally (moved to Memax trash)"));
523
- deletedLocal++;
524
- }
525
- catch (err) {
526
- if (err instanceof SessionOversizeError) {
527
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
528
- skipped++;
529
- }
530
- else {
531
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
532
- errors++;
533
- }
534
- }
535
- continue;
536
- }
537
- if (action.action === "tombstone_diverged") {
538
- // Cloud session is deleted; local copy has content that has changed
539
- // since the last ack. There is no live cloud branch to snapshot —
540
- // resolution is either re-create the cloud session from local, or
541
- // accept the deletion and remove the local file.
542
- let resolution;
543
- if (options.push) {
544
- resolution = "keep_local";
545
- }
546
- else if (options.pull) {
547
- resolution = "keep_cloud";
548
- }
549
- else if (!process.stdin.isTTY || !process.stdout.isTTY) {
550
- resolution = "keep_local";
551
- }
552
- else {
553
- const answer = await promptSessionCloudDeletion(action.file_path);
554
- resolution =
555
- answer === "local"
556
- ? "keep_local"
557
- : answer === "delete"
558
- ? "keep_cloud"
559
- : "skip";
560
- }
561
- if (resolution === "skip") {
562
- console.log(chalk.gray(` - ${action.file_path}`), chalk.gray("skipped"));
563
- skipped++;
564
- continue;
565
- }
566
- if (resolution === "keep_local") {
567
- const local = localByKey.get(key);
568
- if (!local) {
569
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("local file missing — skipped"));
570
- skipped++;
571
- continue;
572
- }
573
- try {
574
- const fileRef = await uploadLocalFile(local.loc.path, local.content);
575
- await getClient().agentSessions.upsert({
576
- agent: action.agent,
577
- file_path: action.file_path,
578
- scope: action.scope,
579
- session_type: local.loc.sessionType,
580
- content_hash: local.hash,
581
- device_id: deviceID,
582
- local_path: local.loc.path,
583
- file_ref: fileRef,
584
- });
585
- console.log(chalk.green(` ↑ ${action.file_path}`), chalk.gray("kept local, re-created on cloud"));
586
- pushed++;
587
- }
588
- catch (err) {
589
- if (err instanceof SessionOversizeError) {
590
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
591
- skipped++;
592
- }
593
- else {
594
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
595
- errors++;
596
- }
597
- }
598
- continue;
599
- }
600
- try {
601
- const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
602
- if (writePath && existsSync(writePath)) {
603
- moveFileToTrash(writePath, "agent-sessions");
604
- }
605
- if (action.version) {
606
- ackSessions.push({
607
- agent: action.agent,
608
- file_path: action.file_path,
609
- scope: action.scope,
610
- version: action.version,
611
- local_path: writePath ?? undefined,
612
- deleted: true,
613
- });
614
- }
615
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("accepted cloud deletion, removed local"));
616
- deletedLocal++;
617
- }
618
- catch (err) {
619
- if (err instanceof SessionOversizeError) {
620
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
621
- skipped++;
622
- }
623
- else {
624
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
625
- errors++;
626
- }
627
- }
628
- continue;
629
- }
630
- if (action.action !== "diverged") {
631
- // Unknown action type — skip defensively so we never misroute.
632
- console.log(chalk.yellow(` ? ${action.file_path}`), chalk.gray(`unknown action: ${action.action}`));
633
- skipped++;
634
- continue;
635
- }
636
- // Live-session divergence: both branches have real content. Use
637
- // resolveDivergence() to atomically snapshot the loser.
638
- // --push → keep_local, --pull → keep_cloud,
639
- // non-interactive default → keep_local, interactive → prompt user.
640
- let divergeResolution;
641
- if (options.push) {
642
- divergeResolution = "keep_local";
643
- }
644
- else if (options.pull) {
645
- divergeResolution = "keep_cloud";
646
- }
647
- else if (!process.stdin.isTTY || !process.stdout.isTTY) {
648
- divergeResolution = "keep_local";
649
- }
650
- else {
651
- const answer = await promptSessionConflict(action.file_path);
652
- divergeResolution =
653
- answer === "local"
654
- ? "keep_local"
655
- : answer === "cloud"
656
- ? "keep_cloud"
657
- : "skip";
658
- }
659
- if (divergeResolution === "skip") {
660
- skipped++;
661
- continue;
662
- }
663
- if (!action.session_id || !action.cloud_hash) {
664
- // Defensive: true diverged actions always include session_id + cloud_hash.
665
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray("missing cloud reference for divergence resolution"));
666
- errors++;
667
- continue;
668
- }
669
- // Upload local content — needed for both keep_local (to apply) and
670
- // keep_cloud (to snapshot the local branch).
671
- const local = localByKey.get(key);
672
- if (!local) {
673
- skipped++;
674
- continue;
675
- }
676
- try {
677
- const fileRef = await uploadLocalFile(local.loc.path, local.content);
678
- const result = await getClient().agentSessions.resolveDivergence({
679
- agent: action.agent,
680
- file_path: action.file_path,
681
- scope: action.scope,
682
- device_id: deviceID,
683
- local_file_ref: fileRef,
684
- local_content_hash: local.hash,
685
- expected_cloud_version: action.cloud_version ?? action.version ?? 0,
686
- expected_cloud_hash: action.cloud_hash,
687
- resolution: divergeResolution,
688
- });
689
- if (result.winner === "local") {
690
- ackSessions.push({
691
- agent: action.agent,
692
- file_path: action.file_path,
693
- scope: action.scope,
694
- content_hash: local.hash,
695
- version: result.new_version,
696
- local_path: local.loc.path,
697
- });
698
- console.log(chalk.green(` ↑ ${action.file_path}`), chalk.gray("kept local, cloud branch archived"));
699
- console.log(chalk.gray(` Recover: memax agents sessions snapshots --session ${action.session_id}`));
700
- pushed++;
701
- }
702
- else {
703
- const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
704
- if (writePath) {
705
- const bytes = await downloadAgentSession(action.session_id);
706
- mkdirSync(dirname(writePath), { recursive: true });
707
- const materialized = materializeAgentSessionContent(action.agent, bytes, {
708
- scope: action.scope,
709
- currentProjectRootPath,
710
- writePath,
711
- });
712
- writeFileSync(writePath, materialized);
713
- ackSessions.push({
714
- agent: action.agent,
715
- file_path: action.file_path,
716
- scope: action.scope,
717
- content_hash: computeSessionSyncHash(action.agent, action.scope, materialized, currentProjectRootPath),
718
- version: result.new_version,
719
- local_path: writePath,
720
- });
721
- }
722
- console.log(chalk.cyan(` ↓ ${action.file_path}`), chalk.gray("kept cloud, local branch archived"));
723
- console.log(chalk.gray(` Recover: memax agents sessions snapshots --session ${action.session_id}`));
724
- pulled++;
725
- }
726
- }
727
- catch (err) {
728
- if (err instanceof SessionOversizeError) {
729
- console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray(`${err.message}; skipping (run \`memax agents sessions delete\` to drop it)`));
730
- skipped++;
731
- }
732
- else {
733
- console.log(chalk.red(` ✗ ${action.file_path}`), chalk.gray(err.message));
734
- errors++;
735
- }
736
- }
737
- }
738
- }
739
- if (ackSessions.length > 0) {
740
- try {
741
- await getClient().agentSessions.ack({
742
- device_id: deviceID,
743
- sessions: ackSessions,
744
- });
745
- }
746
- catch (err) {
747
- console.log(chalk.yellow("\n Warning: failed to persist session sync state"), chalk.gray(err.message));
748
- }
749
- }
750
- const parts = [];
751
- if (pushed > 0)
752
- parts.push(`${pushed} pushed`);
753
- if (pulled > 0)
754
- parts.push(`${pulled} restored`);
755
- if (deletedLocal > 0)
756
- parts.push(`${deletedLocal} deleted locally`);
757
- if (reconciled > 0)
758
- parts.push(`${reconciled} reconciled`);
759
- if (unchanged > 0)
760
- parts.push(`${unchanged} unchanged`);
761
- if (skipped > 0)
762
- parts.push(`${skipped} skipped`);
763
- if (errors > 0)
764
- parts.push(`${errors} errors`);
765
- if (parts.length === 0) {
766
- console.log(chalk.gray(" No session artifacts discovered.\n"));
767
- return;
768
- }
769
- console.log(chalk.bold(`\n Done: ${parts.join(", ")}`));
770
- console.log(chalk.gray(" Session sync preserves raw artifacts. Knowledge extraction remains a separate workflow.\n"));
771
- }
772
- export async function listAgentSessionsCommand() {
773
- try {
774
- const result = await getClient().agentSessions.list();
775
- const sessions = result.sessions;
776
- if (sessions.length === 0) {
777
- console.log(chalk.yellow(" No synced session artifacts.\n"));
778
- return;
779
- }
780
- console.log();
781
- for (const session of sessions) {
782
- const scopeTag = session.scope === "global"
783
- ? chalk.dim("global")
784
- : chalk.dim(session.scope.replace(/^project:/, ""));
785
- console.log(` ${chalk.cyan(formatAgentName(session.agent))} ${session.file_path} ${scopeTag} ${chalk.dim(formatBytes(session.size_bytes))}`);
786
- }
787
- console.log();
788
- }
789
- catch (err) {
790
- console.error(chalk.red(` Failed to fetch session artifacts: ${err.message}\n`));
791
- }
792
- }
793
- export async function listDeletedAgentSessionsCommand() {
794
- try {
795
- const result = await getClient().agentSessions.listDeleted();
796
- const sessions = result.sessions;
797
- if (sessions.length === 0) {
798
- console.log(chalk.gray(" No recoverable deleted session artifacts.\n"));
799
- return;
800
- }
801
- console.log(chalk.bold("\n Recoverable Deleted Session Artifacts\n"));
802
- for (const [index, session] of sessions.entries()) {
803
- const scopeTag = session.scope === "global"
804
- ? chalk.dim("global")
805
- : chalk.dim(session.scope.replace(/^project:/, ""));
806
- console.log(` ${chalk.bold(String(index + 1).padStart(2, " "))}. ${chalk.cyan(formatAgentName(session.agent))} ${session.file_path} ${scopeTag}`);
807
- console.log(chalk.gray(` deleted ${formatAge(session.deleted_at)} · recoverable until ${session.content_expires_at ? new Date(session.content_expires_at).toLocaleString() : "expired"}`));
808
- }
809
- console.log();
810
- }
811
- catch (err) {
812
- console.error(chalk.red(` Failed to fetch deleted session artifacts: ${err.message}\n`));
813
- }
814
- }
815
- export async function restoreDeletedAgentSessionsCommand() {
816
- let deleted;
817
- try {
818
- deleted = await getClient().agentSessions.listDeleted();
819
- }
820
- catch (err) {
821
- console.error(chalk.red(` Failed to fetch deleted session artifacts: ${err.message}\n`));
822
- return;
823
- }
824
- if (deleted.sessions.length === 0) {
825
- console.log(chalk.gray(" No recoverable deleted session artifacts.\n"));
826
- return;
827
- }
828
- console.log(chalk.bold("\n Recover Deleted Session Artifacts\n"));
829
- deleted.sessions.forEach((session, index) => {
830
- const scopeTag = session.scope === "global"
831
- ? chalk.dim("global")
832
- : chalk.dim(session.scope.replace(/^project:/, ""));
833
- console.log(` ${chalk.dim(`${index + 1}.`)} ${chalk.cyan(formatAgentName(session.agent))} ${session.file_path} ${scopeTag}`);
834
- });
835
- console.log();
836
- const answer = await ask(" Select session artifacts to restore (comma-separated numbers, or 'q' to quit): ");
837
- if (!answer || answer.trim().toLowerCase() === "q") {
838
- console.log(chalk.gray(" Cancelled.\n"));
839
- return;
840
- }
841
- const indexes = answer
842
- .split(",")
843
- .map((part) => Number.parseInt(part.trim(), 10))
844
- .filter((idx) => Number.isInteger(idx) && idx >= 1 && idx <= deleted.sessions.length);
845
- if (indexes.length === 0) {
846
- console.log(chalk.gray(" No valid selection.\n"));
847
- return;
848
- }
849
- const cwd = process.cwd();
850
- const deviceID = getOrCreateDeviceID();
851
- const currentProjectScope = getProjectScope(cwd);
852
- const currentProjectRootPath = resolveProjectRootPath(cwd) ?? cwd;
853
- let restored = 0;
854
- for (const index of indexes) {
855
- const sessionInfo = deleted.sessions[index - 1];
856
- try {
857
- const writePath = resolveAgentSessionWritePath(sessionInfo.agent, sessionInfo.file_path, sessionInfo.scope, {
858
- cwd,
859
- home: homedir(),
860
- currentProjectScope,
861
- });
862
- const session = await getClient().agentSessions.restore({
863
- agent: sessionInfo.agent,
864
- file_path: sessionInfo.file_path,
865
- scope: sessionInfo.scope,
866
- device_id: deviceID,
867
- local_path: writePath ?? undefined,
868
- });
869
- if (writePath && !existsSync(writePath)) {
870
- const bytes = await downloadAgentSession(session.id);
871
- mkdirSync(dirname(writePath), { recursive: true });
872
- const materialized = materializeAgentSessionContent(session.agent, bytes, {
873
- scope: session.scope,
874
- currentProjectRootPath,
875
- writePath,
876
- });
877
- writeFileSync(writePath, materialized);
878
- await getClient().agentSessions.ack({
879
- device_id: deviceID,
880
- sessions: [
881
- {
882
- agent: session.agent,
883
- file_path: session.file_path,
884
- scope: session.scope,
885
- content_hash: computeSessionSyncHash(session.agent, session.scope, materialized, currentProjectRootPath),
886
- version: session.version,
887
- local_path: writePath,
888
- },
889
- ],
890
- });
891
- console.log(chalk.green(` ✓ ${session.file_path}`), chalk.gray("restored to cloud and local machine"));
892
- }
893
- else if (writePath && existsSync(writePath)) {
894
- console.log(chalk.yellow(` - ${session.file_path}`), chalk.gray("restored to cloud; local file already exists"));
895
- }
896
- else {
897
- console.log(chalk.yellow(` - ${session.file_path}`), chalk.gray("restored to cloud; no safe local path on this machine"));
898
- }
899
- restored++;
900
- }
901
- catch (err) {
902
- console.log(chalk.red(` ✗ ${sessionInfo.file_path}`), chalk.gray(err.message));
903
- }
904
- }
905
- console.log(chalk.gray(`\n ${restored} session artifact${restored === 1 ? "" : "s"} restored.\n`));
906
- }
907
- export async function deleteAgentSessionsCommand() {
908
- let sessions;
909
- try {
910
- sessions = (await getClient().agentSessions.list()).sessions;
911
- }
912
- catch (err) {
913
- console.error(chalk.red(` Failed to fetch session artifacts: ${err.message}\n`));
914
- return;
915
- }
916
- if (sessions.length === 0) {
917
- console.log(chalk.yellow(" No synced session artifacts to delete.\n"));
918
- return;
919
- }
920
- sessions.forEach((session, index) => {
921
- const scopeTag = session.scope === "global"
922
- ? chalk.dim("global")
923
- : chalk.dim(session.scope.replace(/^project:/, ""));
924
- console.log(` ${chalk.dim(`${index + 1}.`)} ${chalk.cyan(formatAgentName(session.agent))} ${session.file_path} ${scopeTag}`);
925
- });
926
- console.log();
927
- const answer = await ask(" Select session artifacts to delete (comma-separated numbers, or 'q' to quit): ");
928
- if (!answer || answer.trim().toLowerCase() === "q") {
929
- console.log(chalk.gray(" Cancelled.\n"));
930
- return;
931
- }
932
- const indices = answer
933
- .split(",")
934
- .map((value) => Number.parseInt(value.trim(), 10))
935
- .filter((value) => Number.isFinite(value) && value >= 1 && value <= sessions.length);
936
- if (indices.length === 0) {
937
- console.log(chalk.gray(" No valid selections.\n"));
938
- return;
939
- }
940
- const mode = (await ask(" Delete from [l] this device only, [e] everywhere, or [s] skip? "))
941
- .trim()
942
- .toLowerCase();
943
- if (mode !== "l" && mode !== "e") {
944
- console.log(chalk.gray(" Cancelled.\n"));
945
- return;
946
- }
947
- const deviceID = getOrCreateDeviceID();
948
- const cwd = process.cwd();
949
- const currentProjectScope = getProjectScope(cwd);
950
- for (const index of indices) {
951
- const session = sessions[index - 1];
952
- const localPath = resolveAgentSessionWritePath(session.agent, session.file_path, session.scope, { cwd, home: homedir(), currentProjectScope });
953
- try {
954
- if (mode === "l") {
955
- await getClient().agentSessions.localDelete({
956
- device_id: deviceID,
957
- agent: session.agent,
958
- file_path: session.file_path,
959
- scope: session.scope,
960
- local_path: localPath ?? undefined,
961
- });
962
- if (localPath && existsSync(localPath)) {
963
- moveFileToTrash(localPath, "agent-sessions");
964
- }
965
- }
966
- else {
967
- await getClient().agentSessions.delete(session.id);
968
- if (localPath && existsSync(localPath)) {
969
- moveFileToTrash(localPath, "agent-sessions");
970
- }
971
- await getClient().agentSessions.ack({
972
- device_id: deviceID,
973
- sessions: [
974
- {
975
- agent: session.agent,
976
- file_path: session.file_path,
977
- scope: session.scope,
978
- version: session.version + 1,
979
- local_path: localPath ?? undefined,
980
- deleted: true,
981
- },
982
- ],
983
- });
984
- }
985
- console.log(chalk.green(` ✓ ${session.file_path}`), chalk.gray(mode === "e" ? "deleted everywhere" : "removed from this device"));
986
- }
987
- catch (err) {
988
- console.log(chalk.red(` ✗ ${session.file_path}`), chalk.gray(err.message));
989
- }
990
- }
991
- console.log();
992
- }
993
- export async function cleanupAgentSessionsCommand(options) {
994
- let sessions;
995
- try {
996
- sessions = (await getClient().agentSessions.list()).sessions;
997
- }
998
- catch (err) {
999
- console.error(chalk.red(` Failed to fetch session artifacts: ${err.message}\n`));
1000
- return;
1001
- }
1002
- const pairs = findShadowedGlobalSessions(sessions);
1003
- if (pairs.length === 0) {
1004
- console.log(chalk.gray(" No legacy global session duplicates found.\n"));
1005
- return;
1006
- }
1007
- const safePairs = [];
1008
- const divergedPairs = [];
1009
- for (const pair of pairs) {
1010
- if (pair.global.content_hash === pair.project.content_hash) {
1011
- safePairs.push(pair);
1012
- continue;
1013
- }
1014
- try {
1015
- const [globalBytes, projectBytes] = await Promise.all([
1016
- downloadAgentSession(pair.global.id),
1017
- downloadAgentSession(pair.project.id),
1018
- ]);
1019
- if (computeDownloadedPortableHash(pair.global.agent, globalBytes) ===
1020
- computeDownloadedPortableHash(pair.project.agent, projectBytes)) {
1021
- safePairs.push(pair);
1022
- }
1023
- else {
1024
- divergedPairs.push(pair);
1025
- }
1026
- }
1027
- catch {
1028
- divergedPairs.push(pair);
1029
- }
1030
- }
1031
- console.log(chalk.bold("\n Session Duplicate Cleanup\n"));
1032
- if (safePairs.length > 0) {
1033
- console.log(chalk.white(" Safe To Remove"));
1034
- for (const pair of safePairs) {
1035
- console.log(` ${chalk.cyan(formatAgentName(pair.global.agent))} ${pair.global.file_path}`);
1036
- console.log(` ${chalk.gray("delete legacy global copy; project-scoped copy remains")}`);
1037
- }
1038
- console.log();
1039
- }
1040
- if (divergedPairs.length > 0) {
1041
- console.log(chalk.yellow(" Needs Manual Review"));
1042
- for (const pair of divergedPairs) {
1043
- console.log(` ${chalk.yellow(formatAgentName(pair.global.agent))} ${pair.global.file_path}`);
1044
- console.log(` ${chalk.gray(`global hash ${pair.global.content_hash.slice(0, 8)}… differs from project ${pair.project.scope.replace(/^project:/, "")} hash ${pair.project.content_hash.slice(0, 8)}…`)}`);
1045
- }
1046
- console.log();
1047
- }
1048
- if (safePairs.length === 0) {
1049
- console.log(chalk.gray(" No identical legacy global duplicates can be removed safely.\n"));
1050
- return;
1051
- }
1052
- if (!options?.yes) {
1053
- const proceed = await confirmDefault(` Delete ${safePairs.length} safe global duplicate${safePairs.length === 1 ? "" : "s"} from cloud? [Y/n] `);
1054
- if (!proceed) {
1055
- console.log(chalk.gray(" Cancelled.\n"));
1056
- return;
1057
- }
1058
- }
1059
- let deleted = 0;
1060
- let errors = 0;
1061
- for (const pair of safePairs) {
1062
- try {
1063
- await getClient().agentSessions.delete(pair.global.id);
1064
- console.log(chalk.green(` ✓ ${pair.global.file_path}`), chalk.gray("deleted legacy global copy"));
1065
- deleted++;
1066
- }
1067
- catch (err) {
1068
- console.log(chalk.red(` ✗ ${pair.global.file_path}`), chalk.gray(err.message));
1069
- errors++;
1070
- }
1071
- }
1072
- const summary = [];
1073
- if (deleted > 0)
1074
- summary.push(`${deleted} deleted`);
1075
- if (divergedPairs.length > 0)
1076
- summary.push(`${divergedPairs.length} need review`);
1077
- if (errors > 0)
1078
- summary.push(`${errors} errors`);
1079
- console.log(chalk.bold(`\n Done: ${summary.join(", ")}\n`));
1080
- }
1081
- export async function doctorAgentSessionsCommand() {
1082
- const cwd = process.cwd();
1083
- const project = resolveProjectScope(cwd);
1084
- const deviceID = getOrCreateDeviceID();
1085
- const locations = discoverAgentSessions();
1086
- const localByKey = new Map();
1087
- for (const loc of locations) {
1088
- if (!existsSync(loc.path))
1089
- continue;
1090
- localByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
1091
- }
1092
- console.log(chalk.bold("\n Memax Agent Session Doctor\n"));
1093
- console.log(` Device ${chalk.bold(deviceID)}`);
1094
- console.log(` CWD ${chalk.gray(cwd)}`);
1095
- console.log(` Scope ${chalk.bold(project.scope)}`);
1096
- if (project.warning) {
1097
- console.log(` Warning ${chalk.yellow(project.warning)}`);
1098
- }
1099
- console.log();
1100
- console.log(chalk.white(" Local Discovery"));
1101
- if (localByKey.size === 0) {
1102
- console.log(` ${chalk.gray("No supported local session artifacts discovered.")}`);
1103
- }
1104
- else {
1105
- for (const loc of localByKey.values()) {
1106
- console.log(` ${chalk.cyan(formatAgentName(loc.agent))} ${loc.filePath} ${chalk.gray(loc.scope)} ${chalk.gray(loc.path)}`);
1107
- }
1108
- }
1109
- console.log();
1110
- try {
1111
- const cloud = await getClient().agentSessions.list();
1112
- const placements = cloud.sessions.map((session) => ({
1113
- session,
1114
- placement: classifyAgentSessionPlacement(session.agent, session.file_path, session.scope, {
1115
- cwd,
1116
- home: homedir(),
1117
- currentProjectScope: project.scope,
1118
- localByKey,
1119
- }),
1120
- }));
1121
- printSessionPlacementSection(" Restorable Here", chalk.cyan, placements.filter((item) => item.placement.kind === "restorable"));
1122
- printSessionPlacementSection(" Different Project", chalk.yellow, placements.filter((item) => item.placement.kind === "different_project"));
1123
- printSessionPlacementSection(" Unresolved", chalk.magenta, placements.filter((item) => item.placement.kind === "unresolved"));
1124
- console.log(chalk.gray(" Session sync restores only when placement is safe. Ambiguous session stores are skipped.\n"));
1125
- }
1126
- catch (err) {
1127
- console.error(chalk.red(` Failed to fetch cloud session artifacts: ${err.message}\n`));
1128
- }
1129
- }
1130
- export function registerAgentSessionCommands(agentsCmd) {
1131
- const agentSessionsCmd = agentsCmd
1132
- .command("sessions")
1133
- .description("Manage synced agent session artifacts");
1134
- agentSessionsCmd
1135
- .command("sync")
1136
- .description("Sync agent session artifacts bidirectionally with Memax cloud")
1137
- .option("--push", "Force push local session artifacts to cloud (overwrite)")
1138
- .option("--pull", "Force pull cloud session artifacts to local (overwrite)")
1139
- .action(syncAgentSessionsCommand);
1140
- agentSessionsCmd
1141
- .command("list")
1142
- .description("List synced agent session artifacts in the cloud")
1143
- .action(listAgentSessionsCommand);
1144
- agentSessionsCmd
1145
- .command("deleted")
1146
- .description("List recoverable deleted session artifacts retained in cloud")
1147
- .action(listDeletedAgentSessionsCommand);
1148
- agentSessionsCmd
1149
- .command("restore")
1150
- .description("Restore deleted session artifacts retained in cloud")
1151
- .action(restoreDeletedAgentSessionsCommand);
1152
- agentSessionsCmd
1153
- .command("delete")
1154
- .description("Interactively select and delete synced session artifacts")
1155
- .action(deleteAgentSessionsCommand);
1156
- agentSessionsCmd
1157
- .command("cleanup")
1158
- .description("Remove safe legacy global session duplicates from cloud")
1159
- .option("-y, --yes", "Skip confirmation")
1160
- .action(cleanupAgentSessionsCommand);
1161
- agentSessionsCmd
1162
- .command("doctor")
1163
- .description("Explain session sync identity, discovery, and safe restore behavior on this machine")
1164
- .action(doctorAgentSessionsCommand);
1165
- }
1166
- function discoverAgentSessions() {
1167
- const home = homedir();
1168
- const config = loadConfig();
1169
- const locations = [];
1170
- const add = (agent, path, filePath, scope, sessionType, projectRoot) => {
1171
- locations.push({
1172
- agent,
1173
- path,
1174
- filePath: normalizeFilePath(filePath),
1175
- scope,
1176
- sessionType,
1177
- projectRoot,
1178
- });
1179
- };
1180
- const claudeProjectsDir = join(home, ".claude", "projects");
1181
- if (existsSync(claudeProjectsDir)) {
1182
- for (const project of safeListDir(claudeProjectsDir)) {
1183
- const projectRoot = resolveClaudeProjectPath(project);
1184
- const repoUrl = resolveClaudeProjectFolder(project);
1185
- if (!repoUrl || !projectRoot)
1186
- continue;
1187
- const projectDir = join(claudeProjectsDir, project);
1188
- for (const file of safeListDir(projectDir)) {
1189
- if (!file.endsWith(".jsonl"))
1190
- continue;
1191
- add("claude-code", join(projectDir, file), `sessions/${file}`, `project:${repoUrl}`, "transcript", projectRoot);
1192
- }
1193
- }
1194
- }
1195
- const codexHistory = join(home, ".codex", "history.jsonl");
1196
- add("codex", codexHistory, "history.jsonl", "global", "history");
1197
- const codexSessionsRoot = join(home, ".codex", "sessions");
1198
- if (existsSync(codexSessionsRoot)) {
1199
- for (const file of walkFiles(codexSessionsRoot, (entry) => entry.endsWith(".jsonl"))) {
1200
- const ctx = computeSessionProjectContext("codex", file);
1201
- add("codex", file, join("sessions", relative(codexSessionsRoot, file)), ctx.scope, "transcript", ctx.projectRoot);
1202
- }
1203
- }
1204
- const geminiTmpRoot = join(home, ".gemini", "tmp");
1205
- if (existsSync(geminiTmpRoot)) {
1206
- for (const projectDirName of safeListDir(geminiTmpRoot)) {
1207
- const projectDir = join(geminiTmpRoot, projectDirName);
1208
- const projectRootPath = readProjectRootMarker(join(projectDir, ".project_root"));
1209
- if (!projectRootPath)
1210
- continue;
1211
- const resolvedProjectRoot = resolveProjectRootPath(projectRootPath) ?? projectRootPath;
1212
- const scope = getProjectScope(resolvedProjectRoot);
1213
- if (scope === "project" || !scope.startsWith("project:"))
1214
- continue;
1215
- const canonicalScope = scope;
1216
- const chatsDir = join(projectDir, "chats");
1217
- if (!existsSync(chatsDir))
1218
- continue;
1219
- for (const file of safeListDir(chatsDir)) {
1220
- if (!file.endsWith(".json"))
1221
- continue;
1222
- add("gemini", join(chatsDir, file), `chats/${file}`, canonicalScope, "session", resolvedProjectRoot);
1223
- }
1224
- }
1225
- }
1226
- for (const root of config.agent_session_roots ?? []) {
1227
- const normalizedScope = (root.scope || "").trim();
1228
- if (!isScopeValue(normalizedScope))
1229
- continue;
1230
- const rootPath = root.root_path ? resolveHome(root.root_path) : "";
1231
- if (!rootPath || !existsSync(rootPath))
1232
- continue;
1233
- const includeExtensions = root.include_extensions && root.include_extensions.length > 0
1234
- ? new Set(root.include_extensions.map((value) => value.toLowerCase()))
1235
- : new Set([".jsonl", ".json", ".md", ".txt"]);
1236
- const sessionType = root.session_type?.trim() || "artifact";
1237
- for (const file of walkFiles(rootPath, (entry) => includeExtensions.has(extension(entry)))) {
1238
- add(root.agent, file, relative(rootPath, file), normalizedScope, sessionType);
1239
- }
1240
- }
1241
- return locations;
1242
- }
1243
- function findGeminiProjectDir(home, scope) {
1244
- const geminiTmpRoot = join(home, ".gemini", "tmp");
1245
- if (!existsSync(geminiTmpRoot))
1246
- return null;
1247
- for (const projectDirName of safeListDir(geminiTmpRoot)) {
1248
- const projectDir = join(geminiTmpRoot, projectDirName);
1249
- const projectRootPath = readProjectRootMarker(join(projectDir, ".project_root"));
1250
- if (!projectRootPath)
1251
- continue;
1252
- if (getProjectScope(projectRootPath) === scope) {
1253
- return projectDir;
1254
- }
1255
- }
1256
- return null;
1257
- }
1258
- export function resolveAgentSessionWritePath(agent, filePath, scope, options = {}) {
1259
- const cwd = options.cwd ?? process.cwd();
1260
- const home = options.home ?? homedir();
1261
- const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
1262
- const currentProjectRootPath = options.currentProjectRootPath ?? resolveProjectRootPath(cwd) ?? cwd;
1263
- const normalized = normalizeFilePath(filePath);
1264
- if (scope === "global") {
1265
- switch (agent) {
1266
- case "codex":
1267
- if (normalized === "history.jsonl") {
1268
- return join(home, ".codex", "history.jsonl");
1269
- }
1270
- if (normalized.startsWith("sessions/")) {
1271
- return join(home, ".codex", normalized);
1272
- }
1273
- return null;
1274
- default:
1275
- return null;
1276
- }
1277
- }
1278
- if (!scope.startsWith("project:") || scope !== currentProjectScope) {
1279
- return null;
1280
- }
1281
- switch (agent) {
1282
- case "codex":
1283
- if (normalized.startsWith("sessions/")) {
1284
- return join(home, ".codex", normalized);
1285
- }
1286
- return null;
1287
- case "claude-code": {
1288
- if (!normalized.startsWith("sessions/"))
1289
- return null;
1290
- const claudeProjectsDir = join(home, ".claude", "projects");
1291
- for (const project of safeListDir(claudeProjectsDir)) {
1292
- const repoUrl = resolveClaudeProjectFolder(project);
1293
- if (repoUrl && `project:${repoUrl}` === scope) {
1294
- return join(claudeProjectsDir, project, normalized.replace(/^sessions\//, ""));
1295
- }
1296
- }
1297
- return join(claudeProjectsDir, encodeClaudeProjectDir(currentProjectRootPath), normalized.replace(/^sessions\//, ""));
1298
- }
1299
- case "gemini": {
1300
- if (!normalized.startsWith("chats/"))
1301
- return null;
1302
- const projectDir = ensureGeminiProjectDir(home, currentProjectRootPath, scope);
1303
- return join(projectDir, normalized);
1304
- }
1305
- default:
1306
- return null;
1307
- }
1308
- }
1309
- export function classifyAgentSessionPlacement(agent, filePath, scope, options = {}) {
1310
- const key = `${agent}|${normalizeFilePath(filePath)}|${scope}`;
1311
- const existing = options.localByKey?.get(key);
1312
- if (existing) {
1313
- return { kind: "present", path: existing.path, reason: "present locally" };
1314
- }
1315
- const cwd = options.cwd ?? process.cwd();
1316
- const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
1317
- if (scope.startsWith("project:") && scope !== currentProjectScope) {
1318
- return {
1319
- kind: "different_project",
1320
- reason: `belongs to ${scope.replace(/^project:/, "")}`,
1321
- };
1322
- }
1323
- const path = resolveAgentSessionWritePath(agent, filePath, scope, options);
1324
- if (path) {
1325
- return { kind: "restorable", path, reason: "safe restore path available" };
1326
- }
1327
- return {
1328
- kind: "unresolved",
1329
- reason: "no safe restore path on this machine",
1330
- };
1331
- }
1332
- // Must match the public API upload limit. Duplicated here so the CLI can
1333
- // fail-fast with a clear message instead of paying a round-trip to discover
1334
- // the server's 413.
1335
- export const AGENT_SESSION_MAX_BYTES = 200 * 1024 * 1024;
1336
- /**
1337
- * Thrown by uploadLocalFile when a session artifact exceeds the flat
1338
- * agent-session cap. Callers catch this specifically and treat it as a
1339
- * soft-skip (warn and continue) rather than a hard error, so one oversize
1340
- * transcript doesn't block sync of every other file.
1341
- */
1342
- export class SessionOversizeError extends Error {
1343
- sizeBytes;
1344
- maxBytes;
1345
- constructor(path, sizeBytes) {
1346
- super(`${path} is ${Math.round(sizeBytes / (1024 * 1024))} MB, exceeds the ${Math.round(AGENT_SESSION_MAX_BYTES / (1024 * 1024))} MB agent-session upload cap`);
1347
- this.name = "SessionOversizeError";
1348
- this.sizeBytes = sizeBytes;
1349
- this.maxBytes = AGENT_SESSION_MAX_BYTES;
1350
- }
1351
- }
1352
- async function uploadLocalFile(path, content) {
1353
- const stat = statSync(path);
1354
- if (stat.size > AGENT_SESSION_MAX_BYTES) {
1355
- throw new SessionOversizeError(path, stat.size);
1356
- }
1357
- const contentType = inferContentType(path);
1358
- const sha256 = createHash("sha256").update(content).digest("hex");
1359
- const intent = await getClient().uploads.create({
1360
- filename: basenameSafe(path),
1361
- content_type: contentType,
1362
- size_bytes: stat.size,
1363
- purpose: "agent_session",
1364
- });
1365
- await putUpload(intent, content);
1366
- return {
1367
- object_key: intent.object_key,
1368
- filename: basenameSafe(path),
1369
- content_type: contentType,
1370
- size_bytes: stat.size,
1371
- sha256,
1372
- };
1373
- }
1374
- async function putUpload(intent, content) {
1375
- const res = await fetch(intent.upload_url, {
1376
- method: "PUT",
1377
- headers: intent.headers,
1378
- body: content,
1379
- });
1380
- if (!res.ok) {
1381
- throw new Error(`upload failed with status ${res.status}`);
1382
- }
1383
- }
1384
- async function downloadAgentSession(id) {
1385
- const res = await getClient().agentSessions.downloadBlob(id);
1386
- if (!res.ok) {
1387
- throw new Error(`download failed with status ${res.status}`);
1388
- }
1389
- return Buffer.from(await res.arrayBuffer());
1390
- }
1391
- async function promptSessionConflict(filePath) {
1392
- const answer = await ask(` Conflict for ${filePath}. Use [l]ocal, [c]loud, or [s]kip? `);
1393
- const normalized = answer.trim().toLowerCase();
1394
- if (normalized === "l")
1395
- return "local";
1396
- if (normalized === "c")
1397
- return "cloud";
1398
- return "skip";
1399
- }
1400
- async function promptSessionCloudDeletion(filePath) {
1401
- const answer = await ask(` ${filePath} was deleted in cloud. [d]elete local, [k]eep local and restore cloud, or [s]kip? `);
1402
- const normalized = answer.trim().toLowerCase();
1403
- if (normalized === "d")
1404
- return "delete";
1405
- if (normalized === "k")
1406
- return "local";
1407
- return "skip";
1408
- }
1409
- function readProjectRootMarker(path) {
1410
- if (!existsSync(path))
1411
- return null;
1412
- try {
1413
- const value = readFileSync(path, "utf-8").trim();
1414
- return value || null;
1415
- }
1416
- catch {
1417
- return null;
1418
- }
1419
- }
1420
- function walkFiles(root, include) {
1421
- const files = [];
1422
- const visit = (dir) => {
1423
- for (const entry of safeListDir(dir)) {
1424
- const fullPath = join(dir, entry);
1425
- let stat;
1426
- try {
1427
- stat = statSync(fullPath);
1428
- }
1429
- catch {
1430
- continue;
1431
- }
1432
- if (stat.isDirectory()) {
1433
- visit(fullPath);
1434
- continue;
1435
- }
1436
- if (stat.isFile() && include(fullPath)) {
1437
- files.push(fullPath);
1438
- }
1439
- }
1440
- };
1441
- visit(root);
1442
- return files;
1443
- }
1444
- function safeListDir(dir) {
1445
- try {
1446
- return readdirSync(dir);
1447
- }
1448
- catch {
1449
- return [];
1450
- }
1451
- }
1452
- function printSessionPlacementSection(title, color, items) {
1453
- if (items.length === 0)
1454
- return;
1455
- console.log(chalk.white(title));
1456
- for (const item of items) {
1457
- console.log(` ${color(formatAgentName(item.session.agent))} ${item.session.file_path}`);
1458
- console.log(` ${chalk.gray(item.placement.reason)}`);
1459
- }
1460
- console.log();
1461
- }
1462
- function formatAgentName(agent) {
1463
- const labels = {
1464
- "claude-code": "Claude Code",
1465
- codex: "Codex",
1466
- gemini: "Gemini CLI",
1467
- openclaw: "OpenClaw",
1468
- opencode: "OpenCode",
1469
- };
1470
- return labels[agent] ?? agent;
1471
- }
1472
- function inferContentType(path) {
1473
- if (path.endsWith(".jsonl"))
1474
- return "application/x-ndjson";
1475
- if (path.endsWith(".json"))
1476
- return "application/json";
1477
- if (path.endsWith(".md"))
1478
- return "text/markdown";
1479
- return "text/plain";
1480
- }
1481
- function formatBytes(size) {
1482
- if (size < 1024)
1483
- return `${size} B`;
1484
- if (size < 1024 * 1024)
1485
- return `${(size / 1024).toFixed(1)} KB`;
1486
- return `${(size / (1024 * 1024)).toFixed(1)} MB`;
1487
- }
1488
- function formatAge(dateStr) {
1489
- const ms = Date.now() - new Date(dateStr).getTime();
1490
- const mins = Math.floor(ms / 60000);
1491
- if (mins < 60)
1492
- return `${mins}m ago`;
1493
- const hours = Math.floor(mins / 60);
1494
- if (hours < 24)
1495
- return `${hours}h ago`;
1496
- const days = Math.floor(hours / 24);
1497
- return `${days}d ago`;
1498
- }
1499
- function basenameSafe(path) {
1500
- return normalizeFilePath(path).split("/").pop() ?? "artifact";
1501
- }
1502
- function resolveHome(path) {
1503
- if (path.startsWith("~/")) {
1504
- return join(homedir(), path.slice(2));
1505
- }
1506
- return path;
1507
- }
1508
- function extension(path) {
1509
- const normalized = normalizeFilePath(path).toLowerCase();
1510
- const dot = normalized.lastIndexOf(".");
1511
- return dot >= 0 ? normalized.slice(dot) : "";
1512
- }
1513
- //# sourceMappingURL=agent-sessions.js.map