ei-tui 1.6.1 → 1.6.3

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.
@@ -25,7 +25,7 @@ export interface Message {
25
25
  _synthesis?: boolean; // True if message was created by multi-message synthesis
26
26
  speaker_name?: string; // Display name of actual speaker; set on room messages for clean hydration
27
27
 
28
- external?: boolean; // Set by integration importers (OpenCode, Cursor, Claude Code); invisible to LLM context
28
+ external?: boolean; // Set by integration importers (OpenCode, Cursor, Claude Code, Codex); invisible to LLM context
29
29
 
30
30
  }
31
31
 
@@ -4,6 +4,7 @@
4
4
  * opencode:${machine}:${session}:${nativeId}
5
5
  * claudecode:${machine}:${session}:${nativeId}
6
6
  * cursor:${machine}:${session}:${nativeId}
7
+ * codex:${machine}:${session}:${nativeId}
7
8
  * import:document:${slug}:${uuid}
8
9
  * slack:${workspace}:${channel}:${ts}
9
10
  */
@@ -13,6 +14,8 @@ export type MessageIdIntegration =
13
14
  | "opencode"
14
15
  | "claudecode"
15
16
  | "cursor"
17
+ | "codex"
18
+ | "pi"
16
19
  | "import"
17
20
  | "slack"
18
21
  | "unknown"
@@ -67,6 +70,26 @@ export function parseMessageId(id: string): ParsedMessageId {
67
70
  }
68
71
  }
69
72
 
