iivo-sub 0.1.3 → 0.1.4

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
@@ -35,7 +35,7 @@ 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.
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. 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. Restore also rebuilds the Codex SQLite `threads` task list from session files when possible, because recent Codex builds read the task list from `~/.codex/state_*.sqlite`.
39
39
 
40
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.
41
41
 
package/bin/iivo-sub.js CHANGED
@@ -4,8 +4,9 @@ import fs from 'node:fs'
4
4
  import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import readline from 'node:readline'
7
+ import { spawnSync } from 'node:child_process'
7
8
 
8
- const VERSION = '0.1.3'
9
+ const VERSION = '0.1.4'
9
10
  const APP_DIR = path.join(os.homedir(), '.iivo-sub')
10
11
  const CACHE_FILE = path.join(APP_DIR, 'cache.json')
11
12
  const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
@@ -323,7 +324,7 @@ function chatTargets() {
323
324
  path.join(os.homedir(), '.codex', 'history.jsonl'),
324
325
  path.join(os.homedir(), '.codex', 'attachments'),
325
326
  path.join(os.homedir(), '.codex', 'shell_snapshots'),
326
- ...matchingHomeFiles('.codex', /^(?:state|goals|logs|memories)_\d+\.sqlite(?:-(?:shm|wal))?$/)
327
+ ...matchingHomeFiles('.codex', /^(?:state|goals|logs|memories)_\d+\.sqlite$/)
327
328
  ],
