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.
- package/package.json +1 -1
- package/src/cli.ts +58 -0
- package/src/core/handlers/index.ts +76 -21
- package/src/core/llm-client.ts +13 -2
- package/src/core/processor.ts +116 -4
- package/src/core/queue-processor.ts +4 -3
- package/src/core/types.ts +11 -1
- package/src/integrations/claude-code/importer.ts +323 -0
- package/src/integrations/claude-code/index.ts +10 -0
- package/src/integrations/claude-code/reader.ts +238 -0
- package/src/integrations/claude-code/types.ts +163 -0
- package/src/integrations/opencode/importer.ts +10 -3
- package/src/prompts/generation/persona.ts +5 -3
- package/src/prompts/generation/types.ts +1 -1
- package/src/prompts/human/fact-scan.ts +6 -6
- package/src/prompts/human/person-scan.ts +6 -6
- package/src/prompts/human/topic-scan.ts +4 -4
- package/src/prompts/human/trait-scan.ts +6 -6
- package/src/prompts/persona/traits.ts +2 -2
- package/src/prompts/response/sections.ts +15 -0
- package/src/storage/interface.ts +2 -0
- package/src/storage/local.ts +5 -0
- package/tui/README.md +13 -0
- package/tui/src/index.tsx +20 -0
- package/tui/src/storage/file.ts +39 -2
- package/tui/src/util/instance-lock.ts +92 -0
- package/tui/src/util/logger.ts +0 -2
- package/tui/src/util/yaml-serializers.ts +49 -3
|
@@ -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
|
+
}
|