Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ DSH sessions can be exported back to Codex as rollout JSONL files and indexed in
Runtime package versions and default activation checks are described in [COMPATIBILITY.md](COMPATIBILITY.md).

Reverse export preserves user and assistant text; it does not reconstruct tool traces or the original thread id. Use the native DSH archive export to retain the stored event log.

Codex import titles are looked up by thread ID on each request: the current read-only `state_5.sqlite` name/title overrides `session_index.jsonl`, then rollout metadata, first user text, or the ID supplies a fallback. Native Node SQLite is used when available; older Node versions use the optional `sqlite3` CLI. Missing metadata does not prevent imports. Saved titles retain their full length; prompt-derived fallbacks remain limited to 80 characters. Existing DSH conversations are not renamed on re-import.
1 change: 1 addition & 0 deletions lib/codex-reader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function parseCodexSession(file) {
const payload = event.payload ?? {}
header.id = payload.id
header.cwd = payload.cwd
if (typeof payload.title === 'string' && payload.title.trim()) header.title = payload.title
// Some rollouts carry several session_meta lines (thread fork / resume);
// a parent_thread_id on ANY of them marks a sub-agent thread.
if (payload.parent_thread_id !== undefined) header.parentThreadId = payload.parent_thread_id
Expand Down
44 changes: 44 additions & 0 deletions lib/codex-titles.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** Read local Codex display titles once per import/catalog request. Never writes. */
import { existsSync, readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { join } from 'node:path'

const require = createRequire(import.meta.url)
const usable = value => typeof value === 'string' && value.trim().length > 0

export function readCodexTitles(codexHome) {
const titles = new Map()
try {
for (const line of readFileSync(join(codexHome, 'session_index.jsonl'), 'utf8').split('\n')) {
try {
const row = JSON.parse(line)
if (typeof row.id === 'string' && usable(row.thread_name)) titles.set(row.id, row.thread_name)
} catch { /* A partial or malformed line must not hide other entries. */ }
}
} catch { /* The index is optional. */ }

const path = join(codexHome, 'state_5.sqlite')
if (!existsSync(path)) return titles
let db
try {
// The desktop host supplies native SQLite; Node 20 can use the same CLI
// already used by the reverse exporter. Both paths open read-only.
let DatabaseSync
try { ({ DatabaseSync } = require('node:sqlite')) } catch {}
if (DatabaseSync) db = new DatabaseSync(path, { readOnly: true })
const query = sql => db ? db.prepare(sql).all() : JSON.parse(execFileSync('sqlite3', [
'-readonly', '-json', path, sql,
], { encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }) || '[]')
const columns = new Set(query('PRAGMA table_info(threads)').map(row => row.name))
if (!columns.has('id')) return titles
const fields = ['name', 'title'].filter(key => columns.has(key))
if (fields.length === 0) return titles
for (const row of query(`SELECT id, ${fields.join(', ')} FROM threads`)) {
const title = fields.map(key => row[key]).find(usable)
if (typeof row.id === 'string' && title !== undefined) titles.set(row.id, title)
}
} catch { /* Missing CLI, locked/unsupported DB: retain index/rollout fallbacks. */ }
finally { db?.close() }
return titles
}
4 changes: 2 additions & 2 deletions lib/convert.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function buildDshEvents(messages, options = {}) {
* @returns 规范化标题,无可用文本时 undefined。
*/
export function deriveImportTitle(sourceTitle, messages, sourceName) {
const clean = (text) => text.replace(/\s+/gu, ' ').trim().slice(0, 80)
const clean = (text) => text.replace(/\s+/gu, ' ').trim()
let title
if (typeof sourceTitle === 'string') {
title = clean(sourceTitle)
Expand All @@ -230,7 +230,7 @@ export function deriveImportTitle(sourceTitle, messages, sourceName) {
.map(block => block.text)
.join(' ')
.trim()
if (text.length > 0) title = clean(text)
if (text.length > 0) title = clean(text).slice(0, 80)
}
}
if (title === undefined) return undefined
Expand Down
7 changes: 5 additions & 2 deletions lib/import-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { statSync } from 'node:fs'
import { join } from 'node:path'

import { readCodexTitles } from './codex-titles.mjs'
import { listCodexSessions, parseCodexSession, isSubagentThread } from './codex-reader.mjs'
import { buildDshEvents, deriveImportTitle } from './convert.mjs'
import { migrateToCurrent } from './session-migrate.mjs'
Expand Down Expand Up @@ -208,6 +209,7 @@ export async function importCodex(ctx, persistence, options = {}, codexHome = jo
persistence = normalizePersistence(persistence)
const maxBytes = options.maxSessionBytes ?? DEFAULT_MAX_SESSION_BYTES
const dryRun = options.dryRun === true
const titles = readCodexTitles(codexHome)
const existing = new Set((await persistence.list()).map((meta) => meta.id))
const lines = []
if (dryRun) lines.push('[codex] dry-run: no sessions will be written')
Expand Down Expand Up @@ -296,7 +298,7 @@ export async function importCodex(ctx, persistence, options = {}, codexHome = jo
}
const events = buildDshEvents(messages, {
toolEvents: true,
title: deriveImportTitle(candidate.parsed.header.title, messages, 'codex'),
title: deriveImportTitle(titles.get(String(candidate.parsed.header.id)) ?? candidate.parsed.header.title, messages, 'codex'),
titlePinned: true,
}).map((event, seq) => ({ ...event, seq }))
// DSH 0.9.0 session-persistence now *writes* only the current (v3) format:
Expand Down Expand Up @@ -381,6 +383,7 @@ export async function listImportCatalog(persistence, options = {}, codexHome = j
persistence = normalizePersistence(persistence)
const maxBytes = options.maxSessionBytes ?? DEFAULT_MAX_SESSION_BYTES
const includeSubagents = options.importSubagents === true
const titles = readCodexTitles(codexHome)
const existing = new Set((await persistence.list()).map((meta) => meta.id))
const flat = []
let skippedBig = 0
Expand All @@ -407,7 +410,7 @@ export async function listImportCatalog(persistence, options = {}, codexHome = j
if (parsed.header.id === undefined) continue
const rawId = String(parsed.header.id)
const id = `codex-${rawId}`
const title = deriveImportTitle(parsed.header.title, parsed.messages, 'codex') ?? id
const title = deriveImportTitle(titles.get(rawId) ?? parsed.header.title, parsed.messages, 'codex') ?? id
const lastTime = parsed.messages.length > 0 ? (parsed.messages[parsed.messages.length - 1].time ?? 0) : 0
flat.push({
rawId,
Expand Down
1 change: 1 addition & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const suites = [
'test/host.smoke.mjs',
'test/client.render.mjs',
'test/codex-reader.test.mjs',
'test/codex-titles.test.mjs',
'test/import-service.test.mjs',
'test/plugin-skill.test.mjs',
'test/export-codex.test.mjs',
Expand Down
91 changes: 91 additions & 0 deletions test/codex-titles.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import { readCodexTitles } from '../lib/codex-titles.mjs'
import { listImportCatalog, importCodex } from '../lib/import-service.js'
import { parseCodexSession } from '../lib/codex-reader.mjs'

function home(t) {
const root = mkdtempSync(join(tmpdir(), 'codex-titles-'))
t.after(() => rmSync(root, { recursive: true, force: true }))
return root
}
function sql(root, statement) {
execFileSync('sqlite3', [join(root, 'state_5.sqlite'), statement])
}
function rollout(root, id, title, text = 'First prompt') {
const dir = join(root, 'sessions')
mkdirSync(dir, { recursive: true })
const file = join(dir, `rollout-${id}.jsonl`)
const events = [
{ type: 'session_meta', payload: { id, cwd: '/tmp/project', title, timestamp: '2026-10-20T00:00:00Z' } },
{ type: 'response_item', timestamp: '2026-10-20T00:00:01Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] } },
]
writeFileSync(file, events.map(x => JSON.stringify(x)).join('\n'))
return file
}
function dest() {
const store = new Map()
return { store, persistence: {
async list() { return [...store.values()].map(x => x.meta) },
async create(meta) { store.set(meta.id, { meta, events: [] }) },
async append(id, events) { store.get(id).events.push(...events) },
async inspect(id) { return store.get(id) },
} }
}

test('latest index entries survive malformed lines; current DB name/title take precedence', t => {
const root = home(t)
writeFileSync(join(root, 'session_index.jsonl'), [
{ id: 'a', thread_name: 'Old' }, { id: 'a', thread_name: 'Index renamed' },
{ id: 'b', thread_name: 'Index B' }, { id: 'c', thread_name: 'Index C' },
].map(x => JSON.stringify(x)).join('\n') + '\n{partial')
assert.equal(readCodexTitles(root).get('a'), 'Index renamed')
sql(root, "CREATE TABLE threads(id TEXT, name TEXT, title TEXT); INSERT INTO threads VALUES('a','Renamed 正式标题','Old DB'),('b',' ','DB B'),('c',NULL,'');")
const titles = readCodexTitles(root)
assert.equal(titles.get('a'), 'Renamed 正式标题')
assert.equal(titles.get('b'), 'DB B')
assert.equal(titles.get('c'), 'Index C')
sql(root, "UPDATE threads SET name='Changed again' WHERE id='a';")
assert.equal(readCodexTitles(root).get('a'), 'Changed again')
})

test('older DB schemas and corrupt/missing databases retain title fallbacks', t => {
const root = home(t)
assert.equal(readCodexTitles(root).size, 0)
sql(root, "CREATE TABLE threads(id TEXT,title TEXT); INSERT INTO threads VALUES('a','Legacy title');")
assert.equal(readCodexTitles(root).get('a'), 'Legacy title')
rmSync(join(root, 'state_5.sqlite'))
writeFileSync(join(root, 'state_5.sqlite'), 'invalid sqlite')
writeFileSync(join(root, 'session_index.jsonl'), JSON.stringify({ id: 'a', thread_name: 'Index title' }))
assert.equal(readCodexTitles(root).get('a'), 'Index title')
})

test('catalog and actual import use full saved titles; rollout, first-prompt and ID fallbacks remain', async t => {
const root = home(t)
const full = '正式标题'.repeat(30)
sql(root, `CREATE TABLE threads(id TEXT,title TEXT); INSERT INTO threads VALUES('a','${full}');`)
rollout(root, 'a', 'Old rollout title')
const file = rollout(root, 'b', 'Rollout title')
assert.equal(parseCodexSession(file).header.title, 'Rollout title')
rollout(root, 'c', undefined)
rollout(root, 'd', undefined, '')
const { persistence, store } = dest()
const catalog = await listImportCatalog(persistence, {}, root)
const names = new Map(catalog.projects.flatMap(p => p.sessions.map(n => [n.rawId, n.title])))
assert.equal(names.get('a'), '[codex] ' + full)
assert.equal(names.get('b'), '[codex] Rollout title')
assert.equal(names.get('c'), '[codex] First prompt')
assert.equal(names.get('d'), 'codex-d')
await importCodex({ get: () => undefined }, persistence, {}, root)
const title = store.get('codex-a').events.find(e => e.type === 'session/title')
assert.equal(title.data.title, '[codex] ' + full)
sql(root, "UPDATE threads SET title='Renamed later' WHERE id='a';")
const refreshed = await listImportCatalog(persistence, {}, root)
assert.equal(refreshed.projects[0].sessions.find(n => n.rawId === 'a').title, '[codex] Renamed later')
await importCodex({ get: () => undefined }, persistence, {}, root)
assert.equal(store.get('codex-a').events.filter(e => e.type === 'session/title').length, 1)
})