328
329
  'claude-code': [
329
330
  path.join(os.homedir(), '.claude', 'projects'),
@@ -416,6 +417,13 @@ function copyPathToBackup(sourcePath, backupRoot) {
416
417
  const backupFile = toBackupFileName(sourcePath)
417
418
  const backupPath = path.join(backupRoot, 'files', backupFile)
418
419
  ensureDir(path.dirname(backupPath))
420
+ if (stat.isFile() && sourcePath.endsWith('.sqlite') && sqliteBackup(sourcePath, backupPath)) {
421
+ return {
422
+ source: sourcePath,
423
+ backup: path.relative(backupRoot, backupPath),
424
+ type: 'sqlite'
425
+ }
426
+ }
419
427
  fs.cpSync(sourcePath, backupPath, { recursive: true, force: true })
420
428
  return {
421
429
  source: sourcePath,
@@ -424,6 +432,40 @@ function copyPathToBackup(sourcePath, backupRoot) {
424
432
  }
425
433
  }
426
434
 
435
+ function findSqlite3() {
436
+ const candidates = [
437
+ process.env.SQLITE3,
438
+ 'sqlite3',
439
+ '/usr/bin/sqlite3',
440
+ '/usr/local/bin/sqlite3',
441
+ path.join(os.homedir(), 'Android', 'Sdk', 'platform-tools', 'sqlite3')
442
+ ].filter(Boolean)
443
+ for (const candidate of candidates) {
444
+ const result = spawnSync(candidate, ['-version'], { encoding: 'utf8' })
445
+ if (result.status === 0) return candidate
446
+ }
447
+ return ''
448
+ }
449
+
450
+ function sqliteBackup(sourcePath, backupPath) {
451
+ const sqlite3 = findSqlite3()
452
+ if (!sqlite3) return false
453
+ ensureDir(path.dirname(backupPath))
454
+ const result = spawnSync(sqlite3, [sourcePath, `.backup '${backupPath.replaceAll("'", "''")}'`], {
455
+ encoding: 'utf8'
456
+ })
457
+ return result.status === 0 && fileExists(backupPath)
458
+ }
459
+
460
+ function deleteSqliteSidecars(sqlitePath) {
461
+ for (const suffix of ['-wal', '-shm']) {
462
+ const sidecar = `${sqlitePath}${suffix}`
463
+ if (pathExists(sidecar)) {
464
+ deletePath(sidecar)
465
+ }
466
+ }
467
+ }
468
+
427
469
  function codexSessionIDFromPath(filePath) {
428
470
  const base = path.basename(filePath, '.jsonl')
429
471
  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)
@@ -476,6 +518,80 @@ function titleFromCodexSession(filePath) {
476
518
  return ''
477
519
  }
478
520
 
521
+ function codexSessionSummary(filePath) {
522
+ const stat = fs.statSync(filePath)
523
+ const id = codexSessionIDFromPath(filePath)
524
+ const summary = {
525
+ id,
526
+ rollout_path: filePath,
527
+ created_at: stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs,
528
+ updated_at: stat.mtimeMs,
529
+ source: 'cli',
530
+ model_provider: 'openai',
531
+ cwd: os.homedir(),
532
+ title: '',
533
+ first_user_message: '',
534
+ cli_version: '',
535
+ sandbox_policy: '{"type":"workspace-write"}',
536
+ approval_mode: 'on-request',
537
+ model: '',
538
+ reasoning_effort: '',
539
+ thread_source: ''
540
+ }
541
+
542
+ try {
543
+ const content = fs.readFileSync(filePath, 'utf8')
544
+ for (const line of content.split(/\r?\n/)) {
545
+ if (!line.trim()) continue
546
+ let item
547
+ try {
548
+ item = JSON.parse(line)
549
+ } catch {
550
+ continue
551
+ }
552
+ if (typeof item.timestamp === 'string') {
553
+ const ts = Date.parse(item.timestamp)
554
+ if (Number.isFinite(ts)) {
555
+ summary.updated_at = Math.max(summary.updated_at, ts)
556
+ if (!summary.created_at || summary.created_at > ts) summary.created_at = ts
557
+ }
558
+ }
559
+ const payload = item?.payload
560
+ if (item?.type === 'session_meta' && payload) {
561
+ if (payload.id && !summary.id) summary.id = String(payload.id)
562
+ if (payload.timestamp) {
563
+ const ts = Date.parse(payload.timestamp)
564
+ if (Number.isFinite(ts)) summary.created_at = ts
565
+ }
566
+ if (payload.cwd) summary.cwd = String(payload.cwd)
567
+ if (payload.source) summary.source = String(payload.source)
568
+ if (payload.model_provider) summary.model_provider = String(payload.model_provider)
569
+ if (payload.cli_version) summary.cli_version = String(payload.cli_version)
570
+ if (payload.model) summary.model = String(payload.model)
571
+ if (payload.reasoning_effort) summary.reasoning_effort = String(payload.reasoning_effort)
572
+ if (payload.sandbox_policy) summary.sandbox_policy = JSON.stringify(payload.sandbox_policy)
573
+ if (payload.approval_policy) summary.approval_mode = String(payload.approval_policy)
574
+ }
575
+ if (!summary.first_user_message && payload?.type === 'user_message' && payload.message) {
576
+ summary.first_user_message = truncateTitle(payload.message)
577
+ }
578
+ if (!summary.first_user_message && payload?.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) {
579
+ const text = payload.content
580
+ .map((part) => part?.text || part?.input_text || '')
581
+ .filter(Boolean)
582
+ .join(' ')
583
+ if (text) summary.first_user_message = truncateTitle(text)
584
+ }
585
+ }
586
+ } catch {
587
+ // Keep filesystem-derived fallback fields.
588
+ }
589
+
590
+ summary.title = summary.first_user_message || titleFromCodexSession(filePath) || summary.id || path.basename(filePath, '.jsonl')
591
+ if (!summary.first_user_message) summary.first_user_message = summary.title
592
+ return summary
593
+ }
594
+
479
595
  function latestTimestampFromCodexSession(filePath) {
480
596
  let latest = ''
481
597
  try {
@@ -539,6 +655,65 @@ function rebuildCodexSessionIndex() {
539
655
  return rows.length
540
656
  }
541
657
 
658
+ function sqlString(value) {
659
+ return `'${String(value ?? '').replaceAll("'", "''")}'`
660
+ }
661
+
662
+ function rebuildCodexThreadsState() {
663
+ const sqlite3 = findSqlite3()
664
+ if (!sqlite3) return { rebuilt: 0, reason: 'sqlite3 not found' }
665
+
666
+ const sessionsDir = path.join(os.homedir(), '.codex', 'sessions')
667
+ const files = listCodexSessionFiles(sessionsDir)
668
+ if (files.length === 0) return { rebuilt: 0, reason: 'no sessions' }
669
+
670
+ const stateDbs = matchingHomeFiles('.codex', /^state_\d+\.sqlite$/)
671
+ if (stateDbs.length === 0) return { rebuilt: 0, reason: 'state sqlite not found' }
672
+
673
+ const rows = files.map(codexSessionSummary).filter((row) => row.id)
674
+ if (rows.length === 0) return { rebuilt: 0, reason: 'no session ids' }
675
+
676
+ let total = 0
677
+ for (const dbPath of stateDbs) {
678
+ const tableCheck = spawnSync(sqlite3, [dbPath, "select name from sqlite_master where type='table' and name='threads';"], { encoding: 'utf8' })
679
+ if (tableCheck.status !== 0 || !tableCheck.stdout.includes('threads')) continue
680
+
681
+ if (fileExists(dbPath)) {
682
+ fs.copyFileSync(dbPath, `${dbPath}.${timestampForName()}.bak`)
683
+ }
684
+
685
+ const statements = ['BEGIN;', 'DELETE FROM threads;']
686
+ for (const row of rows) {
687
+ const createdMs = Math.trunc(row.created_at)
688
+ const updatedMs = Math.trunc(row.updated_at)
689
+ const created = Math.trunc(createdMs / 1000)
690
+ const updated = Math.trunc(updatedMs / 1000)
691
+ statements.push(`INSERT OR REPLACE INTO threads (
692
+ id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
693
+ sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
694
+ cli_version, first_user_message, memory_mode, model, reasoning_effort,
695
+ created_at_ms, updated_at_ms, thread_source, preview
696
+ ) VALUES (
697
+ ${sqlString(row.id)}, ${sqlString(row.rollout_path)}, ${created}, ${updated},
698
+ ${sqlString(row.source)}, ${sqlString(row.model_provider)}, ${sqlString(row.cwd)}, ${sqlString(row.title)},
699
+ ${sqlString(row.sandbox_policy)}, ${sqlString(row.approval_mode)}, 0, 1, 0,
700
+ ${sqlString(row.cli_version)}, ${sqlString(row.first_user_message)}, 'enabled',
701
+ ${row.model ? sqlString(row.model) : 'NULL'}, ${row.reasoning_effort ? sqlString(row.reasoning_effort) : 'NULL'},
702
+ ${createdMs}, ${updatedMs}, ${row.thread_source ? sqlString(row.thread_source) : 'NULL'}, ${sqlString(row.title)}
703
+ );`)
704
+ }
705
+ statements.push('COMMIT;')
706
+
707
+ const result = spawnSync(sqlite3, [dbPath], {
708
+ input: statements.join('\n'),
709
+ encoding: 'utf8',
710
+ maxBuffer: 10 * 1024 * 1024
711
+ })
712
+ if (result.status === 0) total += rows.length
713
+ }
714
+ return { rebuilt: total, reason: total > 0 ? '' : 'threads table not rebuilt' }
715
+ }
716
+
542
717
  function loadBackupManifest(dir) {
543
718
  const manifestPath = path.join(dir, 'manifest.json')
544
719
  if (!fileExists(manifestPath)) return null
@@ -1116,7 +1291,13 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1116
1291
  if (pathExists(source)) {
1117
1292
  deletePath(source)
1118
1293
  }
1294
+ if (kind === 'chat' && source.endsWith('.sqlite')) {
1295
+ deleteSqliteSidecars(source)
1296
+ }
1119
1297
  fs.cpSync(backupPath, source, { recursive: true, force: true })
1298
+ if (kind === 'chat' && source.endsWith('.sqlite')) {
1299
+ deleteSqliteSidecars(source)
1300
+ }
1120
1301
  restored.push(source)
1121
1302
  if (kind === 'chat' && source === codexSessionsPath) {
1122
1303
  restoredCodexSessions = true
@@ -1127,9 +1308,13 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1127
1308
  }
1128
1309
 
1129
1310
  let rebuiltCodexIndexCount = 0
1311
+ let rebuiltCodexThreads = { rebuilt: 0, reason: '' }
1130
1312
  if (restoredCodexSessions && !restoredCodexIndex) {
1131
1313
  rebuiltCodexIndexCount = rebuildCodexSessionIndex()
1132
1314
  }
1315
+ if (kind === 'chat' && restoredCodexSessions) {
1316
+ rebuiltCodexThreads = rebuildCodexThreadsState()
1317
+ }
1133
1318
 
1134
1319
  clear()
1135
1320
  banner()
@@ -1143,6 +1328,11 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1143
1328
  } else if (restoredCodexIndex) {
1144
1329
  console.log(c('cyan', '已恢复 Codex 任务索引。'))
1145
1330
  }
1331
+ if (rebuiltCodexThreads.rebuilt > 0) {
1332
+ console.log(c('cyan', `已重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.rebuilt} 条`))
1333
+ } else if (rebuiltCodexThreads.reason) {
1334
+ console.log(c('yellow', `未重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.reason}`))
1335
+ }
1146
1336
  console.log()
1147
1337
  console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
1148
1338
  await pause()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iivo-sub",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "IIVO AI gateway quick configuration CLI",
5
5
  "type": "module",
6
6
  "bin": {