iivo-sub 0.1.7 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,9 +33,9 @@ The main menu includes:
33
33
 
34
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>`.
35
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.
36
+ Chat record backup and restore currently support Codex only. Claude Code remains visible in the backup menu as pending implementation; Hermes, OpenClaw, and all-target chat backup options are not shown. 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. SQLite state files are backed up with a consistent SQLite snapshot when `sqlite3` is available. Restore rebuilds the recent-task index from restored session files and also rebuilds the Codex SQLite `threads` task list plus `backfill_state` when possible, because recent Codex builds read the task list from `~/.codex/state_*.sqlite`.
38
+ For Codex, chat backup includes sessions, the recent-task index, history, attachments, shell snapshots, and local state files. SQLite state files are backed up with a consistent SQLite snapshot when `sqlite3` is available. Restore normalizes restored session metadata to the currently configured Codex `model_provider`, syncs session file mtimes from recorded event timestamps, then rebuilds the recent-task index and the Codex SQLite `threads` task list plus `backfill_state` when possible. Recent Codex builds read the task list from `~/.codex/state_*.sqlite` and the VS Code sidebar filters recent tasks by the active model provider.
39
39
 
40
40
  After restoring Codex chats in VS Code, run `Developer: Reload Window` or fully restart the VS Code/Codex window. The Codex extension keeps an in-process task-list cache, so the sidebar may continue to show the old small list until the window reloads.
41
41
 
package/bin/iivo-sub.js CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path'
6
6
  import readline from 'node:readline'
7
7
  import { spawnSync } from 'node:child_process'
8
8
 
9
- const VERSION = '0.1.7'
9
+ const VERSION = '0.1.10'
10
10
  const APP_DIR = path.join(os.homedir(), '.iivo-sub')
11
11
  const CACHE_FILE = path.join(APP_DIR, 'cache.json')
12
12
  const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
@@ -57,10 +57,7 @@ const BACKUP_TARGETS = [
57
57
 
58
58
  const CHAT_BACKUP_TARGETS = [
59
59
  { label: 'Codex 聊天记录', value: 'codex' },
60
- { label: 'Claude Code 聊天记录', value: 'claude-code' },
61
- { label: 'Hermes 聊天记录', value: 'hermes' },
62
- { label: 'OpenClaw 聊天记录', value: 'openclaw' },
63
- { label: '全部聊天记录', value: 'all' }
60
+ { label: 'Claude Code 聊天记录(待实现)', value: 'claude-code' }
64
61
  ]
65
62
 
66
63
  const RESTORE_CONFIRM_OPTIONS = [
@@ -316,32 +313,15 @@ function filesForBackupTarget(target) {
316
313
  return targets[target] ?? []
317
314
  }
318
315
 
319
- function chatTargets() {
320
- return {
321
- codex: [
322
- path.join(os.homedir(), '.codex', 'sessions'),
323
- path.join(os.homedir(), '.codex', 'session_index.jsonl'),
324
- path.join(os.homedir(), '.codex', 'history.jsonl'),
325
- path.join(os.homedir(), '.codex', 'attachments'),
326
- path.join(os.homedir(), '.codex', 'shell_snapshots'),
327
- ...matchingHomeFiles('.codex', /^(?:state|goals|logs|memories)_\d+\.sqlite$/)
328
- ],
329
- 'claude-code': [
330
- path.join(os.homedir(), '.claude', 'projects'),
331
- path.join(os.homedir(), '.claude', 'todos'),
332
- path.join(os.homedir(), '.claude', 'shell-snapshots')
333
- ],
334
- hermes: [
335
- path.join(os.homedir(), '.hermes', 'sessions'),
336
- path.join(os.homedir(), '.hermes', 'chats'),
337
- path.join(os.homedir(), '.hermes', 'history.json')
338
- ],
339
- openclaw: [
340
- path.join(os.homedir(), '.openclaw', 'sessions'),
341
- path.join(os.homedir(), '.openclaw', 'chats'),
342
- path.join(os.homedir(), '.openclaw', 'history.json')
343
- ]
344
- }
316
+ function codexChatBackupPaths() {
317
+ return [
318
+ path.join(os.homedir(), '.codex', 'sessions'),
319
+ path.join(os.homedir(), '.codex', 'session_index.jsonl'),
320
+ path.join(os.homedir(), '.codex', 'history.jsonl'),
321
+ path.join(os.homedir(), '.codex', 'attachments'),
322
+ path.join(os.homedir(), '.codex', 'shell_snapshots'),
323
+ ...matchingHomeFiles('.codex', /^(?:state|goals|logs|memories)_\d+\.sqlite$/)
324
+ ]
345
325
  }
346
326
 
347
327
  function matchingHomeFiles(relativeDir, pattern) {
@@ -352,14 +332,6 @@ function matchingHomeFiles(relativeDir, pattern) {
352
332
  .map((name) => path.join(dir, name))
353
333
  }
354
334
 
355
- function pathsForChatBackupTarget(target) {
356
- const targets = chatTargets()
357
- if (target === 'all') {
358
- return Object.values(targets).flat()
359
- }
360
- return targets[target] ?? []
361
- }
362
-
363
335
  function uniqueFiles(files) {
364
336
  return [...new Set(files)]
365
337
  }
@@ -663,6 +635,68 @@ function listCodexSessionFiles(dir) {
663
635
  return out
664
636
  }
665
637
 
638
+ function normalizeCodexSessionModelProviders(provider) {
639
+ if (!provider) return { filesChanged: 0, provider: '' }
640
+ const sessionsDir = path.join(os.homedir(), '.codex', 'sessions')
641
+ const files = listCodexSessionFiles(sessionsDir)
642
+ let filesChanged = 0
643
+ for (const filePath of files) {
644
+ let content
645
+ let stat
646
+ try {
647
+ content = fs.readFileSync(filePath, 'utf8')
648
+ stat = fs.statSync(filePath)
649
+ } catch {
650
+ continue
651
+ }
652
+ let changed = false
653
+ const lines = content.split(/\r?\n/)
654
+ const nextLines = lines.map((line) => {
655
+ if (!line.trim()) return line
656
+ let item
657
+ try {
658
+ item = JSON.parse(line)
659
+ } catch {
660
+ return line
661
+ }
662
+ if (item?.type !== 'session_meta' || !item.payload || typeof item.payload !== 'object') {
663
+ return line
664
+ }
665
+ if (item.payload.model_provider === provider) return line
666
+ item.payload.model_provider = provider
667
+ changed = true
668
+ return JSON.stringify(item)
669
+ })
670
+ if (!changed) continue
671
+ fs.writeFileSync(filePath, nextLines.join('\n'), 'utf8')
672
+ filesChanged += 1
673
+ fs.utimesSync(filePath, stat.atime, stat.mtime)
674
+ }
675
+ return { filesChanged, provider }
676
+ }
677
+
678
+ function syncCodexSessionTimestamps() {
679
+ const sessionsDir = path.join(os.homedir(), '.codex', 'sessions')
680
+ const files = listCodexSessionFiles(sessionsDir)
681
+ let changed = 0
682
+ for (const filePath of files) {
683
+ const latestTimestamp = latestTimestampFromCodexSession(filePath)
684
+ if (!latestTimestamp) continue
685
+ const latestTime = Date.parse(latestTimestamp)
686
+ if (!Number.isFinite(latestTime)) continue
687
+ let stat
688
+ try {
689
+ stat = fs.statSync(filePath)
690
+ } catch {
691
+ continue
692
+ }
693
+ if (Math.abs(stat.mtimeMs - latestTime) <= 1000) continue
694
+ fs.utimesSync(filePath, stat.atime, new Date(latestTime))
695
+ changed += 1
696
+ }
697
+ return changed
698
+ }
699
+
666
700
  function codexRelativePath(filePath) {
667
701
  return path.relative(path.join(os.homedir(), '.codex'), filePath).split(path.sep).join('/')
668
702
  }
@@ -904,6 +938,18 @@ goals = true
904
938
  `
