iivo-sub 0.1.6 → 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 +4 -2
- package/bin/iivo-sub.js +148 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,9 +33,11 @@ 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
|
|
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
|
|
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
|
+
|
|
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.
|
|
39
41
|
|
|
40
42
|
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.
|
|
41
43
|
|
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.
|
|
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
|
|
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
|
|
320
|
-
return
|
|
321
|
-
codex
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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,72 @@ 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
|
+
|
|
700
|
+
function codexRelativePath(filePath) {
|
|
701
|
+
return path.relative(path.join(os.homedir(), '.codex'), filePath).split(path.sep).join('/')
|
|
702
|
+
}
|
|
703
|
+
|
|
666
704
|
function rebuildCodexSessionIndex() {
|
|
667
705
|
const sessionsDir = path.join(os.homedir(), '.codex', 'sessions')
|
|
668
706
|
const indexPath = path.join(os.homedir(), '.codex', 'session_index.jsonl')
|
|
@@ -695,6 +733,25 @@ function sqlString(value) {
|
|
|
695
733
|
return `'${String(value ?? '').replaceAll("'", "''")}'`
|
|
696
734
|
}
|
|
697
735
|
|
|
736
|
+
function updateCodexBackfillState(sqlite3, dbPath, watermark) {
|
|
737
|
+
if (!watermark) return false
|
|
738
|
+
const tableCheck = spawnSync(sqlite3, [dbPath, "select name from sqlite_master where type='table' and name='backfill_state';"], {
|
|
739
|
+
encoding: 'utf8',
|
|
740
|
+
timeout: 10000
|
|
741
|
+
})
|
|
742
|
+
if (tableCheck.status !== 0 || !tableCheck.stdout.includes('backfill_state')) return false
|
|
743
|
+
|
|
744
|
+
const completedAt = Math.trunc(Date.now() / 1000)
|
|
745
|
+
const script = [
|
|
746
|
+
'BEGIN;',
|
|
747
|
+
'DELETE FROM backfill_state WHERE id = 1;',
|
|
748
|
+
`INSERT INTO backfill_state (id, status, last_watermark, last_success_at, updated_at) VALUES (1, 'complete', ${sqlString(watermark)}, ${completedAt}, ${completedAt});`,
|
|
749
|
+
'COMMIT;'
|
|
750
|
+
].join('\n')
|
|
751
|
+
const result = runSqliteScript(sqlite3, dbPath, script)
|
|
752
|
+
return result.status === 0
|
|
753
|
+
}
|
|
754
|
+
|
|
698
755
|
function rebuildCodexThreadsState() {
|
|
699
756
|
const sqlite3 = findSqlite3()
|
|
700
757
|
if (!sqlite3) return { rebuilt: 0, reason: 'sqlite3 not found' }
|
|
@@ -709,7 +766,14 @@ function rebuildCodexThreadsState() {
|
|
|
709
766
|
const rows = files.map(codexSessionSummary).filter((row) => row.id)
|
|
710
767
|
if (rows.length === 0) return { rebuilt: 0, reason: 'no session ids' }
|
|
711
768
|
|
|
769
|
+
const watermark = files
|
|
770
|
+
.map(codexRelativePath)
|
|
771
|
+
.filter((value) => value && !value.startsWith('..'))
|
|
772
|
+
.sort()
|
|
773
|
+
.at(-1) || ''
|
|
774
|
+
|
|
712
775
|
let total = 0
|
|
776
|
+
let backfillUpdated = 0
|
|
713
777
|
for (const dbPath of stateDbs) {
|
|
714
778
|
const tableCheck = spawnSync(sqlite3, [dbPath, "select name from sqlite_master where type='table' and name='threads';"], {
|
|
715
779
|
encoding: 'utf8',
|
|
@@ -744,9 +808,12 @@ ${createdMs}, ${updatedMs}, ${row.thread_source ? sqlString(row.thread_source) :
|
|
|
744
808
|
statements.push('COMMIT;')
|
|
745
809
|
|
|
746
810
|
const result = runSqliteScript(sqlite3, dbPath, statements.join('\n'))
|
|
747
|
-
if (result.status === 0)
|
|
811
|
+
if (result.status === 0) {
|
|
812
|
+
total += rows.length
|
|
813
|
+
if (updateCodexBackfillState(sqlite3, dbPath, watermark)) backfillUpdated += 1
|
|
814
|
+
}
|
|
748
815
|
}
|
|
749
|
-
return { rebuilt: total, reason: total > 0 ? '' : 'threads table not rebuilt' }
|
|
816
|
+
return { rebuilt: total, backfillUpdated, reason: total > 0 ? '' : 'threads table not rebuilt' }
|
|
750
817
|
}
|
|
751
818
|
|
|
752
819
|
function loadBackupManifest(dir) {
|
|
@@ -871,6 +938,18 @@ goals = true
|
|
|
871
938
|
`
|
|
872
939
|
}
|
|
873
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
|
+
|
|
874
953
|
function claudeSettings({ host, apiKey }) {
|
|
875
954
|
return json({
|
|
876
955
|
env: {
|
|
@@ -1176,6 +1255,14 @@ async function backupChatRecords() {
|
|
|
1176
1255
|
const target = await select('请选择聊天记录备份目标:', CHAT_BACKUP_TARGETS)
|
|
1177
1256
|
if (target === '__exit__') return '__exit__'
|
|
1178
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
|
+
}
|
|
1179
1266
|
|
|
1180
1267
|
const defaultName = `${timestampForName()}_chats_${target}`
|
|
1181
1268
|
clear()
|
|
@@ -1192,7 +1279,7 @@ async function backupChatRecords() {
|
|
|
1192
1279
|
return backupChatRecords()
|
|
1193
1280
|
}
|
|
1194
1281
|
|
|
1195
|
-
const candidates = uniqueFiles(
|
|
1282
|
+
const candidates = uniqueFiles(codexChatBackupPaths())
|
|
1196
1283
|
const existing = candidates.filter(pathExists)
|
|
1197
1284
|
if (existing.length === 0) {
|
|
1198
1285
|
console.log(c('yellow', `没有找到可备份的 ${chatBackupTargetLabel(target)}。`))
|
|
@@ -1236,6 +1323,7 @@ function backupEntryMatchesKind(entryName, backupDir, kind) {
|
|
|
1236
1323
|
const manifest = loadBackupManifest(path.join(backupDir, entryName))
|
|
1237
1324
|
if (!manifest) return kind === 'config'
|
|
1238
1325
|
if (kind === 'config') return !manifest.kind || manifest.kind === 'config'
|
|
1326
|
+
if (kind === 'chat') return manifest.kind === 'chat' && manifest.target === 'codex'
|
|
1239
1327
|
return manifest.kind === kind
|
|
1240
1328
|
}
|
|
1241
1329
|
|
|
@@ -1244,7 +1332,7 @@ async function restoreBackup() {
|
|
|
1244
1332
|
}
|
|
1245
1333
|
|
|
1246
1334
|
async function restoreChatRecords() {
|
|
1247
|
-
return restoreBackupByKind('chat', '
|
|
1335
|
+
return restoreBackupByKind('chat', '请选择要还原的 Codex 聊天记录备份:', '暂无 Codex 聊天记录备份。')
|
|
1248
1336
|
}
|
|
1249
1337
|
|
|
1250
1338
|
async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
@@ -1343,8 +1431,12 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
1343
1431
|
}
|
|
1344
1432
|
|
|
1345
1433
|
let rebuiltCodexIndexCount = 0
|
|
1434
|
+
let normalizedCodexProviders = { filesChanged: 0, provider: '' }
|
|
1435
|
+
let syncedCodexSessionTimestamps = 0
|
|
1346
1436
|
let rebuiltCodexThreads = { rebuilt: 0, reason: '' }
|
|
1347
1437
|
if (restoredCodexSessions) {
|
|
1438
|
+
normalizedCodexProviders = normalizeCodexSessionModelProviders(currentCodexModelProvider())
|
|
1439
|
+
syncedCodexSessionTimestamps = syncCodexSessionTimestamps()
|
|
1348
1440
|
rebuiltCodexIndexCount = rebuildCodexSessionIndex()
|
|
1349
1441
|
}
|
|
1350
1442
|
if (kind === 'chat' && restoredCodexSessions) {
|
|
@@ -1363,11 +1455,24 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
1363
1455
|
} else if (restoredCodexIndex) {
|
|
1364
1456
|
console.log(c('cyan', '已恢复 Codex 任务索引。'))
|
|
1365
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
|
+
}
|
|
1366
1464
|
if (rebuiltCodexThreads.rebuilt > 0) {
|
|
1367
1465
|
console.log(c('cyan', `已重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.rebuilt} 条`))
|
|
1466
|
+
if (rebuiltCodexThreads.backfillUpdated > 0) {
|
|
1467
|
+
console.log(c('cyan', `已同步 Codex sqlite 回填状态: ${rebuiltCodexThreads.backfillUpdated} 个数据库`))
|
|
1468
|
+
}
|
|
1368
1469
|
} else if (rebuiltCodexThreads.reason) {
|
|
1369
1470
|
console.log(c('yellow', `未重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.reason}`))
|
|
1370
1471
|
}
|
|
1472
|
+
if (kind === 'chat' && restoredCodexSessions) {
|
|
1473
|
+
console.log()
|
|
1474
|
+
console.log(c('yellow', '重要: Codex VS Code 插件会缓存任务列表。还原后请执行 "Developer: Reload Window",或完全退出并重开 VS Code/Codex 窗口,否则侧边栏可能仍显示还原前的少量记录。'))
|
|
1475
|
+
}
|
|
1371
1476
|
console.log()
|
|
1372
1477
|
console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
|
|
1373
1478
|
await pause()
|