iivo-sub 0.1.3 → 0.1.5
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 +1 -1
- package/bin/iivo-sub.js +222 -5
- package/package.json +1 -1
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.
|
|
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
|
@@ -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.
|
|
9
|
+
const VERSION = '0.1.5'
|
|
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
|
|
327
|
+
...matchingHomeFiles('.codex', /^(?:state|goals|logs|memories)_\d+\.sqlite$/)
|
|
327
328
|
],
|
|
328
329
|
'claude-code': [
|
|
329
330
|
path.join(os.homedir(), '.claude', 'projects'),
|
|
@@ -416,7 +417,14 @@ 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))
|
|
419
|
-
|
|
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
|
+
}
|
|
427
|
+
fs.cpSync(sourcePath, backupPath, { recursive: true, force: true, preserveTimestamps: true })
|
|
420
428
|
return {
|
|
421
429
|
source: sourcePath,
|
|
422
430
|
backup: path.relative(backupRoot, backupPath),
|
|
@@ -424,6 +432,56 @@ 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', timeout: 5000 })
|
|
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
|
+
timeout: 30000
|
|
457
|
+
})
|
|
458
|
+
return result.status === 0 && fileExists(backupPath)
|
|
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
|
+
|
|
476
|
+
function deleteSqliteSidecars(sqlitePath) {
|
|
477
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
478
|
+
const sidecar = `${sqlitePath}${suffix}`
|
|
479
|
+
if (pathExists(sidecar)) {
|
|
480
|
+
deletePath(sidecar)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
427
485
|
function codexSessionIDFromPath(filePath) {
|
|
428
486
|
const base = path.basename(filePath, '.jsonl')
|
|
429
487
|
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 +534,92 @@ function titleFromCodexSession(filePath) {
|
|
|
476
534
|
return ''
|
|
477
535
|
}
|
|
478
536
|
|
|
537
|
+
function codexSessionSummary(filePath) {
|
|
538
|
+
const stat = fs.statSync(filePath)
|
|
539
|
+
const fileCreatedAt = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs
|
|
540
|
+
const id = codexSessionIDFromPath(filePath)
|
|
541
|
+
const summary = {
|
|
542
|
+
id,
|
|
543
|
+
rollout_path: filePath,
|
|
544
|
+
created_at: fileCreatedAt,
|
|
545
|
+
updated_at: stat.mtimeMs,
|
|
546
|
+
source: 'cli',
|
|
547
|
+
model_provider: 'openai',
|
|
548
|
+
cwd: os.homedir(),
|
|
549
|
+
title: '',
|
|
550
|
+
first_user_message: '',
|
|
551
|
+
cli_version: '',
|
|
552
|
+
sandbox_policy: '{"type":"workspace-write"}',
|
|
553
|
+
approval_mode: 'on-request',
|
|
554
|
+
model: '',
|
|
555
|
+
reasoning_effort: '',
|
|
556
|
+
thread_source: ''
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
let firstEventAt = 0
|
|
561
|
+
let latestEventAt = 0
|
|
562
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
563
|
+
for (const line of content.split(/\r?\n/)) {
|
|
564
|
+
if (!line.trim()) continue
|
|
565
|
+
let item
|
|
566
|
+
try {
|
|
567
|
+
item = JSON.parse(line)
|
|
568
|
+
} catch {
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
if (typeof item.timestamp === 'string') {
|
|
572
|
+
const ts = Date.parse(item.timestamp)
|
|
573
|
+
if (Number.isFinite(ts)) {
|
|
574
|
+
if (!firstEventAt || ts < firstEventAt) firstEventAt = ts
|
|
575
|
+
if (ts > latestEventAt) latestEventAt = ts
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const payload = item?.payload
|
|
579
|
+
if (item?.type === 'session_meta' && payload) {
|
|
580
|
+
if (payload.id && !summary.id) summary.id = String(payload.id)
|
|
581
|
+
if (payload.timestamp) {
|
|
582
|
+
const ts = Date.parse(payload.timestamp)
|
|
583
|
+
if (Number.isFinite(ts)) summary.created_at = ts
|
|
584
|
+
}
|
|
585
|
+
if (payload.cwd) summary.cwd = String(payload.cwd)
|
|
586
|
+
if (payload.source) summary.source = String(payload.source)
|
|
587
|
+
if (payload.model_provider) summary.model_provider = String(payload.model_provider)
|
|
588
|
+
if (payload.cli_version) summary.cli_version = String(payload.cli_version)
|
|
589
|
+
if (payload.model) summary.model = String(payload.model)
|
|
590
|
+
if (payload.reasoning_effort) summary.reasoning_effort = String(payload.reasoning_effort)
|
|
591
|
+
if (payload.sandbox_policy) summary.sandbox_policy = JSON.stringify(payload.sandbox_policy)
|
|
592
|
+
if (payload.approval_policy) summary.approval_mode = String(payload.approval_policy)
|
|
593
|
+
}
|
|
594
|
+
if (!summary.first_user_message && payload?.type === 'user_message' && payload.message) {
|
|
595
|
+
summary.first_user_message = truncateTitle(payload.message)
|
|
596
|
+
}
|
|
597
|
+
if (!summary.first_user_message && payload?.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) {
|
|
598
|
+
const text = payload.content
|
|
599
|
+
.map((part) => part?.text || part?.input_text || '')
|
|
600
|
+
.filter(Boolean)
|
|
601
|
+
.join(' ')
|
|
602
|
+
if (text) summary.first_user_message = truncateTitle(text)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (firstEventAt && (!summary.created_at || firstEventAt < summary.created_at)) {
|
|
606
|
+
summary.created_at = firstEventAt
|
|
607
|
+
}
|
|
608
|
+
if (latestEventAt) {
|
|
609
|
+
summary.updated_at = latestEventAt
|
|
610
|
+
}
|
|
611
|
+
if (summary.created_at > summary.updated_at) {
|
|
612
|
+
summary.created_at = summary.updated_at
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// Keep filesystem-derived fallback fields.
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
summary.title = summary.first_user_message || titleFromCodexSession(filePath) || summary.id || path.basename(filePath, '.jsonl')
|
|
619
|
+
if (!summary.first_user_message) summary.first_user_message = summary.title
|
|
620
|
+
return summary
|
|
621
|
+
}
|
|
622
|
+
|
|
479
623
|
function latestTimestampFromCodexSession(filePath) {
|
|
480
624
|
let latest = ''
|
|
481
625
|
try {
|
|
@@ -539,6 +683,64 @@ function rebuildCodexSessionIndex() {
|
|
|
539
683
|
return rows.length
|
|
540
684
|
}
|
|
541
685
|
|
|
686
|
+
function sqlString(value) {
|
|
687
|
+
return `'${String(value ?? '').replaceAll("'", "''")}'`
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function rebuildCodexThreadsState() {
|
|
691
|
+
const sqlite3 = findSqlite3()
|
|
692
|
+
if (!sqlite3) return { rebuilt: 0, reason: 'sqlite3 not found' }
|
|
693
|
+
|
|
694
|
+
const sessionsDir = path.join(os.homedir(), '.codex', 'sessions')
|
|
695
|
+
const files = listCodexSessionFiles(sessionsDir)
|
|
696
|
+
if (files.length === 0) return { rebuilt: 0, reason: 'no sessions' }
|
|
697
|
+
|
|
698
|
+
const stateDbs = matchingHomeFiles('.codex', /^state_\d+\.sqlite$/)
|
|
699
|
+
if (stateDbs.length === 0) return { rebuilt: 0, reason: 'state sqlite not found' }
|
|
700
|
+
|
|
701
|
+
const rows = files.map(codexSessionSummary).filter((row) => row.id)
|
|
702
|
+
if (rows.length === 0) return { rebuilt: 0, reason: 'no session ids' }
|
|
703
|
+
|
|
704
|
+
let total = 0
|
|
705
|
+
for (const dbPath of stateDbs) {
|
|
706
|
+
const tableCheck = spawnSync(sqlite3, [dbPath, "select name from sqlite_master where type='table' and name='threads';"], {
|
|
707
|
+
encoding: 'utf8',
|
|
708
|
+
timeout: 10000
|
|
709
|
+
})
|
|
710
|
+
if (tableCheck.status !== 0 || !tableCheck.stdout.includes('threads')) continue
|
|
711
|
+
|
|
712
|
+
if (fileExists(dbPath)) {
|
|
713
|
+
fs.copyFileSync(dbPath, `${dbPath}.${timestampForName()}.bak`)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const statements = ['BEGIN;', 'DELETE FROM threads;']
|
|
717
|
+
for (const row of rows) {
|
|
718
|
+
const createdMs = Math.trunc(row.created_at)
|
|
719
|
+
const updatedMs = Math.trunc(row.updated_at)
|
|
720
|
+
const created = Math.trunc(createdMs / 1000)
|
|
721
|
+
const updated = Math.trunc(updatedMs / 1000)
|
|
722
|
+
statements.push(`INSERT OR REPLACE INTO threads (
|
|
723
|
+
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
|
724
|
+
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
|
725
|
+
cli_version, first_user_message, memory_mode, model, reasoning_effort,
|
|
726
|
+
created_at_ms, updated_at_ms, thread_source, preview
|
|
727
|
+
) VALUES (
|
|
728
|
+
${sqlString(row.id)}, ${sqlString(row.rollout_path)}, ${created}, ${updated},
|
|
729
|
+
${sqlString(row.source)}, ${sqlString(row.model_provider)}, ${sqlString(row.cwd)}, ${sqlString(row.title)},
|
|
730
|
+
${sqlString(row.sandbox_policy)}, ${sqlString(row.approval_mode)}, 0, 1, 0,
|
|
731
|
+
${sqlString(row.cli_version)}, ${sqlString(row.first_user_message)}, 'enabled',
|
|
732
|
+
${row.model ? sqlString(row.model) : 'NULL'}, ${row.reasoning_effort ? sqlString(row.reasoning_effort) : 'NULL'},
|
|
733
|
+
${createdMs}, ${updatedMs}, ${row.thread_source ? sqlString(row.thread_source) : 'NULL'}, ${sqlString(row.title)}
|
|
734
|
+
);`)
|
|
735
|
+
}
|
|
736
|
+
statements.push('COMMIT;')
|
|
737
|
+
|
|
738
|
+
const result = runSqliteScript(sqlite3, dbPath, statements.join('\n'))
|
|
739
|
+
if (result.status === 0) total += rows.length
|
|
740
|
+
}
|
|
741
|
+
return { rebuilt: total, reason: total > 0 ? '' : 'threads table not rebuilt' }
|
|
742
|
+
}
|
|
743
|
+
|
|
542
744
|
function loadBackupManifest(dir) {
|
|
543
745
|
const manifestPath = path.join(dir, 'manifest.json')
|
|
544
746
|
if (!fileExists(manifestPath)) return null
|
|
@@ -1116,7 +1318,13 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
1116
1318
|
if (pathExists(source)) {
|
|
1117
1319
|
deletePath(source)
|
|
1118
1320
|
}
|
|
1119
|
-
|
|
1321
|
+
if (kind === 'chat' && source.endsWith('.sqlite')) {
|
|
1322
|
+
deleteSqliteSidecars(source)
|
|
1323
|
+
}
|
|
1324
|
+
fs.cpSync(backupPath, source, { recursive: true, force: true, preserveTimestamps: true })
|
|
1325
|
+
if (kind === 'chat' && source.endsWith('.sqlite')) {
|
|
1326
|
+
deleteSqliteSidecars(source)
|
|
1327
|
+
}
|
|
1120
1328
|
restored.push(source)
|
|
1121
1329
|
if (kind === 'chat' && source === codexSessionsPath) {
|
|
1122
1330
|
restoredCodexSessions = true
|
|
@@ -1127,9 +1335,13 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
1127
1335
|
}
|
|
1128
1336
|
|
|
1129
1337
|
let rebuiltCodexIndexCount = 0
|
|
1130
|
-
|
|
1338
|
+
let rebuiltCodexThreads = { rebuilt: 0, reason: '' }
|
|
1339
|
+
if (restoredCodexSessions) {
|
|
1131
1340
|
rebuiltCodexIndexCount = rebuildCodexSessionIndex()
|
|
1132
1341
|
}
|
|
1342
|
+
if (kind === 'chat' && restoredCodexSessions) {
|
|
1343
|
+
rebuiltCodexThreads = rebuildCodexThreadsState()
|
|
1344
|
+
}
|
|
1133
1345
|
|
|
1134
1346
|
clear()
|
|
1135
1347
|
banner()
|
|
@@ -1143,6 +1355,11 @@ async function restoreBackupByKind(kind, title, emptyMessage) {
|
|
|
1143
1355
|
} else if (restoredCodexIndex) {
|
|
1144
1356
|
console.log(c('cyan', '已恢复 Codex 任务索引。'))
|
|
1145
1357
|
}
|
|
1358
|
+
if (rebuiltCodexThreads.rebuilt > 0) {
|
|
1359
|
+
console.log(c('cyan', `已重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.rebuilt} 条`))
|
|
1360
|
+
} else if (rebuiltCodexThreads.reason) {
|
|
1361
|
+
console.log(c('yellow', `未重建 Codex sqlite 任务列表: ${rebuiltCodexThreads.reason}`))
|
|
1362
|
+
}
|
|
1146
1363
|
console.log()
|
|
1147
1364
|
console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
|
|
1148
1365
|
await pause()
|