iivo-sub 0.1.4 → 0.1.6

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. 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`.
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 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
@@ -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.4'
9
+ const VERSION = '0.1.6'
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
@@ -424,7 +424,7 @@ function copyPathToBackup(sourcePath, backupRoot) {
424
424
  type: 'sqlite'
425
425
  }
426
426
  }
427
- fs.cpSync(sourcePath, backupPath, { recursive: true, force: true })
427
+ fs.cpSync(sourcePath, backupPath, { recursive: true, force: true, preserveTimestamps: true })
428
428
  return {
429
429
  source: sourcePath,
430
430
  backup: path.relative(backupRoot, backupPath),
@@ -441,7 +441,7 @@ function findSqlite3() {
441
441
  path.join(os.homedir(), 'Android', 'Sdk', 'platform-tools', 'sqlite3')
442
442
  ].filter(Boolean)
443
443
  for (const candidate of candidates) {
444
- const result = spawnSync(candidate, ['-version'], { encoding: 'utf8' })
444
+ const result = spawnSync(candidate, ['-version'], { encoding: 'utf8', timeout: 5000 })
445
445
  if (result.status === 0) return candidate
446
446
  }
447
447
  return ''
@@ -452,11 +452,27 @@ function sqliteBackup(sourcePath, backupPath) {
452
452
  if (!sqlite3) return false
453
453
  ensureDir(path.dirname(backupPath))
454
454
  const result = spawnSync(sqlite3, [sourcePath, `.backup '${backupPath.replaceAll("'", "''")}'`], {
455
- encoding: 'utf8'
455
+ encoding: 'utf8',
456
+ timeout: 30000
456
457
  })
457
458
  return result.status === 0 && fileExists(backupPath)
458
459
  }
459
460
 