905
939
  }
906
940
 
941
+ function currentCodexModelProvider() {
942
+ const configPath = path.join(os.homedir(), '.codex', 'config.toml')
943
+ if (!fileExists(configPath)) return ''
944
+ try {
945
+ const content = fs.readFileSync(configPath, 'utf8')
946
+ const match = content.match(/^\s*model_provider\s*=\s*["']([^"']+)["']\s*$/m)
947
+ return match?.[1]?.trim() || ''
948
+ } catch {
949
+ return ''
950
+ }
951
+ }
952
+
907
953
  function claudeSettings({ host, apiKey }) {
908
954
  return json({
909
955
  env: {
@@ -1209,6 +1255,14 @@ async function backupChatRecords() {
1209
1255
  const target = await select('请选择聊天记录备份目标:', CHAT_BACKUP_TARGETS)
1210
1256
  if (target === '__exit__') return '__exit__'
1211
1257
  if (!target) return
1258
+ if (target !== 'codex') {
1259
+ clear()
1260
+ banner()
1261
+ console.log(c('yellow', `${chatBackupTargetLabel(target)}暂不可用。`))
1262
+ console.log(c('dim', '聊天记录备份当前仅支持 Codex。'))
1263
+ await pause()
1264
+ return
1265
+ }
1212
1266
 
1213
1267
  const defaultName = `${timestampForName()}_chats_${target}`
1214
1268
  clear()
@@ -1225,7 +1279,7 @@ async function backupChatRecords() {
1225
1279
  return backupChatRecords()
1226
1280
  }
1227
1281
 
1228
- const candidates = uniqueFiles(pathsForChatBackupTarget(target))
1282
+ const candidates = uniqueFiles(codexChatBackupPaths())
1229
1283
  const existing = candidates.filter(pathExists)
1230
1284
  if (existing.length === 0) {
1231
1285
  console.log(c('yellow', `没有找到可备份的 ${chatBackupTargetLabel(target)}。`))
@@ -1269,6 +1323,7 @@ function backupEntryMatchesKind(entryName, backupDir, kind) {
1269
1323
  const manifest = loadBackupManifest(path.join(backupDir, entryName))
1270
1324
  if (!manifest) return kind === 'config'
1271
1325
  if (kind === 'config') return !manifest.kind || manifest.kind === 'config'
1326
+ if (kind === 'chat') return manifest.kind === 'chat' && manifest.target === 'codex'
1272
1327
  return manifest.kind === kind
1273
1328
  }
1274
1329
 
@@ -1277,7 +1332,7 @@ async function restoreBackup() {
1277
1332
  }
1278
1333
 
1279
1334
  async function restoreChatRecords() {
1280
- return restoreBackupByKind('chat', '请选择要还原的聊天记录备份:', '暂无聊天记录备份。')
1335
+ return restoreBackupByKind('chat', '请选择要还原的 Codex 聊天记录备份:', '暂无 Codex 聊天记录备份。')
1281
1336
  }
1282
1337
 
1283
1338
  async function restoreBackupByKind(kind, title, emptyMessage) {
@@ -1376,8 +1431,12 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1376
1431
  }
1377
1432
 
1378
1433
  let rebuiltCodexIndexCount = 0
1434
+ let normalizedCodexProviders = { filesChanged: 0, provider: '' }
1435
+ let syncedCodexSessionTimestamps = 0
1379
1436
  let rebuiltCodexThreads = { rebuilt: 0, reason: '' }
1380
1437
  if (restoredCodexSessions) {
1438
+ normalizedCodexProviders = normalizeCodexSessionModelProviders(currentCodexModelProvider())
1439
+ syncedCodexSessionTimestamps = syncCodexSessionTimestamps()
1381
1440
  rebuiltCodexIndexCount = rebuildCodexSessionIndex()
1382
1441
  }
1383
1442
  if (kind === 'chat' && restoredCodexSessions) {
@@ -1396,6 +1455,12 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1396
1455
  } else if (restoredCodexIndex) {
1397
1456
  console.log(c('cyan', '已恢复 Codex 任务索引。'))
1398
1457
  }
1458
+ if (normalizedCodexProviders.filesChanged > 0) {
1459
+ console.log(c('cyan', `已迁移 Codex 会话模型提供方: ${normalizedCodexProviders.provider} (${normalizedCodexProviders.filesChanged} 个文件)`))
1460
+ }
1461
+ if (syncedCodexSessionTimestamps > 0) {
1462
+ console.log(c('cyan', `已校正 Codex 会话文件时间: ${syncedCodexSessionTimestamps} 个文件`))
1463
+ }
1399
1464
  if (rebuiltCodexThreads.rebuilt > 0) {
1400
1465
  console.log(c('cyan', `已重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.rebuilt} 条`))
1401
1466
  if (rebuiltCodexThreads.backfillUpdated > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iivo-sub",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "description": "IIVO AI gateway quick configuration CLI",
5
5
  "type": "module",
6
6
  "bin": {