73
+ if (parts[0] === "codex" && parts.length >= 4) {
74
+ return {
75
+ integration: "codex",
76
+ machine: parts[1],
77
+ session: parts[2],
78
+ nativeId: parts.slice(3).join(":"),
79
+ raw: id,
80
+ }
81
+ }
82
+
83
+ if (parts[0] === "pi" && parts.length >= 4) {
84
+ return {
85
+ integration: "pi",
86
+ machine: parts[1],
87
+ session: parts[2],
88
+ nativeId: parts.slice(3).join(":"),
89
+ raw: id,
90
+ }
91
+ }
92
+
70
93
  if (parts[0] === "import" && parts[1] === "document" && parts.length >= 4) {
71
94
  return {
72
95
  integration: "import",
@@ -109,6 +132,14 @@ export function qualifyCursorMessage(machine: string, sessionId: string, nativeI
109
132
  return `cursor:${machine}:${sessionId}:${nativeId}`
110
133
  }
111
134
 
135
+ export function qualifyCodexMessage(machine: string, sessionId: string, nativeId: string): string {
136
+ return `codex:${machine}:${sessionId}:${nativeId}`
137
+ }
138
+
139
+ export function qualifyPiMessage(machine: string, sessionId: string, nativeId: string): string {
140
+ return `pi:${machine}:${sessionId}:${nativeId}`
141
+ }
142
+
112
143
  export function qualifyDocumentMessage(slug: string, uuid: string): string {
113
144
  return `import:document:${slug}:${uuid}`
114
145
  }
@@ -1,11 +1,9 @@
1
1
  import type { StateManager } from "../../core/state-manager.js";
2
- import type { Ei_Interface, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
2
+ import type { Ei_Interface, Message, PersonaEntity, PersonaTrait } from "../../core/types.js";
3
3
  import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
4
- import type { IClaudeCodeReader, ClaudeCodeSession, ClaudeCodeMessage } from "./types.js";
5
- import {
6
- CLAUDE_CODE_PERSONA_NAME,
7
- MIN_SESSION_AGE_MS,
8
- } from "./types.js";
4
+ import type { IClaudeCodeReader, ClaudeCodeSession } from "./types.js";
5
+ import { CLAUDE_CODE_PERSONA_NAME } from "./types.js";
6
+ import { MIN_SESSION_AGE_MS } from "../constants.js";
9
7
  import { ClaudeCodeReader } from "./reader.js";
10
8
  import {
11
9
  queueAllScans,
@@ -16,8 +14,10 @@ import {
16
14
  queueTopicRewritePhase,
17
15
  } from "../../core/orchestrators/ceremony.js";
18
16
  import { isProcessRunning } from "../process-check.js";
19
- import { getMachineId } from "../machine-id.js";
20
17
  import { qualifyClaudeCodeMessage } from "../../core/utils/message-id.js";
18
+ import { getMachineId } from "../machine-id.js";
19
+ import { convertToEiMessage, convertToPreMarkedEiMessage } from "../shared/message-converter.js";
20
+ import { TWELVE_HOURS_MS } from "../constants.js";
21
21
 
22
22
  // =============================================================================
23
23
  // Export Types
@@ -41,30 +41,9 @@ export interface ClaudeCodeImporterOptions {
41
41
  // Utility Functions
42
42
  // =============================================================================
43
43
 
44
- const TWELVE_HOURS_MS = 43_200_000;
45
44
  const CLAUDE_CODE_GROUP = "Claude Code";
46
45
 
47
- function convertToEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
48
- return {
49
- id: qualifyClaudeCodeMessage(getMachineId(), sessionId, msg.id),
50
- role: msg.role === "user" ? "human" : "system",
51
- content: msg.content,
52
- timestamp: msg.timestamp,
53
- read: true,
54
- context_status: "default" as ContextStatus,
55
- external: true,
56
- };
57
- }
58
-
59
- function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
60
- return {
61
- ...convertToEiMessage(msg, sessionId),
62
- f: true,
63
- t: true,
64
- p: true,
65
- e: true,
66
- };
67
- }
46
+ const qualify = qualifyClaudeCodeMessage;
68
47
 
69
48
  /**
70
49
  * Ensure the single "Claude Code" persona exists.
@@ -249,7 +228,7 @@ export async function importClaudeCodeSessions(
249
228
  for (const msg of messages) {
250
229
  const msgMs = new Date(msg.timestamp).getTime();
251
230
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
252
- const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
231
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify) : convertToEiMessage(msg, targetSession.id, qualify);
253
232
  stateManager.messages_append(persona.id, eiMsg);
254
233
  result.messagesImported++;
255
234
  if (!isOld) toAnalyze.push(eiMsg);
@@ -139,7 +139,7 @@ export const CLAUDE_CODE_TOPIC_GROUPS = ["General", "Coding", "Claude Code"];
139
139
  * Minimum session age before we import it.
140
140
  * Mirrors OpenCode's 20-minute rule — gives the session time to "settle."
141
141
  */
142
- export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
142
+ export { MIN_SESSION_AGE_MS } from "../constants.js";
143
143
 
144
144
  // ============================================================================
145
145
  // Human Settings Shape (mirrors OpenCodeSettings in core/types.ts)
@@ -0,0 +1,237 @@
1
+ import type { StateManager } from "../../core/state-manager.js";
2
+ import type { Ei_Interface, Message, PersonaEntity, PersonaTrait } from "../../core/types.js";
3
+ import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
4
+ import {
5
+ queueAllScans,
6
+ type ExtractionContext,
7
+ } from "../../core/orchestrators/human-extraction.js";
8
+ import {
9
+ queuePersonRewritePhase,
10
+ queueTopicRewritePhase,
11
+ } from "../../core/orchestrators/ceremony.js";
12
+ import { qualifyCodexMessage } from "../../core/utils/message-id.js";
13
+ import { convertToEiMessage, convertToPreMarkedEiMessage } from "../shared/message-converter.js";
14
+ import { getMachineId } from "../machine-id.js";
15
+ import { isProcessRunning } from "../process-check.js";
16
+ import { CodexReader } from "./reader.js";
17
+ import {
18
+ CODEX_PERSONA_NAME,
19
+ type CodexSession,
20
+ type ICodexReader,
21
+ } from "./types.js";
22
+ import { MIN_SESSION_AGE_MS, TWELVE_HOURS_MS } from "../constants.js";
23
+
24
+ export interface CodexImportResult {
25
+ sessionsProcessed: number;
26
+ messagesImported: number;
27
+ personaCreated: boolean;
28
+ extractionScansQueued: number;
29
+ }
30
+
31
+ export interface CodexImporterOptions {
32
+ stateManager: StateManager;
33
+ interface?: Ei_Interface;
34
+ reader?: ICodexReader;
35
+ signal?: AbortSignal;
36
+ }
37
+
38
+ const CODEX_GROUP = "Codex";
39
+
40
+ const qualify = qualifyCodexMessage;
41
+
42
+ function ensureCodexPersona(
43
+ stateManager: StateManager,
44
+ eiInterface?: Ei_Interface
45
+ ): PersonaEntity {
46
+ const existing = stateManager.persona_getByName(CODEX_PERSONA_NAME);
47
+ if (existing) return existing;
48
+
49
+ const now = new Date().toISOString();
50
+ const seedTraits: PersonaTrait[] = DEFAULT_SEED_TRAITS.map((t) => ({
51
+ id: crypto.randomUUID(),
52
+ name: t.name,
53
+ description: t.description,
54
+ sentiment: t.sentiment,
55
+ strength: t.strength,
56
+ last_updated: now,
57
+ }));
58
+
59
+ const persona: PersonaEntity = {
60
+ id: crypto.randomUUID(),
61
+ display_name: CODEX_PERSONA_NAME,
62
+ entity: "system",
63
+ aliases: ["codex", "codex cli", "codex desktop", "openai codex"],
64
+ short_description: "Codex - OpenAI coding agent environment",
65
+ long_description:
66
+ "Codex is OpenAI's coding agent environment for working with local codebases, terminal commands, tools, and implementation tasks.",
67
+ group_primary: CODEX_GROUP,
68
+ groups_visible: [CODEX_GROUP],
69
+ traits: seedTraits,
70
+ topics: [],
71
+ is_paused: false,
72
+ is_archived: false,
73
+ is_static: false,
74
+ heartbeat_delay_ms: TWELVE_HOURS_MS,
75
+ last_heartbeat: now,
76
+ last_updated: now,
77
+ };
78
+
79
+ stateManager.persona_add(persona);
80
+ eiInterface?.onPersonaAdded?.();
81
+ return persona;
82
+ }
83
+
84
+ function updateProcessedState(
85
+ stateManager: StateManager,
86
+ session: CodexSession
87
+ ): void {
88
+ const human = stateManager.getHuman();
89
+ const lastMessageMs = new Date(session.lastMessageAt).getTime();
90
+ const extractionPoint = human.settings?.codex?.extraction_point;
91
+ const currentPointMs = extractionPoint ? new Date(extractionPoint).getTime() : 0;
92
+ const newPointMs = Math.max(currentPointMs, lastMessageMs);
93
+
94
+ const processedSessions = {
95
+ ...(human.settings?.codex?.processed_sessions ?? {}),
96
+ [session.id]: new Date().toISOString(),
97
+ };
98
+
99
+ stateManager.setHuman({
100
+ ...human,
101
+ settings: {
102
+ ...human.settings,
103
+ codex: {
104
+ ...human.settings?.codex,
105
+ extraction_point: new Date(newPointMs).toISOString(),
106
+ processed_sessions: processedSessions,
107
+ },
108
+ },
109
+ });
110
+ }
111
+
112
+ async function isCodexRunning(): Promise<boolean> {
113
+ return (await isProcessRunning("Codex")) || (await isProcessRunning("codex"));
114
+ }
115
+
116
+ export async function importCodexSessions(
117
+ options: CodexImporterOptions
118
+ ): Promise<CodexImportResult> {
119
+ const { stateManager, interface: eiInterface, signal } = options;
120
+ const reader = options.reader ?? new CodexReader();
121
+
122
+ const result: CodexImportResult = {
123
+ sessionsProcessed: 0,
124
+ messagesImported: 0,
125
+ personaCreated: false,
126
+ extractionScansQueued: 0,
127
+ };
128
+
129
+ const allSessions = await reader.getSessions();
130
+ if (signal?.aborted) return result;
131
+
132
+ const human = stateManager.getHuman();
133
+ const processedSessions = human.settings?.codex?.processed_sessions ?? {};
134
+ const now = Date.now();
135
+ const toolRunning = await isCodexRunning();
136
+
137
+ let targetSession: CodexSession | null = null;
138
+
139
+ for (const session of allSessions) {
140
+ const sessionLastMs = new Date(session.lastMessageAt).getTime();
141
+ const ageMs = now - sessionLastMs;
142
+
143
+ if (ageMs < MIN_SESSION_AGE_MS && toolRunning) continue;
144
+
145
+ const lastImported = processedSessions[session.id];
146
+ if (lastImported && sessionLastMs <= new Date(lastImported).getTime()) continue;
147
+
148
+ targetSession = session;
149
+ break;
150
+ }
151
+
152
+ if (!targetSession) {
153
+ console.log("[Codex] All sessions processed, nothing new to import");
154
+ return result;
155
+ }
156
+
157
+ if (signal?.aborted) return result;
158
+
159
+ console.log(
160
+ `[Codex] Processing session: "${targetSession.title}" ` +
161
+ `(last message: ${targetSession.lastMessageAt})`
162
+ );
163
+
164
+ const messages = targetSession.messages;
165
+ if (messages.length === 0) {
166
+ updateProcessedState(stateManager, targetSession);
167
+ return result;
168
+ }
169
+
170
+ if (signal?.aborted) return result;
171
+
172
+ const personaExistedBefore = stateManager.persona_getByName(CODEX_PERSONA_NAME) !== null;
173
+ const persona = ensureCodexPersona(stateManager, eiInterface);
174
+ result.personaCreated = !personaExistedBefore;
175
+
176
+ if (!personaExistedBefore) {
177
+ stateManager.persona_archive(persona.id);
178
+ } else {
179
+ const existingMsgs = stateManager.messages_get(persona.id);
180
+ const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
181
+ if (externalIds.length > 0) {
182
+ stateManager.messages_remove(persona.id, externalIds);
183
+ }
184
+ }
185
+
186
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
187
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
188
+ const toAnalyze: Message[] = [];
189
+
190
+ for (const msg of messages) {
191
+ const msgMs = new Date(msg.timestamp).getTime();
192
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
193
+ const eiMsg = isOld
194
+ ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
195
+ : convertToEiMessage(msg, targetSession.id, qualify);
196
+
197
+ stateManager.messages_append(persona.id, eiMsg);
198
+ result.messagesImported++;
199
+ if (!isOld) toAnalyze.push(eiMsg);
200
+ }
201
+
202
+ stateManager.messages_sort(persona.id);
203
+ eiInterface?.onMessageAdded?.(persona.id);
204
+
205
+ if (toAnalyze.length > 0 && !signal?.aborted) {
206
+ const allInState = stateManager.messages_get(persona.id);
207
+ const analyzeIds = new Set(toAnalyze.map((m) => m.id));
208
+ const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
209
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
210
+
211
+ const context: ExtractionContext = {
212
+ personaId: persona.id,
213
+ channelDisplayName: persona.display_name,
214
+ messages_context: contextMsgs,
215
+ messages_analyze: toAnalyze,
216
+ sources: [`codex:${getMachineId()}:${targetSession.id}`],
217
+ };
218
+
219
+ queuePersonRewritePhase(stateManager);
220
+ queueTopicRewritePhase(stateManager);
221
+ queueAllScans(context, stateManager, {
222
+ extraction_model: human.settings?.codex?.extraction_model,
223
+ external_filter: "only",
224
+ });
225
+ result.extractionScansQueued += 4;
226
+ }
227
+
228
+ result.sessionsProcessed = 1;
229
+ updateProcessedState(stateManager, targetSession);
230
+
231
+ console.log(
232
+ `[Codex] Session complete: ${result.messagesImported} messages imported, ` +
233
+ `${result.extractionScansQueued} extraction scans queued`
234
+ );
235
+
236
+ return result;
237
+ }
@@ -0,0 +1,11 @@
1
+ export { CodexReader } from "./reader.js";
2
+ export { importCodexSessions } from "./importer.js";
3
+ export type { CodexImportResult, CodexImporterOptions } from "./importer.js";
4
+ export type {
5
+ CodexMessage,
6
+ CodexMessageWindow,
7
+ CodexSession,
8
+ CodexSettings,
9
+ ICodexReader,
10
+ } from "./types.js";
11
+ export { CODEX_PERSONA_NAME } from "./types.js";
@@ -0,0 +1,241 @@
1
+ import type {
2
+ CodexMessage,
3
+ CodexMessageWindow,
4
+ CodexRolloutRecord,
5
+ CodexSession,
6
+ CodexThreadRow,
7
+ ICodexReader,
8
+ } from "./types.js";
9
+
10
+ const isBrowser = typeof document !== "undefined";
11
+
12
+ let _join: typeof import("path").join;
13
+ let _basename: typeof import("path").basename;
14
+ let _readFile: typeof import("fs/promises").readFile;
15
+ let _readdir: typeof import("fs/promises").readdir;
16
+ let _stat: typeof import("fs/promises").stat;
17
+ let _nodeModulesLoaded = false;
18
+
19
+ async function ensureNodeModules(): Promise<boolean> {
20
+ if (isBrowser) return false;
21
+ if (_nodeModulesLoaded) return true;
22
+
23
+ const PATH_MODULE = "path";
24
+ const FS_MODULE = "fs/promises";
25
+
26
+ const pathMod = await import(/* @vite-ignore */ PATH_MODULE);
27
+ const fsMod = await import(/* @vite-ignore */ FS_MODULE);
28
+
29
+ _join = pathMod.join;
30
+ _basename = pathMod.basename;
31
+ _readFile = fsMod.readFile;
32
+ _readdir = fsMod.readdir;
33
+ _stat = fsMod.stat;
34
+ _nodeModulesLoaded = true;
35
+ return true;
36
+ }
37
+
38
+ function getDefaultCodexHome(): string {
39
+ if (!_join) return "";
40
+ return process.env.CODEX_HOME || _join(process.env.HOME || "~", ".codex");
41
+ }
42
+
43
+ function titleFromCwd(cwd: string): string {
44
+ if (!cwd) return "Codex Session";
45
+ const parts = cwd.replace(/\\/g, "/").split("/").filter(Boolean);
46
+ return parts[parts.length - 1] ?? cwd;
47
+ }
48
+
49
+ function timestampFromMs(value: number | null | undefined): string | null {
50
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
51
+ return new Date(value).toISOString();
52
+ }
53
+
54
+ function extractEventMessage(record: CodexRolloutRecord, lineIndex: number, sessionId: string): CodexMessage | null {
55
+ if (record.type !== "event_msg") return null;
56
+
57
+ const payload = record.payload ?? {};
58
+ const payloadType = payload.type;
59
+ if (payloadType !== "user_message" && payloadType !== "agent_message") return null;
60
+
61
+ const rawMessage = payload.message;
62
+ if (typeof rawMessage !== "string" || rawMessage.trim() === "") return null;
63
+
64
+ const timestamp = typeof record.timestamp === "string" && record.timestamp.trim()
65
+ ? record.timestamp
66
+ : new Date(0).toISOString();
67
+
68
+ return {
69
+ id: `evt_${lineIndex + 1}`,
70
+ sessionId,
71
+ role: payloadType === "user_message" ? "user" : "assistant",
72
+ content: rawMessage.trim(),
73
+ timestamp,
74
+ };
75
+ }
76
+
77
+ export function parseCodexRolloutMessages(text: string, sessionId: string): CodexMessage[] {
78
+ const messages: CodexMessage[] = [];
79
+ const lines = text.split("\n");
80
+
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const trimmed = lines[i].trim();
83
+ if (!trimmed) continue;
84
+
85
+ let record: CodexRolloutRecord;
86
+ try {
87
+ record = JSON.parse(trimmed) as CodexRolloutRecord;
88
+ } catch {
89
+ continue;
90
+ }
91
+
92
+ const message = extractEventMessage(record, i, sessionId);
93
+ if (message) messages.push(message);
94
+ }
95
+
96
+ return messages.sort(
97
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
98
+ );
99
+ }
100
+
101
+ export class CodexReader implements ICodexReader {
102
+ private readonly codexHome?: string;
103
+
104
+ constructor(codexHome?: string) {
105
+ this.codexHome = codexHome;
106
+ }
107
+
108
+ async isAvailable(): Promise<boolean> {
109
+ if (!(await ensureNodeModules())) return false;
110
+ const dbPath = await this.findStateDbPath();
111
+ if (!dbPath) return false;
112
+
113
+ try {
114
+ return (await _stat(dbPath)).isFile();
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ async getSessions(): Promise<CodexSession[]> {
121
+ if (!(await ensureNodeModules())) return [];
122
+
123
+ const dbPath = await this.findStateDbPath();
124
+ if (!dbPath) return [];
125
+
126
+ let db: import("bun:sqlite").Database;
127
+ try {
128
+ const { Database } = await import(/* @vite-ignore */ "bun:sqlite");
129
+ db = new Database(dbPath, { readonly: true });
130
+ } catch (err) {
131
+ console.warn("[CodexReader] failed to open state DB:", err);
132
+ return [];
133
+ }
134
+
135
+ let rows: CodexThreadRow[];
136
+ try {
137
+ rows = db.query("SELECT * FROM threads WHERE rollout_path IS NOT NULL AND rollout_path != ''").all() as CodexThreadRow[];
138
+ } catch (err) {
139
+ console.warn("[CodexReader] failed to read threads table:", err);
140
+ db.close();
141
+ return [];
142
+ }
143
+
144
+ db.close();
145
+
146
+ const sessions: CodexSession[] = [];
147
+ for (const row of rows) {
148
+ if (!row.id || !row.rollout_path) continue;
149
+ const session = await this.sessionFromThreadRow(row);
150
+ if (session) sessions.push(session);
151
+ }
152
+
153
+ return sessions.sort(
154
+ (a, b) => new Date(a.lastMessageAt).getTime() - new Date(b.lastMessageAt).getTime()
155
+ );
156
+ }
157
+
158
+ async getMessageById(
159
+ sessionId: string,
160
+ messageId: string,
161
+ before = 0,
162
+ after = 0
163
+ ): Promise<CodexMessageWindow | null> {
164
+ const sessions = await this.getSessions();
165
+ const session = sessions.find((s) => s.id === sessionId);
166
+ if (!session) return null;
167
+
168
+ const idx = session.messages.findIndex((m) => m.id === messageId);
169
+ if (idx === -1) return null;
170
+
171
+ return {
172
+ message: session.messages[idx],
173
+ before: session.messages.slice(Math.max(0, idx - before), idx),
174
+ after: session.messages.slice(idx + 1, idx + 1 + after),
175
+ session,
176
+ };
177
+ }
178
+
179
+ private async findStateDbPath(): Promise<string | null> {
180
+ const base = this.codexHome ?? getDefaultCodexHome();
181
+ let entries: string[];
182
+ try {
183
+ entries = await _readdir(base);
184
+ } catch {
185
+ return null;
186
+ }
187
+
188
+ const stateDbs = entries
189
+ .map((name) => {
190
+ const match = name.match(/^state_(\d+)\.sqlite$/);
191
+ return match ? { name, version: Number(match[1]) } : null;
192
+ })
193
+ .filter((entry): entry is { name: string; version: number } => entry !== null)
194
+ .sort((a, b) => b.version - a.version);
195
+
196
+ if (stateDbs.length > 0) return _join(base, stateDbs[0].name);
197
+ if (entries.includes("state.sqlite")) return _join(base, "state.sqlite");
198
+ return null;
199
+ }
200
+
201
+ private async sessionFromThreadRow(row: CodexThreadRow): Promise<CodexSession | null> {
202
+ const rolloutPath = row.rollout_path;
203
+ if (!rolloutPath) return null;
204
+
205
+ const messages = await this.readMessages(row.id, rolloutPath);
206
+ if (messages.length === 0) return null;
207
+
208
+ const first = messages[0];
209
+ const last = messages[messages.length - 1];
210
+ const title = row.title?.trim()
211
+ || row.first_user_message?.trim()
212
+ || titleFromCwd(row.cwd ?? "");
213
+
214
+ return {
215
+ id: row.id,
216
+ title,
217
+ cwd: row.cwd ?? "",
218
+ source: row.source ?? undefined,
219
+ threadSource: row.thread_source ?? undefined,
220
+ agentNickname: row.agent_nickname ?? undefined,
221
+ agentRole: row.agent_role ?? undefined,
222
+ agentPath: row.agent_path ?? undefined,
223
+ rolloutPath,
224
+ firstMessageAt: first.timestamp || timestampFromMs(row.created_at_ms) || timestampFromMs(row.created_at) || new Date(0).toISOString(),
225
+ lastMessageAt: last.timestamp || timestampFromMs(row.updated_at_ms) || timestampFromMs(row.updated_at) || new Date(0).toISOString(),
226
+ messages,
227
+ };
228
+ }
229
+
230
+ private async readMessages(sessionId: string, rolloutPath: string): Promise<CodexMessage[]> {
231
+ let text: string;
232
+ try {
233
+ text = await _readFile(rolloutPath, "utf-8");
234
+ } catch (err) {
235
+ console.warn(`[CodexReader] skipping missing rollout ${_basename(rolloutPath)}:`, err);
236
+ return [];
237
+ }
238
+
239
+ return parseCodexRolloutMessages(text, sessionId);
240
+ }
241
+ }