461
+ function runSqliteScript(sqlite3, dbPath, script) {
462
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iivo-sub-sqlite-'))
463
+ const scriptPath = path.join(tempDir, 'script.sql')
464
+ try {
465
+ fs.writeFileSync(scriptPath, script, 'utf8')
466
+ return spawnSync(sqlite3, [dbPath, `.read ${scriptPath}`], {
467
+ encoding: 'utf8',
468
+ maxBuffer: 10 * 1024 * 1024,
469
+ timeout: 30000
470
+ })
471
+ } finally {
472
+ deletePath(tempDir)
473
+ }
474
+ }
475
+
460
476
  function deleteSqliteSidecars(sqlitePath) {
461
477
  for (const suffix of ['-wal', '-shm']) {
462
478
  const sidecar = `${sqlitePath}${suffix}`
@@ -502,14 +518,18 @@ function titleFromCodexSession(filePath) {
502
518
  }
503
519
  const payload = item?.payload
504
520
  if (payload?.type === 'user_message' && payload.message) {
505
- return truncateTitle(payload.message)
521
+ const title = truncateTitle(payload.message)
522
+ if (title) return title
506
523
  }
507
524
  if (payload?.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) {
508
525
  const text = payload.content
509
526
  .map((part) => part?.text || part?.input_text || '')
510
527
  .filter(Boolean)
511
528
  .join(' ')
512
- if (text) return truncateTitle(text)
529
+ if (text) {
530
+ const title = truncateTitle(text)
531
+ if (title) return title
532
+ }
513
533
  }
514
534
  }
515
535
  } catch {
@@ -520,11 +540,12 @@ function titleFromCodexSession(filePath) {
520
540
 
521
541
  function codexSessionSummary(filePath) {
522
542
  const stat = fs.statSync(filePath)
543
+ const fileCreatedAt = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs
523
544
  const id = codexSessionIDFromPath(filePath)
524
545
  const summary = {
525
546
  id,
526
547
  rollout_path: filePath,
527
- created_at: stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs,
548
+ created_at: fileCreatedAt,
528
549
  updated_at: stat.mtimeMs,
529
550
  source: 'cli',
530
551
  model_provider: 'openai',
@@ -540,6 +561,8 @@ function codexSessionSummary(filePath) {
540
561
  }
541
562
 
542
563
  try {
564
+ let firstEventAt = 0
565
+ let latestEventAt = 0
543
566
  const content = fs.readFileSync(filePath, 'utf8')
544
567
  for (const line of content.split(/\r?\n/)) {
545
568
  if (!line.trim()) continue
@@ -552,8 +575,8 @@ function codexSessionSummary(filePath) {
552
575
  if (typeof item.timestamp === 'string') {
553
576
  const ts = Date.parse(item.timestamp)
554
577
  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
578
+ if (!firstEventAt || ts < firstEventAt) firstEventAt = ts
579
+ if (ts > latestEventAt) latestEventAt = ts
557
580
  }
558
581
  }
559
582
  const payload = item?.payload
@@ -573,16 +596,29 @@ function codexSessionSummary(filePath) {
573
596
  if (payload.approval_policy) summary.approval_mode = String(payload.approval_policy)
574
597
  }
575
598
  if (!summary.first_user_message && payload?.type === 'user_message' && payload.message) {
576
- summary.first_user_message = truncateTitle(payload.message)
599
+ const title = truncateTitle(payload.message)
600
+ if (title) summary.first_user_message = title
577
601
  }
578
602
  if (!summary.first_user_message && payload?.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) {
579
603
  const text = payload.content
580
604
  .map((part) => part?.text || part?.input_text || '')
581
605
  .filter(Boolean)
582
606
  .join(' ')
583
- if (text) summary.first_user_message = truncateTitle(text)
607
+ if (text) {
608
+ const title = truncateTitle(text)
609
+ if (title) summary.first_user_message = title
610
+ }
584
611
  }
585
612
  }
613
+ if (firstEventAt && (!summary.created_at || firstEventAt < summary.created_at)) {
614
+ summary.created_at = firstEventAt
615
+ }
616
+ if (latestEventAt) {
617
+ summary.updated_at = latestEventAt
618
+ }
619
+ if (summary.created_at > summary.updated_at) {
620
+ summary.created_at = summary.updated_at
621
+ }
586
622
  } catch {
587
623
  // Keep filesystem-derived fallback fields.
588
624
  }
@@ -675,7 +711,10 @@ function rebuildCodexThreadsState() {
675
711
 
676
712
  let total = 0
677
713
  for (const dbPath of stateDbs) {
678
- const tableCheck = spawnSync(sqlite3, [dbPath, "select name from sqlite_master where type='table' and name='threads';"], { encoding: 'utf8' })
714
+ const tableCheck = spawnSync(sqlite3, [dbPath, "select name from sqlite_master where type='table' and name='threads';"], {
715
+ encoding: 'utf8',
716
+ timeout: 10000
717
+ })
679
718
  if (tableCheck.status !== 0 || !tableCheck.stdout.includes('threads')) continue
680
719
 
681
720
  if (fileExists(dbPath)) {
@@ -704,11 +743,7 @@ ${createdMs}, ${updatedMs}, ${row.thread_source ? sqlString(row.thread_source) :
704
743
  }
705
744
  statements.push('COMMIT;')
706
745
 
707
- const result = spawnSync(sqlite3, [dbPath], {
708
- input: statements.join('\n'),
709
- encoding: 'utf8',
710
- maxBuffer: 10 * 1024 * 1024
711
- })
746
+ const result = runSqliteScript(sqlite3, dbPath, statements.join('\n'))
712
747
  if (result.status === 0) total += rows.length
713
748
  }
714
749
  return { rebuilt: total, reason: total > 0 ? '' : 'threads table not rebuilt' }
@@ -1294,7 +1329,7 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1294
1329
  if (kind === 'chat' && source.endsWith('.sqlite')) {
1295
1330
  deleteSqliteSidecars(source)
1296
1331
  }
1297
- fs.cpSync(backupPath, source, { recursive: true, force: true })
1332
+ fs.cpSync(backupPath, source, { recursive: true, force: true, preserveTimestamps: true })
1298
1333
  if (kind === 'chat' && source.endsWith('.sqlite')) {
1299
1334
  deleteSqliteSidecars(source)
1300
1335
  }
@@ -1309,7 +1344,7 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
1309
1344
 
1310
1345
  let rebuiltCodexIndexCount = 0
1311
1346
  let rebuiltCodexThreads = { rebuilt: 0, reason: '' }
1312
- if (restoredCodexSessions && !restoredCodexIndex) {
1347
+ if (restoredCodexSessions) {
1313
1348
  rebuiltCodexIndexCount = rebuildCodexSessionIndex()
1314
1349
  }
1315
1350
  if (kind === 'chat' && restoredCodexSessions) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iivo-sub",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "IIVO AI gateway quick configuration CLI",
5
5
  "type": "module",
6
6
  "bin": {