ei-tui 0.1.10 → 0.1.13

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.
@@ -0,0 +1,323 @@
1
+ import type { StateManager } from "../../core/state-manager.js";
2
+ import type { Ei_Interface, Topic, Message, ContextStatus, PersonaEntity } from "../../core/types.js";
3
+ import type { IClaudeCodeReader, ClaudeCodeSession, ClaudeCodeMessage } from "./types.js";
4
+ import {
5
+ CLAUDE_CODE_PERSONA_NAME,
6
+ CLAUDE_CODE_TOPIC_GROUPS,
7
+ MIN_SESSION_AGE_MS,
8
+ } from "./types.js";
9
+ import { ClaudeCodeReader } from "./reader.js";
10
+ import {
11
+ queueAllScans,
12
+ type ExtractionContext,
13
+ } from "../../core/orchestrators/human-extraction.js";
14
+
15
+ // =============================================================================
16
+ // Export Types
17
+ // =============================================================================
18
+
19
+ export interface ClaudeCodeImportResult {
20
+ sessionsProcessed: number;
21
+ topicsCreated: number;
22
+ topicsUpdated: number;
23
+ messagesImported: number;
24
+ personaCreated: boolean;
25
+ extractionScansQueued: number;
26
+ }
27
+
28
+ export interface ClaudeCodeImporterOptions {
29
+ stateManager: StateManager;
30
+ interface?: Ei_Interface;
31
+ reader?: IClaudeCodeReader;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ // =============================================================================
36
+ // Utility Functions
37
+ // =============================================================================
38
+
39
+ const TWELVE_HOURS_MS = 43_200_000;
40
+ const CLAUDE_CODE_GROUP = "Claude Code";
41
+
42
+ function convertToEiMessage(msg: ClaudeCodeMessage): Message {
43
+ return {
44
+ id: msg.id,
45
+ role: msg.role === "user" ? "human" : "system",
46
+ verbal_response: msg.content,
47
+ timestamp: msg.timestamp,
48
+ read: true,
49
+ context_status: "default" as ContextStatus,
50
+ };
51
+ }
52
+
53
+ function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage): Message {
54
+ return {
55
+ ...convertToEiMessage(msg),
56
+ f: true,
57
+ r: true,
58
+ p: true,
59
+ o: true,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Ensure the single "Claude Code" persona exists.
65
+ * All sessions share one persona — it's a coding assistant, not a multi-agent system.
66
+ */
67
+ function ensureClaudeCodePersona(
68
+ stateManager: StateManager,
69
+ eiInterface?: Ei_Interface
70
+ ): PersonaEntity {
71
+ const existing = stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME);
72
+ if (existing) return existing;
73
+
74
+ const now = new Date().toISOString();
75
+ const persona: PersonaEntity = {
76
+ id: crypto.randomUUID(),
77
+ display_name: CLAUDE_CODE_PERSONA_NAME,
78
+ entity: "system",
79
+ aliases: ["claude-code", "claude code"],
80
+ short_description: "Claude Code — Anthropic's AI coding assistant",
81
+ long_description:
82
+ "Claude Code is an agentic coding assistant that helps with coding tasks, debugging, architecture decisions, and more.",
83
+ group_primary: CLAUDE_CODE_GROUP,
84
+ groups_visible: [CLAUDE_CODE_GROUP],
85
+ traits: [],
86
+ topics: [],
87
+ is_paused: false,
88
+ is_archived: false,
89
+ is_static: false,
90
+ heartbeat_delay_ms: TWELVE_HOURS_MS,
91
+ last_heartbeat: now,
92
+ last_updated: now,
93
+ last_activity: now,
94
+ };
95
+
96
+ stateManager.persona_add(persona);
97
+ eiInterface?.onPersonaAdded?.();
98
+ return persona;
99
+ }
100
+
101
+ // =============================================================================
102
+ // Topic Management
103
+ // =============================================================================
104
+
105
+ function ensureSessionTopic(
106
+ session: ClaudeCodeSession,
107
+ stateManager: StateManager
108
+ ): "created" | "updated" | "unchanged" {
109
+ const human = stateManager.getHuman();
110
+ const existingTopic = human.topics.find((t) => t.id === session.id);
111
+
112
+ if (existingTopic) {
113
+ if (existingTopic.name !== session.title) {
114
+ const updatedTopic: Topic = {
115
+ ...existingTopic,
116
+ name: session.title,
117
+ last_updated: new Date().toISOString(),
118
+ };
119
+ stateManager.human_topic_upsert(updatedTopic);
120
+ return "updated";
121
+ }
122
+ return "unchanged";
123
+ }
124
+
125
+ const newTopic: Topic = {
126
+ id: session.id,
127
+ name: session.title,
128
+ description: `Claude Code session in ${session.cwd}`,
129
+ sentiment: 0,
130
+ exposure_current: 0.5,
131
+ exposure_desired: 0.3,
132
+ persona_groups: CLAUDE_CODE_TOPIC_GROUPS,
133
+ learned_by: CLAUDE_CODE_PERSONA_NAME,
134
+ last_updated: new Date().toISOString(),
135
+ };
136
+
137
+ stateManager.human_topic_upsert(newTopic);
138
+ return "created";
139
+ }
140
+
141
+ // =============================================================================
142
+ // State Helpers
143
+ // =============================================================================
144
+
145
+ function updateProcessedState(
146
+ stateManager: StateManager,
147
+ session: ClaudeCodeSession
148
+ ): void {
149
+ const human = stateManager.getHuman();
150
+ const processedSessions = {
151
+ ...(human.settings?.claudeCode?.processed_sessions ?? {}),
152
+ [session.id]: new Date().toISOString(),
153
+ };
154
+
155
+ stateManager.setHuman({
156
+ ...human,
157
+ settings: {
158
+ ...human.settings,
159
+ claudeCode: {
160
+ ...human.settings?.claudeCode,
161
+ processed_sessions: processedSessions,
162
+ },
163
+ },
164
+ });
165
+ }
166
+
167
+ // =============================================================================
168
+ // Main Import Function
169
+ // =============================================================================
170
+
171
+ /**
172
+ * Import one Claude Code session per call.
173
+ *
174
+ * Flow:
175
+ * 1. Ensure topics exist for all sessions (cheap, always runs).
176
+ * 2. Find the next unprocessed session (20+ minutes old).
177
+ * 3. Ensure the "Claude Code" persona exists.
178
+ * 4. Archive the persona and clear its messages, then write all messages
179
+ * for the session — pre-marking already-imported messages [p,r,o,f]=true,
180
+ * leaving new messages unmarked for extraction.
181
+ * 5. Queue extraction for unmarked messages.
182
+ * 6. Mark session processed.
183
+ */
184
+ export async function importClaudeCodeSessions(
185
+ options: ClaudeCodeImporterOptions
186
+ ): Promise<ClaudeCodeImportResult> {
187
+ const { stateManager, interface: eiInterface, signal } = options;
188
+ const reader = options.reader ?? new ClaudeCodeReader();
189
+
190
+ const result: ClaudeCodeImportResult = {
191
+ sessionsProcessed: 0,
192
+ topicsCreated: 0,
193
+ topicsUpdated: 0,
194
+ messagesImported: 0,
195
+ personaCreated: false,
196
+ extractionScansQueued: 0,
197
+ };
198
+
199
+ // ─── Step 1: Ensure topics exist for ALL sessions ─────────────────────
200
+ const allSessions = await reader.getSessions();
201
+
202
+ for (const session of allSessions) {
203
+ const topicResult = ensureSessionTopic(session, stateManager);
204
+ if (topicResult === "created") result.topicsCreated++;
205
+ else if (topicResult === "updated") result.topicsUpdated++;
206
+ }
207
+
208
+ if (signal?.aborted) return result;
209
+ if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
210
+ eiInterface?.onHumanUpdated?.();
211
+ }
212
+
213
+ // ─── Step 2: Find next unprocessed session ────────────────────────────
214
+ const human = stateManager.getHuman();
215
+ const processedSessions = human.settings?.claudeCode?.processed_sessions ?? {};
216
+ const now = Date.now();
217
+
218
+ let targetSession: ClaudeCodeSession | null = null;
219
+
220
+ // allSessions is already sorted oldest-first
221
+ for (const session of allSessions) {
222
+ const lastImported = processedSessions[session.id];
223
+ const sessionLastMs = new Date(session.lastMessageAt).getTime();
224
+ const ageMs = now - sessionLastMs;
225
+
226
+ if (ageMs < MIN_SESSION_AGE_MS) continue; // too fresh
227
+
228
+ if (!lastImported) {
229
+ targetSession = session;
230
+ break;
231
+ }
232
+
233
+ // Re-import if session has been updated since last import
234
+ if (sessionLastMs > new Date(lastImported).getTime()) {
235
+ targetSession = session;
236
+ break;
237
+ }
238
+ }
239
+
240
+ if (!targetSession) {
241
+ console.log("[ClaudeCode] All sessions processed, nothing new to import");
242
+ return result;
243
+ }
244
+
245
+ if (signal?.aborted) return result;
246
+
247
+ console.log(
248
+ `[ClaudeCode] Processing session: "${targetSession.title}" ` +
249
+ `(last message: ${targetSession.lastMessageAt})`
250
+ );
251
+
252
+ // ─── Step 3: Pull messages ────────────────────────────────────────────
253
+ const messages = await reader.getMessagesForSession(targetSession.id);
254
+
255
+ if (messages.length === 0) {
256
+ updateProcessedState(stateManager, targetSession);
257
+ return result;
258
+ }
259
+
260
+ if (signal?.aborted) return result;
261
+
262
+ // ─── Step 4: Ensure persona, archive, clear, write messages ──────────
263
+ const persona = ensureClaudeCodePersona(stateManager, eiInterface);
264
+ result.personaCreated = !stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME);
265
+
266
+ if (!persona.is_archived) {
267
+ stateManager.persona_archive(persona.id);
268
+ }
269
+
270
+ const existingMsgs = stateManager.messages_get(persona.id);
271
+ if (existingMsgs.length > 0) {
272
+ stateManager.messages_remove(persona.id, existingMsgs.map((m) => m.id));
273
+ }
274
+
275
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
276
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
277
+ const toAnalyze: Message[] = [];
278
+
279
+ for (const msg of messages) {
280
+ const msgMs = new Date(msg.timestamp).getTime();
281
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
282
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
283
+ stateManager.messages_append(persona.id, eiMsg);
284
+ result.messagesImported++;
285
+ if (!isOld) toAnalyze.push(eiMsg);
286
+ }
287
+
288
+ stateManager.messages_sort(persona.id);
289
+ stateManager.persona_update(persona.id, {
290
+ last_activity: new Date().toISOString(),
291
+ });
292
+ eiInterface?.onMessageAdded?.(persona.id);
293
+
294
+ // ─── Step 5: Queue extraction for new messages ────────────────────────
295
+ if (toAnalyze.length > 0 && !signal?.aborted) {
296
+ const allInState = stateManager.messages_get(persona.id);
297
+ const analyzeIds = new Set(toAnalyze.map((m) => m.id));
298
+ const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
299
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
300
+
301
+ const context: ExtractionContext = {
302
+ personaId: persona.id,
303
+ personaDisplayName: persona.display_name,
304
+ messages_context: contextMsgs,
305
+ messages_analyze: toAnalyze,
306
+ };
307
+
308
+ queueAllScans(context, stateManager);
309
+ result.extractionScansQueued += 4;
310
+ }
311
+
312
+ result.sessionsProcessed = 1;
313
+
314
+ // ─── Step 6: Mark processed ───────────────────────────────────────────
315
+ updateProcessedState(stateManager, targetSession);
316
+
317
+ console.log(
318
+ `[ClaudeCode] Session complete: ${result.messagesImported} messages imported, ` +
319
+ `${result.extractionScansQueued} extraction scans queued`
320
+ );
321
+
322
+ return result;
323
+ }
@@ -0,0 +1,10 @@
1
+ export { ClaudeCodeReader } from "./reader.js";
2
+ export { importClaudeCodeSessions } from "./importer.js";
3
+ export type { ClaudeCodeImportResult, ClaudeCodeImporterOptions } from "./importer.js";
4
+ export type {
5
+ IClaudeCodeReader,
6
+ ClaudeCodeSession,
7
+ ClaudeCodeMessage,
8
+ ClaudeCodeSettings,
9
+ } from "./types.js";
10
+ export { CLAUDE_CODE_PERSONA_NAME } from "./types.js";
@@ -0,0 +1,238 @@
1
+ import type {
2
+ IClaudeCodeReader,
3
+ ClaudeCodeSession,
4
+ ClaudeCodeMessage,
5
+ ClaudeCodeRecord,
6
+ ClaudeCodeUserRecord,
7
+ ClaudeCodeAssistantRecord,
8
+ } from "./types.js";
9
+
10
+ // OpenTUI polyfills window but not document — check document for real browser
11
+ const isBrowser = typeof document !== "undefined";
12
+
13
+ let _join: typeof import("path").join;
14
+ let _readdir: typeof import("fs/promises").readdir;
15
+ let _readFile: typeof import("fs/promises").readFile;
16
+ let _nodeModulesLoaded = false;
17
+
18
+ async function ensureNodeModules(): Promise<boolean> {
19
+ if (isBrowser) return false;
20
+ if (_nodeModulesLoaded) return true;
21
+
22
+ const PATH_MODULE = "path";
23
+ const FS_MODULE = "fs/promises";
24
+
25
+ const pathMod = await import(/* @vite-ignore */ PATH_MODULE);
26
+ const fsMod = await import(/* @vite-ignore */ FS_MODULE);
27
+
28
+ _join = pathMod.join;
29
+ _readdir = fsMod.readdir;
30
+ _readFile = fsMod.readFile;
31
+ _nodeModulesLoaded = true;
32
+ return true;
33
+ }
34
+
35
+ function getDefaultProjectsPath(): string {
36
+ if (!_join) return "";
37
+ return _join(process.env.HOME || "~", ".claude", "projects");
38
+ }
39
+
40
+ /**
41
+ * Derives a human-readable session title from the cwd.
42
+ * "/Users/flare576/Projects/Personal/ei" → "ei"
43
+ */
44
+ function titleFromCwd(cwd: string): string {
45
+ if (!cwd) return "Unknown";
46
+ const parts = cwd.replace(/\\/g, "/").split("/").filter(Boolean);
47
+ return parts[parts.length - 1] ?? cwd;
48
+ }
49
+
50
+ /**
51
+ * Extracts plain text from an assistant content block array.
52
+ * Skips: thinking blocks, tool_use blocks, anything that isn't a text block.
53
+ */
54
+ function extractAssistantText(content: ClaudeCodeAssistantRecord["message"]["content"]): string {
55
+ return content
56
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
57
+ .map((block) => block.text)
58
+ .join("\n\n")
59
+ .trim();
60
+ }
61
+
62
+ export class ClaudeCodeReader implements IClaudeCodeReader {
63
+ private readonly projectsPath?: string;
64
+
65
+ constructor(projectsPath?: string) {
66
+ this.projectsPath = projectsPath;
67
+ }
68
+
69
+ async getSessions(): Promise<ClaudeCodeSession[]> {
70
+ if (!(await ensureNodeModules())) return [];
71
+
72
+ const projectsDir = this.projectsPath ?? getDefaultProjectsPath();
73
+ const sessions: ClaudeCodeSession[] = [];
74
+
75
+ let projectDirs: string[];
76
+ try {
77
+ projectDirs = await _readdir(projectsDir);
78
+ } catch {
79
+ return [];
80
+ }
81
+
82
+ for (const projectDirName of projectDirs) {
83
+ if (projectDirName.startsWith(".")) continue;
84
+
85
+ const projectPath = _join(projectsDir, projectDirName);
86
+ let sessionFiles: string[];
87
+ try {
88
+ sessionFiles = await _readdir(projectPath);
89
+ } catch {
90
+ continue;
91
+ }
92
+
93
+ for (const fileName of sessionFiles) {
94
+ if (!fileName.endsWith(".jsonl")) continue;
95
+
96
+ // Skip agent sidechain sessions (named "agent-<hash>.jsonl")
97
+ if (fileName.startsWith("agent-")) continue;
98
+
99
+ const sessionId = fileName.replace(/\.jsonl$/, "");
100
+ const filePath = _join(projectPath, fileName);
101
+
102
+ const session = await this.parseSessionMeta(sessionId, filePath);
103
+ if (session) {
104
+ sessions.push(session);
105
+ }
106
+ }
107
+ }
108
+
109
+ return sessions.sort(
110
+ (a, b) => new Date(a.lastMessageAt).getTime() - new Date(b.lastMessageAt).getTime()
111
+ );
112
+ }
113
+
114
+ async getMessagesForSession(sessionId: string): Promise<ClaudeCodeMessage[]> {
115
+ if (!(await ensureNodeModules())) return [];
116
+
117
+ const projectsDir = this.projectsPath ?? getDefaultProjectsPath();
118
+ const messages: ClaudeCodeMessage[] = [];
119
+
120
+ // Find the file — it could be under any project dir
121
+ let projectDirs: string[];
122
+ try {
123
+ projectDirs = await _readdir(projectsDir);
124
+ } catch {
125
+ return [];
126
+ }
127
+
128
+ let filePath: string | null = null;
129
+ for (const projectDirName of projectDirs) {
130
+ if (projectDirName.startsWith(".")) continue;
131
+ const candidate = _join(projectsDir, projectDirName, `${sessionId}.jsonl`);
132
+ try {
133
+ await _readFile(candidate, "utf-8"); // throws if not found
134
+ filePath = candidate;
135
+ break;
136
+ } catch {
137
+ // not in this project dir
138
+ }
139
+ }
140
+
141
+ if (!filePath) return [];
142
+
143
+ const records = await this.readJsonl(filePath);
144
+
145
+ for (const record of records) {
146
+ if (record.type === "user") {
147
+ const r = record as ClaudeCodeUserRecord;
148
+ const content = typeof r.message?.content === "string" ? r.message.content : "";
149
+ if (!content.trim()) continue;
150
+
151
+ messages.push({
152
+ id: r.uuid,
153
+ sessionId: r.sessionId,
154
+ role: "user",
155
+ content,
156
+ timestamp: r.timestamp,
157
+ });
158
+ } else if (record.type === "assistant") {
159
+ const r = record as ClaudeCodeAssistantRecord;
160
+ const content = extractAssistantText(r.message?.content ?? []);
161
+ if (!content) continue;
162
+
163
+ messages.push({
164
+ id: r.uuid,
165
+ sessionId: r.sessionId,
166
+ role: "assistant",
167
+ content,
168
+ timestamp: r.timestamp,
169
+ });
170
+ }
171
+ // Skip: file-history-snapshot, system, summary, progress, tool_use, tool_result
172
+ }
173
+
174
+ return messages.sort(
175
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
176
+ );
177
+ }
178
+
179
+ // ============================================================================
180
+ // Private Helpers
181
+ // ============================================================================
182
+
183
+ private async parseSessionMeta(
184
+ sessionId: string,
185
+ filePath: string
186
+ ): Promise<ClaudeCodeSession | null> {
187
+ const records = await this.readJsonl(filePath);
188
+
189
+ let firstTimestamp: string | null = null;
190
+ let lastTimestamp: string | null = null;
191
+ let cwd = "";
192
+
193
+ for (const record of records) {
194
+ if (record.type !== "user" && record.type !== "assistant") continue;
195
+
196
+ const ts = record.timestamp;
197
+ if (!ts) continue;
198
+
199
+ if (!firstTimestamp || ts < firstTimestamp) firstTimestamp = ts;
200
+ if (!lastTimestamp || ts > lastTimestamp) lastTimestamp = ts;
201
+
202
+ if (!cwd && (record as ClaudeCodeUserRecord | ClaudeCodeAssistantRecord).cwd) {
203
+ cwd = (record as ClaudeCodeUserRecord | ClaudeCodeAssistantRecord).cwd!;
204
+ }
205
+ }
206
+
207
+ if (!firstTimestamp || !lastTimestamp) return null;
208
+
209
+ return {
210
+ id: sessionId,
211
+ cwd,
212
+ title: titleFromCwd(cwd),
213
+ firstMessageAt: firstTimestamp,
214
+ lastMessageAt: lastTimestamp,
215
+ };
216
+ }
217
+
218
+ private async readJsonl(filePath: string): Promise<ClaudeCodeRecord[]> {
219
+ let text: string;
220
+ try {
221
+ text = await _readFile(filePath, "utf-8");
222
+ } catch {
223
+ return [];
224
+ }
225
+
226
+ const records: ClaudeCodeRecord[] = [];
227
+ for (const line of text.split("\n")) {
228
+ const trimmed = line.trim();
229
+ if (!trimmed) continue;
230
+ try {
231
+ records.push(JSON.parse(trimmed) as ClaudeCodeRecord);
232
+ } catch {
233
+ // skip malformed lines
234
+ }
235
+ }
236
+ return records;
237
+ }
238
+ }