iivo-sub 0.1.1 → 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 CHANGED
@@ -26,11 +26,17 @@ The main menu includes:
26
26
  - Quick configuration
27
27
  - Backup configuration
28
28
  - Restore backup
29
+ - Backup chat records
30
+ - Restore chat records
29
31
  - Clear cache and backups
30
32
  - Exit
31
33
 
32
34
  Backup configuration lets you choose Codex, Claude Code, Hermes, OpenClaw, or all targets. You can name the backup yourself; the default name is `<date-time>_<target>`.
33
35
 
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
+
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
+
34
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.
35
41
 
36
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.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
@@ -40,6 +40,8 @@ const MAIN_MENU = [
40
40
  { label: '快速配置', value: 'quick' },
41
41
  { label: '备份配置', value: 'backup' },
42
42
  { label: '恢复备份', value: 'restore' },
43
+ { label: '备份聊天记录', value: 'backup-chats' },
44
+ { label: '还原聊天记录', value: 'restore-chats' },
43
45
  { label: '清空缓存及备份', value: 'clear-cache' },
44
46
  { label: '退出', value: 'exit' }
45
47
  ]
@@ -52,6 +54,14 @@ const BACKUP_TARGETS = [
52
54
  { label: '全部备份', value: 'all' }
53
55
  ]
54
56
 
