iivo-sub 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/iivo-sub.js +149 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ Backup configuration lets you choose Codex, Claude Code, Hermes, OpenClaw, or al
|
|
|
35
35
|
|
|
36
36
|
Chat record backup lets you back up common local conversation paths for Codex, Claude Code, Hermes, and OpenClaw. Restore creates a safety backup before replacing existing chat record paths.
|
|
37
37
|
|
|
38
|
+
For Codex, chat backup includes sessions, the recent-task index, history, attachments, shell snapshots, and local state files. Restoring a new backup keeps the original recent-task index; restoring an older Codex chat backup that only contains sessions will rebuild the recent-task index automatically.
|
|
39
|
+
|
|
38
40
|
After you enter an API key once, it is cached in `~/.iivo-sub/cache.json`. Next time the prompt shows a masked key such as `sk-x...abcd`; press Enter to reuse it or type a new key to replace it.
|
|
39
41
|
|
|
40
42
|
Existing config files are backed up under `~/.iivo-sub/backups/` before being replaced.
|
package/bin/iivo-sub.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'node:os'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import readline from 'node:readline'
|
|
7
7
|
|
|
8
|
-
const VERSION = '0.1.
|
|
8
|
+
const VERSION = '0.1.3'
|
|
9
9
|
const APP_DIR = path.join(os.homedir(), '.iivo-sub')
|
|
10
10
|
const CACHE_FILE = path.join(APP_DIR, 'cache.json')
|
|
11
11
|
const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
|
|
@@ -319,7 +319,11 @@ function chatTargets() {
|
|
|
319
319
|
return {
|
|
320
320
|
codex: [
|
|
321
321
|
path.join(os.homedir(), '.codex', 'sessions'),
|
|
322
|
-
path.join(os.homedir(), '.codex', '
|
|
322
|
+
path.join(os.homedir(), '.codex', 'session_index.jsonl'),
|
|
323
|
+
path.join(os.homedir(), '.codex', 'history.jsonl'),
|
|
324
|
+
path.join(os.homedir(), '.codex', 'attachments'),
|
|
325
|
+
path.join(os.homedir(), '.codex', 'shell_snapshots'),
|
|
326
|
+
...matchingHomeFiles('.codex', /^(?:state|goals|logs|memories)_\d+\.sqlite(?:-(?:shm|wal))?$/)
|
|
323
327
|
],
|
|
324
328
|
'claude-code': [
|
|
325
329
|
path.join(os.homedir(), '.claude', 'projects'),
|
|
@@ -339,6 +343,14 @@ function chatTargets() {
|
|
|
339
343
|
}
|
|
340
344
|
}
|
|
341
345
|
|
|
346
|
+
function matchingHomeFiles(relativeDir, pattern) {
|
|
347
|
+
const dir = path.join(os.homedir(), relativeDir)
|
|
348
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []
|
|
349
|
+
return fs.readdirSync(dir)
|
|
350
|
+
.filter((name) => pattern.test(name))
|
|
351
|
+
.map((name) => path.join(dir, name))
|
|
352
|
+
}
|
|
353
|
+
|
|
342
354
|
function pathsForChatBackupTarget(target) {
|
|
343
355
|
const targets = chatTargets()
|
|
344
356
|
if (target === 'all') {
|
|
@@ -412,6 +424,121 @@ function copyPathToBackup(sourcePath, backupRoot) {
|
|
|
412
424
|
}
|
|
413
425
|
}
|
|
414
426
|
|
|
427
|
+
function codexSessionIDFromPath(filePath) {
|
|
428
|
+
const base = path.basename(filePath, '.jsonl')
|
|
429
|
+
const match = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i)
|
|
430
|
+
return match ? match[1] : ''
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function truncateTitle(text) {
|
|
434
|
+
let value = String(text || '')
|
|
435
|
+
const requestMatch = value.match(/(?:##\s*)?My request for Codex:\s*([\s\S]+)/i)
|
|
436
|
+
if (requestMatch) {
|
|
437
|
+
value = requestMatch[1]
|
|
438
|
+
}
|
|
439
|
+
value = value
|
|
440
|
+
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
|
|
441
|
+
.replace(/# Context from my IDE setup:/gi, ' ')
|
|
442
|
+
.replace(/## Open tabs:[\s\S]*?(?=##|$)/gi, ' ')
|
|
443
|
+
.replace(/## Active file:[\s\S]*?(?=##|$)/gi, ' ')
|
|
444
|
+
return value
|
|
445
|
+
.replace(/\s+/g, ' ')
|
|
446
|
+
.trim()
|
|
447
|
+
.slice(0, 80)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function titleFromCodexSession(filePath) {
|
|
451
|
+
try {
|
|
452
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
453
|
+
for (const line of content.split(/\r?\n/)) {
|
|
454
|
+
if (!line.trim()) continue
|
|
455
|
+
let item
|
|
456
|
+
try {
|
|
457
|
+
item = JSON.parse(line)
|
|
458
|
+
} catch {
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
const payload = item?.payload
|
|
462
|
+
if (payload?.type === 'user_message' && payload.message) {
|
|
463
|
+
return truncateTitle(payload.message)
|
|
464
|
+
}
|
|
465
|
+
if (payload?.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) {
|
|
466
|
+
const text = payload.content
|
|
467
|
+
.map((part) => part?.text || part?.input_text || '')
|
|
468
|
+
.filter(Boolean)
|
|
469
|
+
.join(' ')
|
|
470
|
+
if (text) return truncateTitle(text)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
return ''
|
|
475
|
+
}
|
|
476
|
+
return ''
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function latestTimestampFromCodexSession(filePath) {
|
|
480
|
+
let latest = ''
|
|
481
|
+
try {
|
|
482
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
483
|
+
for (const line of content.split(/\r?\n/)) {
|
|
484
|
+
if (!line.trim()) continue
|
|
485
|
+
try {
|
|
486
|
+
const item = JSON.parse(line)
|
|
487
|
+
if (typeof item.timestamp === 'string' && item.timestamp > latest) {
|
|
488
|
+
latest = item.timestamp
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
// Ignore malformed lines.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
return ''
|
|
496
|
+
}
|
|
497
|
+
return latest
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function listCodexSessionFiles(dir) {
|
|
501
|
+
if (!fs.existsSync(dir)) return []
|
|
502
|
+
const out = []
|
|
503
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
504
|
+
const fullPath = path.join(dir, entry.name)
|
|
505
|
+
if (entry.isDirectory()) {
|
|
506
|
+
out.push(...listCodexSessionFiles(fullPath))
|
|
507
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
508
|
+
out.push(fullPath)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return out
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function rebuildCodexSessionIndex() {
|
|
515
|
+
const sessionsDir = path.join(os.homedir(), '.codex', 'sessions')
|
|
516
|
+
const indexPath = path.join(os.homedir(), '.codex', 'session_index.jsonl')
|
|
517
|
+
const files = listCodexSessionFiles(sessionsDir)
|
|
518
|
+
if (files.length === 0) return 0
|
|
519
|
+
|
|
520
|
+
const rows = files.map((filePath) => {
|
|
521
|
+
const id = codexSessionIDFromPath(filePath)
|
|
522
|
+
if (!id) return null
|
|
523
|
+
const title = titleFromCodexSession(filePath) || id
|
|
524
|
+
const stat = fs.statSync(filePath)
|
|
525
|
+
return {
|
|
526
|
+
id,
|
|
527
|
+
thread_name: title,
|
|
528
|
+
updated_at: latestTimestampFromCodexSession(filePath) || stat.mtime.toISOString()
|
|
529
|
+
}
|
|
530
|
+
}).filter(Boolean)
|
|
531
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
532
|
+
|
|
533
|
+
if (rows.length === 0) return 0
|
|
534
|
+
ensureDir(path.dirname(indexPath))
|
|
535
|
+
if (fileExists(indexPath)) {
|
|
536
|
+
fs.copyFileSync(indexPath, `${indexPath}.${timestampForName()}.bak`)
|
|
537
|
+
}
|
|
538
|
+
fs.writeFileSync(indexPath, rows.map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8')
|
|
539
|
+
return rows.length
|
|
540
|
+
}
|
|
541
|
+
|
|
415
542
|
function loadBackupManifest(dir) {
|
|
416
543
|
const manifestPath = path.join(dir, 'manifest.json')
|
|
417
544
|
if (!fileExists(manifestPath)) return null
|
|
@@ -974,6 +1101,10 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
974
1101
|
const safetyBackup = path.join(APP_DIR, 'backups', `${timestampForName()}_pre-restore_${safeBackupName(manifest.name || selected)}`)
|
|
975
1102
|
ensureDir(safetyBackup)
|
|
976
1103
|
const restored = []
|
|
1104
|
+
let restoredCodexSessions = false
|
|
1105
|
+
let restoredCodexIndex = false
|
|
1106
|
+
const codexSessionsPath = path.join(os.homedir(), '.codex', 'sessions')
|
|
1107
|
+
const codexIndexPath = path.join(os.homedir(), '.codex', 'session_index.jsonl')
|
|
977
1108
|
for (const file of manifest.files || []) {
|
|
978
1109
|
const source = file.source
|
|
979
1110
|
const backupPath = path.join(selectedDir, file.backup)
|
|
@@ -987,6 +1118,17 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
987
1118
|
}
|
|
988
1119
|
fs.cpSync(backupPath, source, { recursive: true, force: true })
|
|
989
1120
|
restored.push(source)
|
|
1121
|
+
if (kind === 'chat' && source === codexSessionsPath) {
|
|
1122
|
+
restoredCodexSessions = true
|
|
1123
|
+
}
|
|
1124
|
+
if (kind === 'chat' && source === codexIndexPath) {
|
|
1125
|
+
restoredCodexIndex = true
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
let rebuiltCodexIndexCount = 0
|
|
1130
|
+
if (restoredCodexSessions && !restoredCodexIndex) {
|
|
1131
|
+
rebuiltCodexIndexCount = rebuildCodexSessionIndex()
|
|
990
1132
|
}
|
|
991
1133
|
|
|
992
1134
|
clear()
|
|
@@ -996,6 +1138,11 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
996
1138
|
for (const file of restored) {
|
|
997
1139
|
console.log(`- ${file}`)
|
|
998
1140
|
}
|
|
1141
|
+
if (rebuiltCodexIndexCount > 0) {
|
|
1142
|
+
console.log(c('cyan', `已重建 Codex 任务索引: ${rebuiltCodexIndexCount} 条`))
|
|
1143
|
+
} else if (restoredCodexIndex) {
|
|
1144
|
+
console.log(c('cyan', '已恢复 Codex 任务索引。'))
|
|
1145
|
+
}
|
|
999
1146
|
console.log()
|
|
1000
1147
|
console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
|
|
1001
1148
|
await pause()
|