57
+ const CHAT_BACKUP_TARGETS = [
58
+ { label: 'Codex 聊天记录', value: 'codex' },
59
+ { label: 'Claude Code 聊天记录', value: 'claude-code' },
60
+ { label: 'Hermes 聊天记录', value: 'hermes' },
61
+ { label: 'OpenClaw 聊天记录', value: 'openclaw' },
62
+ { label: '全部聊天记录', value: 'all' }
63
+ ]
64
+
55
65
  const RESTORE_CONFIRM_OPTIONS = [
56
66
  { label: '确认恢复', value: 'yes' },
57
67
  { label: '取消', value: 'no' }
@@ -260,6 +270,10 @@ function fileExists(filePath) {
260
270
  return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
261
271
  }
262
272
 
273
+ function pathExists(targetPath) {
274
+ return fs.existsSync(targetPath)
275
+ }
276
+
263
277
  function configTargets() {
264
278
  return {
265
279
  codex: [
@@ -301,6 +315,50 @@ function filesForBackupTarget(target) {
301
315
  return targets[target] ?? []
302
316
  }
303
317
 
318
+ function chatTargets() {
319
+ return {
320
+ codex: [
321
+ path.join(os.homedir(), '.codex', 'sessions'),
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))?$/)
327
+ ],
328
+ 'claude-code': [
329
+ path.join(os.homedir(), '.claude', 'projects'),
330
+ path.join(os.homedir(), '.claude', 'todos'),
331
+ path.join(os.homedir(), '.claude', 'shell-snapshots')
332
+ ],
333
+ hermes: [
334
+ path.join(os.homedir(), '.hermes', 'sessions'),
335
+ path.join(os.homedir(), '.hermes', 'chats'),
336
+ path.join(os.homedir(), '.hermes', 'history.json')
337
+ ],
338
+ openclaw: [
339
+ path.join(os.homedir(), '.openclaw', 'sessions'),
340
+ path.join(os.homedir(), '.openclaw', 'chats'),
341
+ path.join(os.homedir(), '.openclaw', 'history.json')
342
+ ]
343
+ }
344
+ }
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
+
354
+ function pathsForChatBackupTarget(target) {
355
+ const targets = chatTargets()
356
+ if (target === 'all') {
357
+ return Object.values(targets).flat()
358
+ }
359
+ return targets[target] ?? []
360
+ }
361
+
304
362
  function uniqueFiles(files) {
305
363
  return [...new Set(files)]
306
364
  }
@@ -309,6 +367,10 @@ function backupTargetLabel(value) {
309
367
  return BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
310
368
  }
311
369
 
370
+ function chatBackupTargetLabel(value) {
371
+ return CHAT_BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
372
+ }
373
+
312
374
  function timestampForName() {
313
375
  const now = new Date()
314
376
  const pad = (value) => String(value).padStart(2, '0')
@@ -349,6 +411,134 @@ function copyConfigToBackup(filePath, backupRoot) {
349
411
  }
350
412
  }
351
413
 
414
+ function copyPathToBackup(sourcePath, backupRoot) {
415
+ const stat = fs.statSync(sourcePath)
416
+ const backupFile = toBackupFileName(sourcePath)
417
+ const backupPath = path.join(backupRoot, 'files', backupFile)
418
+ ensureDir(path.dirname(backupPath))
419
+ fs.cpSync(sourcePath, backupPath, { recursive: true, force: true })
420
+ return {
421
+ source: sourcePath,
422
+ backup: path.relative(backupRoot, backupPath),
423
+ type: stat.isDirectory() ? 'directory' : 'file'
424
+ }
425
+ }
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
+
352
542
  function loadBackupManifest(dir) {
353
543
  const manifestPath = path.join(dir, 'manifest.json')
354
544
  if (!fileExists(manifestPath)) return null
@@ -746,6 +936,7 @@ async function backupConfig() {
746
936
  ensureDir(backupRoot)
747
937
  const files = existing.map((filePath) => copyConfigToBackup(filePath, backupRoot))
748
938
  const manifest = {
939
+ kind: 'config',
749
940
  name,
750
941
  target,
751
942
  target_label: backupTargetLabel(target),
@@ -769,27 +960,105 @@ async function backupConfig() {
769
960
  await pause()
770
961
  }
771
962
 
963
+ async function backupChatRecords() {
964
+ clear()
965
+ banner()
966
+ const target = await select('请选择聊天记录备份目标:', CHAT_BACKUP_TARGETS)
967
+ if (target === '__exit__') return '__exit__'
968
+ if (!target) return
969
+
970
+ const defaultName = `${timestampForName()}_chats_${target}`
971
+ clear()
972
+ banner()
973
+ const rawName = await question('请输入聊天记录备份名称 [Esc 返回上一步]', { defaultValue: defaultName })
974
+ if (rawName === '__exit__') return '__exit__'
975
+ if (rawName === null) return backupChatRecords()
976
+
977
+ const name = safeBackupName(rawName) || defaultName
978
+ const backupRoot = path.join(APP_DIR, 'backups', name)
979
+ if (fs.existsSync(backupRoot)) {
980
+ console.log(c('red', `备份名称已存在: ${name}`))
981
+ await pause()
982
+ return backupChatRecords()
983
+ }
984
+
985
+ const candidates = uniqueFiles(pathsForChatBackupTarget(target))
986
+ const existing = candidates.filter(pathExists)
987
+ if (existing.length === 0) {
988
+ console.log(c('yellow', `没有找到可备份的 ${chatBackupTargetLabel(target)}。`))
989
+ console.log()
990
+ console.log(c('dim', '已检查常见目录:'))
991
+ for (const item of candidates) {
992
+ console.log(c('dim', `- ${item}`))
993
+ }
994
+ await pause()
995
+ return
996
+ }
997
+
998
+ ensureDir(backupRoot)
999
+ const files = existing.map((sourcePath) => copyPathToBackup(sourcePath, backupRoot))
1000
+ const manifest = {
1001
+ kind: 'chat',
1002
+ name,
1003
+ target,
1004
+ target_label: chatBackupTargetLabel(target),
1005
+ created_at: new Date().toISOString(),
1006
+ files
1007
+ }
1008
+ fs.writeFileSync(path.join(backupRoot, 'manifest.json'), json(manifest), 'utf8')
1009
+
1010
+ clear()
1011
+ banner()
1012
+ console.log(c('green', '聊天记录备份完成。'))
1013
+ console.log()
1014
+ console.log(`备份名称: ${name}`)
1015
+ console.log(`备份目标: ${chatBackupTargetLabel(target)}`)
1016
+ console.log(`备份目录: ${backupRoot}`)
1017
+ console.log()
1018
+ console.log(c('cyan', '已备份路径:'))
1019
+ for (const file of files) {
1020
+ console.log(`- ${file.source}`)
1021
+ }
1022
+ await pause()
1023
+ }
1024
+
1025
+ function backupEntryMatchesKind(entryName, backupDir, kind) {
1026
+ const manifest = loadBackupManifest(path.join(backupDir, entryName))
1027
+ if (!manifest) return kind === 'config'
1028
+ if (kind === 'config') return !manifest.kind || manifest.kind === 'config'
1029
+ return manifest.kind === kind
1030
+ }
1031
+
772
1032
  async function restoreBackup() {
1033
+ return restoreBackupByKind('config', '请选择要查看的配置备份:', '暂无配置备份。')
1034
+ }
1035
+
1036
+ async function restoreChatRecords() {
1037
+ return restoreBackupByKind('chat', '请选择要还原的聊天记录备份:', '暂无聊天记录备份。')
1038
+ }
1039
+
1040
+ async function restoreBackupByKind(kind, title, emptyMessage) {
773
1041
  clear()
774
1042
  banner()
775
1043
  const backupDir = path.join(APP_DIR, 'backups')
776
1044
  if (!fs.existsSync(backupDir)) {
777
- console.log(c('yellow', '暂无备份。'))
1045
+ console.log(c('yellow', emptyMessage))
778
1046
  return pause()
779
1047
  }
780
1048
 
781
1049
  const entries = fs.readdirSync(backupDir, { withFileTypes: true })
782
1050
  .filter((entry) => entry.isDirectory())
783
1051
  .map((entry) => entry.name)
1052
+ .filter((name) => backupEntryMatchesKind(name, backupDir, kind))
784
1053
  .sort()
785
1054
  .reverse()
786
1055
 
787
1056
  if (entries.length === 0) {
788
- console.log(c('yellow', '暂无备份。'))
1057
+ console.log(c('yellow', emptyMessage))
789
1058
  return pause()
790
1059
  }
791
1060
 
792
- const selected = await select('请选择要查看的备份:', [
1061
+ const selected = await select(title, [
793
1062
  ...entries.map((name) => ({ label: name, value: name })),
794
1063
  { label: '返回', value: null }
795
1064
  ])
@@ -816,38 +1085,64 @@ async function restoreBackup() {
816
1085
  console.log(`备份目标: ${manifest.target_label || manifest.target || '未知'}`)
817
1086
  console.log(`创建时间: ${manifest.created_at || '未知'}`)
818
1087
  console.log()
819
- console.log(c('cyan', '将恢复以下文件:'))
1088
+ console.log(c('cyan', kind === 'chat' ? '将还原以下聊天记录路径:' : '将恢复以下文件:'))
820
1089
  for (const file of manifest.files || []) {
821
1090
  console.log(`- ${file.source}`)
822
1091
  }
823
1092
  console.log()
824
1093
 
825
- const confirm = await select('确认恢复这个备份吗?当前同名配置会先自动备份。', RESTORE_CONFIRM_OPTIONS)
1094
+ const confirmText = kind === 'chat'
1095
+ ? '确认还原这个聊天记录备份吗?当前同名聊天记录会先自动备份。'
1096
+ : '确认恢复这个备份吗?当前同名配置会先自动备份。'
1097
+ const confirm = await select(confirmText, RESTORE_CONFIRM_OPTIONS)
826
1098
  if (confirm === '__exit__') return '__exit__'
827
1099
  if (confirm !== 'yes') return
828
1100
 
829
1101
  const safetyBackup = path.join(APP_DIR, 'backups', `${timestampForName()}_pre-restore_${safeBackupName(manifest.name || selected)}`)
830
1102
  ensureDir(safetyBackup)
831
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')
832
1108
  for (const file of manifest.files || []) {
833
1109
  const source = file.source
834
1110
  const backupPath = path.join(selectedDir, file.backup)
835
- if (!source || !fileExists(backupPath)) continue
836
- if (fileExists(source)) {
837
- copyConfigToBackup(source, safetyBackup)
1111
+ if (!source || !pathExists(backupPath)) continue
1112
+ if (pathExists(source)) {
1113
+ copyPathToBackup(source, safetyBackup)
838
1114
  }
839
1115
  ensureDir(path.dirname(source))
840
- fs.copyFileSync(backupPath, source)
1116
+ if (pathExists(source)) {
1117
+ deletePath(source)
1118
+ }
1119
+ fs.cpSync(backupPath, source, { recursive: true, force: true })
841
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()
842
1132
  }
843
1133
 
844
1134
  clear()
845
1135
  banner()
846
- console.log(c('green', '恢复完成。'))
1136
+ console.log(c('green', kind === 'chat' ? '聊天记录还原完成。' : '恢复完成。'))
847
1137
  console.log()
848
1138
  for (const file of restored) {
849
1139
  console.log(`- ${file}`)
850
1140
  }
1141
+ if (rebuiltCodexIndexCount > 0) {
1142
+ console.log(c('cyan', `已重建 Codex 任务索引: ${rebuiltCodexIndexCount} 条`))
1143
+ } else if (restoredCodexIndex) {
1144
+ console.log(c('cyan', '已恢复 Codex 任务索引。'))
1145
+ }
851
1146
  console.log()
852
1147
  console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
853
1148
  await pause()
@@ -858,7 +1153,7 @@ async function clearCacheAndBackups() {
858
1153
  banner()
859
1154
  console.log(c('yellow', '将删除以下 iivo-sub 数据:'))
860
1155
  console.log(`- API Key 缓存: ${CACHE_FILE}`)
861
- console.log(`- 备份目录: ${path.join(APP_DIR, 'backups')}`)
1156
+ console.log(`- 备份目录: ${path.join(APP_DIR, 'backups')} ${c('dim', '(包含配置备份和聊天记录备份)')}`)
862
1157
  console.log(`- 临时配置脚本: ${path.join(APP_DIR, '*.env / *.ps1 / *.cmd')}`)
863
1158
  console.log(`- 最近配置摘要: ${path.join(APP_DIR, 'config.json')}`)
864
1159
  console.log()
@@ -925,6 +1220,14 @@ async function main() {
925
1220
  const result = await restoreBackup()
926
1221
  if (result === '__exit__') break
927
1222
  }
1223
+ if (action === 'backup-chats') {
1224
+ const result = await backupChatRecords()
1225
+ if (result === '__exit__') break
1226
+ }
1227
+ if (action === 'restore-chats') {
1228
+ const result = await restoreChatRecords()
1229
+ if (result === '__exit__') break
1230
+ }
928
1231
  if (action === 'clear-cache') {
929
1232
  const result = await clearCacheAndBackups()
930
1233
  if (result === '__exit__') break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iivo-sub",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "IIVO AI gateway quick configuration CLI",
5
5
  "type": "module",
6
6
  "bin": {