@arcreflex/agent-transcripts 0.1.9 → 0.1.10
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 +12 -0
- package/package.json +1 -1
- package/scripts/infer-cc-types.prose +87 -0
- package/src/adapters/claude-code.ts +290 -53
- package/src/sync.ts +8 -7
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -122,12 +122,24 @@ interface TranscriptsIndex {
|
|
|
122
122
|
- `Message`: union of UserMessage | AssistantMessage | SystemMessage | ToolCallGroup | ErrorMessage
|
|
123
123
|
- `Adapter`: name, discover function, parse function
|
|
124
124
|
|
|
125
|
+
### Titles
|
|
126
|
+
|
|
127
|
+
Transcripts get titles from (in priority order):
|
|
128
|
+
|
|
129
|
+
1. Harness-provided summary (e.g., Claude Code's sessions-index.json `summary` field)
|
|
130
|
+
2. Cached title from previous sync
|
|
131
|
+
3. LLM-generated title via OpenRouter (requires `OPENROUTER_API_KEY`)
|
|
132
|
+
|
|
125
133
|
## Adding an Adapter
|
|
126
134
|
|
|
127
135
|
1. Create `src/adapters/<name>.ts` implementing `Adapter`
|
|
128
136
|
2. Register in `src/adapters/index.ts` (adapters map + detection rules)
|
|
129
137
|
3. Add test fixtures in `test/fixtures/<name>/`
|
|
130
138
|
|
|
139
|
+
## Development Scripts
|
|
140
|
+
|
|
141
|
+
- `scripts/infer-cc-types.prose`: open-prose program to infer types from real CC session data
|
|
142
|
+
|
|
131
143
|
## Tests
|
|
132
144
|
|
|
133
145
|
Snapshot-based: `*.input.jsonl` → parse → render → compare against `*.output.md`
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Infer TypeScript types from Claude Code session data
|
|
2
|
+
#
|
|
3
|
+
# Usage: prose run scripts/infer-cc-types.prose
|
|
4
|
+
#
|
|
5
|
+
# Examines real CC session directories and updates the type definitions
|
|
6
|
+
# in src/adapters/claude-code.ts to match the current data format.
|
|
7
|
+
|
|
8
|
+
# Agent that synthesizes TypeScript types from JSON examples
|
|
9
|
+
agent type-inferrer:
|
|
10
|
+
model: sonnet
|
|
11
|
+
prompt: """
|
|
12
|
+
You analyze JSON data samples and synthesize TypeScript type definitions.
|
|
13
|
+
|
|
14
|
+
Your output should be:
|
|
15
|
+
- Clean, minimal TypeScript interfaces
|
|
16
|
+
- JSDoc comments explaining non-obvious fields
|
|
17
|
+
- Optional fields marked with ?
|
|
18
|
+
- Union types where the data shows multiple shapes
|
|
19
|
+
- `unknown` (never `any`) for truly dynamic data
|
|
20
|
+
|
|
21
|
+
When you see multiple examples, infer which fields are always present
|
|
22
|
+
(required) vs sometimes present (optional).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Discover CC session data locations (once)
|
|
26
|
+
let discovery = session "Find CC session data"
|
|
27
|
+
model: sonnet
|
|
28
|
+
prompt: """
|
|
29
|
+
Find Claude Code session data on this system.
|
|
30
|
+
|
|
31
|
+
1. Search for sessions-index.json files under ~/.claude
|
|
32
|
+
2. For each, note the directory path and count of .jsonl files
|
|
33
|
+
|
|
34
|
+
Return a structured list of what you found.
|
|
35
|
+
If nothing found, say so clearly.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Iterate: infer types → implement → check → repeat if needed
|
|
39
|
+
let feedback = "(no feedback)"
|
|
40
|
+
|
|
41
|
+
loop until **feedback indicates success** (max: 5):
|
|
42
|
+
# Parallel type inference
|
|
43
|
+
parallel:
|
|
44
|
+
index_types = session: type-inferrer
|
|
45
|
+
prompt: """
|
|
46
|
+
Analyze the sessions-index.json files from the discovered locations.
|
|
47
|
+
|
|
48
|
+
Generate TypeScript interfaces for:
|
|
49
|
+
- SessionsIndex (the root object)
|
|
50
|
+
- SessionIndexEntry (each entry in the entries array)
|
|
51
|
+
"""
|
|
52
|
+
context: { discovery, feedback }
|
|
53
|
+
|
|
54
|
+
record_types = session: type-inferrer
|
|
55
|
+
prompt: """
|
|
56
|
+
Analyze Claude Code session .jsonl files (sample 20-30 lines from a few files).
|
|
57
|
+
|
|
58
|
+
Generate TypeScript types describing JSONL records.
|
|
59
|
+
- ClaudeRecord (the JSONL line structure)
|
|
60
|
+
- ContentBlock (the message.content array elements)
|
|
61
|
+
"""
|
|
62
|
+
context: { discovery, feedback }
|
|
63
|
+
|
|
64
|
+
# Implement and verify
|
|
65
|
+
feedback = session "Update adapter types"
|
|
66
|
+
model: opus
|
|
67
|
+
prompt: """
|
|
68
|
+
Update the type definitions in src/adapters/claude-code.ts to match these inferred types.
|
|
69
|
+
|
|
70
|
+
## Inferred from sessions-index.json
|
|
71
|
+
{index_types}
|
|
72
|
+
|
|
73
|
+
## Inferred from JSONL records
|
|
74
|
+
{record_types}
|
|
75
|
+
|
|
76
|
+
## Task
|
|
77
|
+
|
|
78
|
+
1. Read the current types in src/adapters/claude-code.ts
|
|
79
|
+
2. Edit to match the inferred types (add new fields, fix types, etc.)
|
|
80
|
+
3. Run: bun run typecheck
|
|
81
|
+
|
|
82
|
+
If typecheck succeeds and you're happy with the types, output a message indicating success.
|
|
83
|
+
If typecheck fails, assess whether the failures reflect a real need to change the code or a flaw in the type design.
|
|
84
|
+
If yes, and you're happy with the types, make the changes and output a message indicating success.
|
|
85
|
+
Otherwise: output feedback requesting revisions to the type design.
|
|
86
|
+
"""
|
|
87
|
+
context: { index_types, record_types }
|
|
@@ -17,45 +17,279 @@ import type {
|
|
|
17
17
|
} from "../types.ts";
|
|
18
18
|
import { extractToolSummary } from "../utils/summary.ts";
|
|
19
19
|
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// sessions-index.json types
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
20
24
|
/**
|
|
21
|
-
*
|
|
25
|
+
* Root structure of sessions-index.json file.
|
|
26
|
+
* Contains metadata about all Claude Code sessions for a project.
|
|
22
27
|
*/
|
|
23
28
|
interface SessionsIndex {
|
|
29
|
+
/** Schema version for the index file format */
|
|
24
30
|
version: number;
|
|
31
|
+
/** Array of session metadata entries */
|
|
25
32
|
entries: SessionIndexEntry[];
|
|
33
|
+
/**
|
|
34
|
+
* Original project path (workspace path).
|
|
35
|
+
* This is the actual project directory being worked on.
|
|
36
|
+
*/
|
|
37
|
+
originalPath: string;
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Metadata for a single Claude Code session.
|
|
42
|
+
* Each entry corresponds to one .jsonl session file.
|
|
43
|
+
*/
|
|
28
44
|
interface SessionIndexEntry {
|
|
45
|
+
/** Unique session identifier (UUID format) */
|
|
29
46
|
sessionId: string;
|
|
47
|
+
/**
|
|
48
|
+
* Absolute path to the session .jsonl file.
|
|
49
|
+
* Typically in ~/.claude/projects/<project-name>/<sessionId>.jsonl
|
|
50
|
+
*/
|
|
30
51
|
fullPath: string;
|
|
52
|
+
/**
|
|
53
|
+
* File modification time in Unix epoch milliseconds.
|
|
54
|
+
* Used to detect file changes and for sorting.
|
|
55
|
+
*/
|
|
31
56
|
fileMtime: number;
|
|
57
|
+
/**
|
|
58
|
+
* First user prompt in the session (may be truncated with "…").
|
|
59
|
+
* Will be "No prompt" for empty sessions or "[Request interrupted by user]"
|
|
60
|
+
* for sessions that were interrupted before completion.
|
|
61
|
+
*/
|
|
62
|
+
firstPrompt: string;
|
|
63
|
+
/**
|
|
64
|
+
* Human-readable summary of the session.
|
|
65
|
+
* Generated by Claude Code, describes what the session accomplished.
|
|
66
|
+
* Common patterns:
|
|
67
|
+
* - "Conversation Cleared" / "Empty Conversation - Clear Command Only" for cleared sessions
|
|
68
|
+
* - Action-oriented descriptions for active sessions
|
|
69
|
+
*/
|
|
70
|
+
summary: string;
|
|
71
|
+
/** Total number of messages in the session */
|
|
72
|
+
messageCount: number;
|
|
73
|
+
/** ISO 8601 timestamp when the session was created */
|
|
74
|
+
created: string;
|
|
75
|
+
/** ISO 8601 timestamp when the session was last modified */
|
|
76
|
+
modified: string;
|
|
77
|
+
/**
|
|
78
|
+
* Git branch that was active when the session was created/active.
|
|
79
|
+
* Useful for correlating sessions with code changes.
|
|
80
|
+
*/
|
|
81
|
+
gitBranch: string;
|
|
82
|
+
/**
|
|
83
|
+
* Project workspace path.
|
|
84
|
+
* This is the working directory for the session.
|
|
85
|
+
*/
|
|
86
|
+
projectPath: string;
|
|
87
|
+
/**
|
|
88
|
+
* Whether this session is a "sidechain" (subagent) session.
|
|
89
|
+
* Subagent sessions are spawned by parent sessions for specialized tasks.
|
|
90
|
+
* Main sessions have isSidechain: false.
|
|
91
|
+
*/
|
|
92
|
+
isSidechain: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// JSONL record types
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Top-level JSONL record type discriminated by `type` field.
|
|
101
|
+
* Each line in a Claude Code session .jsonl file is one of these record types.
|
|
102
|
+
*/
|
|
103
|
+
type ClaudeRecord =
|
|
104
|
+
| FileHistorySnapshot
|
|
105
|
+
| UserRecord
|
|
106
|
+
| AssistantRecord
|
|
107
|
+
| SystemRecord
|
|
108
|
+
| ProgressRecord
|
|
109
|
+
| SummaryRecord;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Records that participate in the message tree (have uuid, parentUuid, timestamp).
|
|
113
|
+
* These are the record types that the parsing code processes for transcript generation.
|
|
114
|
+
*/
|
|
115
|
+
type MessageRecord =
|
|
116
|
+
| UserRecord
|
|
117
|
+
| AssistantRecord
|
|
118
|
+
| SystemRecord
|
|
119
|
+
| ProgressRecord;
|
|
120
|
+
|
|
121
|
+
/** Type guard for message records */
|
|
122
|
+
function isMessageRecord(rec: ClaudeRecord): rec is MessageRecord {
|
|
123
|
+
return (
|
|
124
|
+
rec.type === "user" ||
|
|
125
|
+
rec.type === "assistant" ||
|
|
126
|
+
rec.type === "system" ||
|
|
127
|
+
rec.type === "progress"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Common fields shared by most record types */
|
|
132
|
+
interface BaseRecord {
|
|
133
|
+
uuid: string;
|
|
134
|
+
parentUuid: string | null;
|
|
135
|
+
timestamp: string;
|
|
32
136
|
isSidechain: boolean;
|
|
137
|
+
userType: "external";
|
|
138
|
+
cwd: string;
|
|
139
|
+
sessionId: string;
|
|
140
|
+
version: string;
|
|
141
|
+
gitBranch: string;
|
|
142
|
+
slug?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface FileHistorySnapshot {
|
|
146
|
+
type: "file-history-snapshot";
|
|
147
|
+
messageId: string;
|
|
148
|
+
snapshot: {
|
|
149
|
+
messageId: string;
|
|
150
|
+
trackedFileBackups: Record<
|
|
151
|
+
string,
|
|
152
|
+
{
|
|
153
|
+
backupFileName: string;
|
|
154
|
+
version: number;
|
|
155
|
+
backupTime: string;
|
|
156
|
+
}
|
|
157
|
+
>;
|
|
158
|
+
timestamp: string;
|
|
159
|
+
};
|
|
160
|
+
isSnapshotUpdate: boolean;
|
|
33
161
|
}
|
|
34
162
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
parentUuid?: string | null;
|
|
40
|
-
timestamp?: string;
|
|
41
|
-
message?: {
|
|
42
|
-
role: string;
|
|
163
|
+
interface UserRecord extends BaseRecord {
|
|
164
|
+
type: "user";
|
|
165
|
+
message: {
|
|
166
|
+
role: "user";
|
|
43
167
|
content: string | ContentBlock[];
|
|
44
168
|
};
|
|
169
|
+
/** Present when message is metadata (e.g., command caveats) */
|
|
170
|
+
isMeta?: boolean;
|
|
171
|
+
/** Present when message is a tool result */
|
|
172
|
+
toolUseResult?: {
|
|
173
|
+
task?: {
|
|
174
|
+
id: string;
|
|
175
|
+
subject: string;
|
|
176
|
+
};
|
|
177
|
+
[key: string]: unknown;
|
|
178
|
+
};
|
|
179
|
+
/** UUID of the assistant message that triggered this tool use */
|
|
180
|
+
sourceToolAssistantUUID?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface AssistantRecord extends BaseRecord {
|
|
184
|
+
type: "assistant";
|
|
185
|
+
message: {
|
|
186
|
+
model: string;
|
|
187
|
+
id: string;
|
|
188
|
+
type: "message";
|
|
189
|
+
role: "assistant";
|
|
190
|
+
content: ContentBlock[];
|
|
191
|
+
stop_reason: string | null;
|
|
192
|
+
stop_sequence: string | null;
|
|
193
|
+
usage: {
|
|
194
|
+
input_tokens: number;
|
|
195
|
+
cache_creation_input_tokens?: number;
|
|
196
|
+
cache_read_input_tokens?: number;
|
|
197
|
+
cache_creation?: {
|
|
198
|
+
ephemeral_5m_input_tokens: number;
|
|
199
|
+
ephemeral_1h_input_tokens: number;
|
|
200
|
+
};
|
|
201
|
+
output_tokens: number;
|
|
202
|
+
service_tier?: string;
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
requestId: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface SystemRecord extends BaseRecord {
|
|
209
|
+
type: "system";
|
|
210
|
+
/** Subtypes: "turn_duration", etc. */
|
|
211
|
+
subtype: string;
|
|
212
|
+
isMeta: boolean;
|
|
213
|
+
/** Present for turn_duration subtype */
|
|
214
|
+
durationMs?: number;
|
|
215
|
+
/** Text content (if any) - some system messages may include display text */
|
|
45
216
|
content?: string;
|
|
46
|
-
subtype?: string;
|
|
47
|
-
cwd?: string;
|
|
48
217
|
}
|
|
49
218
|
|
|
50
|
-
interface
|
|
51
|
-
type:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
219
|
+
interface ProgressRecord extends BaseRecord {
|
|
220
|
+
type: "progress";
|
|
221
|
+
data: AgentProgressData | HookProgressData;
|
|
222
|
+
/** Tool use ID that triggered this progress */
|
|
223
|
+
toolUseID?: string;
|
|
224
|
+
/** Parent tool use ID (for nested tool calls) */
|
|
225
|
+
parentToolUseID?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface AgentProgressData {
|
|
229
|
+
type: "agent_progress";
|
|
230
|
+
message: {
|
|
231
|
+
type: "user";
|
|
232
|
+
message: {
|
|
233
|
+
role: "user";
|
|
234
|
+
content: ContentBlock[];
|
|
235
|
+
};
|
|
236
|
+
uuid: string;
|
|
237
|
+
timestamp: string;
|
|
238
|
+
};
|
|
239
|
+
normalizedMessages: unknown[];
|
|
240
|
+
/** The prompt text sent to the agent */
|
|
241
|
+
prompt: string;
|
|
242
|
+
/** Agent identifier */
|
|
243
|
+
agentId: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface HookProgressData {
|
|
247
|
+
type: "hook_progress";
|
|
248
|
+
hookEvent: string;
|
|
249
|
+
hookName: string;
|
|
250
|
+
command: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface SummaryRecord {
|
|
254
|
+
type: "summary";
|
|
255
|
+
/** Human-readable summary text */
|
|
256
|
+
summary: string;
|
|
257
|
+
/** UUID of the leaf message in the conversation tree */
|
|
258
|
+
leafUuid: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Content block types
|
|
263
|
+
// ============================================================================
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Content blocks appear in message.content arrays for both user and assistant messages.
|
|
267
|
+
*/
|
|
268
|
+
type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock;
|
|
269
|
+
|
|
270
|
+
interface TextBlock {
|
|
271
|
+
type: "text";
|
|
272
|
+
text: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface ThinkingBlock {
|
|
276
|
+
type: "thinking";
|
|
277
|
+
thinking: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface ToolUseBlock {
|
|
281
|
+
type: "tool_use";
|
|
282
|
+
id: string;
|
|
283
|
+
name: string;
|
|
284
|
+
input: Record<string, unknown>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface ToolResultBlock {
|
|
288
|
+
type: "tool_result";
|
|
289
|
+
tool_use_id: string;
|
|
290
|
+
content: string | ContentBlock[];
|
|
291
|
+
/** Present when tool result indicates an error */
|
|
292
|
+
is_error?: boolean;
|
|
59
293
|
}
|
|
60
294
|
|
|
61
295
|
/**
|
|
@@ -87,13 +321,16 @@ function parseJsonl(content: string): {
|
|
|
87
321
|
return { records, warnings };
|
|
88
322
|
}
|
|
89
323
|
|
|
324
|
+
/** Any record that can participate in parent-child relationships */
|
|
325
|
+
type TraversableRecord = { uuid: string; parentUuid: string | null };
|
|
326
|
+
|
|
90
327
|
/**
|
|
91
328
|
* Find the nearest message ancestor by walking up the parent chain.
|
|
92
329
|
* Returns undefined if no message ancestor exists.
|
|
93
330
|
*/
|
|
94
331
|
function findMessageAncestor(
|
|
95
332
|
parentUuid: string | null | undefined,
|
|
96
|
-
allByUuid: Map<string,
|
|
333
|
+
allByUuid: Map<string, TraversableRecord>,
|
|
97
334
|
messageUuids: Set<string>,
|
|
98
335
|
): string | undefined {
|
|
99
336
|
const visited = new Set<string>();
|
|
@@ -112,8 +349,12 @@ function findMessageAncestor(
|
|
|
112
349
|
return undefined;
|
|
113
350
|
}
|
|
114
351
|
|
|
352
|
+
/** Message record types that appear in conversation trees */
|
|
353
|
+
type ConversationRecord = UserRecord | AssistantRecord | SystemRecord;
|
|
354
|
+
|
|
355
|
+
/** Result of splitting records into conversation trees */
|
|
115
356
|
interface SplitResult {
|
|
116
|
-
conversations:
|
|
357
|
+
conversations: ConversationRecord[][];
|
|
117
358
|
/** Map from message UUID to its resolved parent (nearest message ancestor) */
|
|
118
359
|
resolvedParents: Map<string, string | undefined>;
|
|
119
360
|
}
|
|
@@ -123,40 +364,36 @@ interface SplitResult {
|
|
|
123
364
|
* Returns conversations and a map of resolved parent references.
|
|
124
365
|
*/
|
|
125
366
|
function splitConversations(records: ClaudeRecord[]): SplitResult {
|
|
126
|
-
// Filter to only message records (user, assistant, system with uuid)
|
|
367
|
+
// Filter to only message records (user, assistant, system - records with uuid)
|
|
127
368
|
const messageRecords = records.filter(
|
|
128
|
-
(r) =>
|
|
129
|
-
r.
|
|
130
|
-
(r.type === "user" || r.type === "assistant" || r.type === "system"),
|
|
369
|
+
(r): r is UserRecord | AssistantRecord | SystemRecord =>
|
|
370
|
+
r.type === "user" || r.type === "assistant" || r.type === "system",
|
|
131
371
|
);
|
|
132
372
|
|
|
133
373
|
if (messageRecords.length === 0) {
|
|
134
374
|
return { conversations: [], resolvedParents: new Map() };
|
|
135
375
|
}
|
|
136
376
|
|
|
137
|
-
// Build UUID lookup for ALL records
|
|
138
|
-
|
|
377
|
+
// Build UUID lookup for ALL records with uuid/parentUuid to enable
|
|
378
|
+
// traversing through non-message records (e.g., progress) when resolving parents
|
|
379
|
+
const allByUuid = new Map<string, TraversableRecord>();
|
|
139
380
|
for (const rec of records) {
|
|
140
|
-
if (rec
|
|
381
|
+
if (isMessageRecord(rec)) {
|
|
141
382
|
allByUuid.set(rec.uuid, rec);
|
|
142
383
|
}
|
|
143
384
|
}
|
|
144
385
|
|
|
145
386
|
// Set of message UUIDs for quick lookup
|
|
146
|
-
const messageUuids = new Set<string>();
|
|
147
|
-
for (const rec of messageRecords) {
|
|
148
|
-
if (rec.uuid) messageUuids.add(rec.uuid);
|
|
149
|
-
}
|
|
387
|
+
const messageUuids = new Set<string>(messageRecords.map((r) => r.uuid));
|
|
150
388
|
|
|
151
389
|
// Build parent → children map, resolving through non-message records
|
|
152
390
|
// Also track resolved parents for use in transformation
|
|
153
|
-
const byUuid = new Map<string,
|
|
391
|
+
const byUuid = new Map<string, ConversationRecord>();
|
|
154
392
|
const children = new Map<string, string[]>();
|
|
155
393
|
const resolvedParents = new Map<string, string | undefined>();
|
|
156
394
|
const roots: string[] = [];
|
|
157
395
|
|
|
158
396
|
for (const rec of messageRecords) {
|
|
159
|
-
if (!rec.uuid) continue;
|
|
160
397
|
byUuid.set(rec.uuid, rec);
|
|
161
398
|
|
|
162
399
|
// Find nearest message ancestor (walking through non-message records)
|
|
@@ -181,12 +418,12 @@ function splitConversations(records: ClaudeRecord[]): SplitResult {
|
|
|
181
418
|
|
|
182
419
|
// BFS from each root to collect conversation
|
|
183
420
|
const visited = new Set<string>();
|
|
184
|
-
const conversations:
|
|
421
|
+
const conversations: ConversationRecord[][] = [];
|
|
185
422
|
|
|
186
423
|
for (const root of roots) {
|
|
187
424
|
if (visited.has(root)) continue;
|
|
188
425
|
|
|
189
|
-
const conversation:
|
|
426
|
+
const conversation: ConversationRecord[] = [];
|
|
190
427
|
const queue = [root];
|
|
191
428
|
|
|
192
429
|
while (queue.length > 0) {
|
|
@@ -210,8 +447,8 @@ function splitConversations(records: ClaudeRecord[]): SplitResult {
|
|
|
210
447
|
|
|
211
448
|
// Sort conversations by their first message timestamp
|
|
212
449
|
conversations.sort((a, b) => {
|
|
213
|
-
const ta =
|
|
214
|
-
const tb =
|
|
450
|
+
const ta = new Date(a[0].timestamp).getTime();
|
|
451
|
+
const tb = new Date(b[0].timestamp).getTime();
|
|
215
452
|
return ta - tb;
|
|
216
453
|
});
|
|
217
454
|
|
|
@@ -342,7 +579,7 @@ function resolveParent(
|
|
|
342
579
|
* Transform a conversation into our intermediate format.
|
|
343
580
|
*/
|
|
344
581
|
function transformConversation(
|
|
345
|
-
records:
|
|
582
|
+
records: ConversationRecord[],
|
|
346
583
|
sourcePath: string,
|
|
347
584
|
warnings: Warning[],
|
|
348
585
|
resolvedParents: Map<string, string | undefined>,
|
|
@@ -354,7 +591,7 @@ function transformConversation(
|
|
|
354
591
|
// Collect all tool results from user messages (tool_use_id → result)
|
|
355
592
|
const allToolResults = new Map<string, string>();
|
|
356
593
|
for (const rec of records) {
|
|
357
|
-
if (rec.type === "user"
|
|
594
|
+
if (rec.type === "user") {
|
|
358
595
|
const results = extractToolResults(rec.message.content);
|
|
359
596
|
for (const [id, content] of results) {
|
|
360
597
|
allToolResults.set(id, content);
|
|
@@ -366,23 +603,21 @@ function transformConversation(
|
|
|
366
603
|
|
|
367
604
|
// First pass: identify which messages will be skipped
|
|
368
605
|
for (const rec of records) {
|
|
369
|
-
if (!rec.uuid) continue;
|
|
370
|
-
|
|
371
606
|
let willSkip = false;
|
|
372
607
|
|
|
373
608
|
// Take the first cwd we find.
|
|
374
|
-
if (!cwd
|
|
609
|
+
if (!cwd) {
|
|
375
610
|
cwd = rec.cwd;
|
|
376
611
|
}
|
|
377
612
|
|
|
378
|
-
if (rec.type === "user"
|
|
613
|
+
if (rec.type === "user") {
|
|
379
614
|
if (isToolResultOnly(rec.message.content)) {
|
|
380
615
|
willSkip = true;
|
|
381
616
|
} else {
|
|
382
617
|
const text = extractText(rec.message.content);
|
|
383
618
|
if (!text.trim()) willSkip = true;
|
|
384
619
|
}
|
|
385
|
-
} else if (rec.type === "assistant"
|
|
620
|
+
} else if (rec.type === "assistant") {
|
|
386
621
|
const text = extractText(rec.message.content);
|
|
387
622
|
const thinking = extractThinking(rec.message.content);
|
|
388
623
|
const toolCalls = extractToolCalls(rec.message.content, allToolResults);
|
|
@@ -403,16 +638,17 @@ function transformConversation(
|
|
|
403
638
|
|
|
404
639
|
// Second pass: build messages with corrected parent references
|
|
405
640
|
for (const rec of records) {
|
|
406
|
-
const sourceRef = rec.uuid
|
|
407
|
-
const timestamp = rec.timestamp
|
|
641
|
+
const sourceRef = rec.uuid;
|
|
642
|
+
const timestamp = rec.timestamp;
|
|
408
643
|
// Start with the resolved parent (through non-message records),
|
|
409
644
|
// then walk through any skipped messages
|
|
410
|
-
const parentMessageRef =
|
|
411
|
-
|
|
412
|
-
|
|
645
|
+
const parentMessageRef = resolveParent(
|
|
646
|
+
resolvedParents.get(rec.uuid),
|
|
647
|
+
skippedParents,
|
|
648
|
+
);
|
|
413
649
|
const rawJson = JSON.stringify(rec);
|
|
414
650
|
|
|
415
|
-
if (rec.type === "user"
|
|
651
|
+
if (rec.type === "user") {
|
|
416
652
|
// Skip tool-result-only user messages (they're just tool responses)
|
|
417
653
|
if (isToolResultOnly(rec.message.content)) continue;
|
|
418
654
|
|
|
@@ -427,7 +663,7 @@ function transformConversation(
|
|
|
427
663
|
content: text,
|
|
428
664
|
});
|
|
429
665
|
}
|
|
430
|
-
} else if (rec.type === "assistant"
|
|
666
|
+
} else if (rec.type === "assistant") {
|
|
431
667
|
const text = extractText(rec.message.content);
|
|
432
668
|
const thinking = extractThinking(rec.message.content);
|
|
433
669
|
const toolCalls = extractToolCalls(rec.message.content, allToolResults);
|
|
@@ -535,6 +771,7 @@ async function discoverFromIndex(
|
|
|
535
771
|
relativePath:
|
|
536
772
|
relative(source, entry.fullPath) || basename(entry.fullPath),
|
|
537
773
|
mtime: fileStat.mtime.getTime(),
|
|
774
|
+
summary: entry.summary,
|
|
538
775
|
});
|
|
539
776
|
} catch {
|
|
540
777
|
// Skip files that no longer exist
|
package/src/sync.ts
CHANGED
|
@@ -198,22 +198,23 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
198
198
|
// Ensure output directory exists
|
|
199
199
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
200
200
|
|
|
201
|
-
//
|
|
202
|
-
const
|
|
203
|
-
|
|
201
|
+
// Use title from: (1) harness-provided summary, (2) cache, (3) LLM later
|
|
202
|
+
const title =
|
|
203
|
+
session.summary ||
|
|
204
|
+
(cached?.contentHash === contentHash
|
|
204
205
|
? cached.segments[i]?.title
|
|
205
|
-
: undefined;
|
|
206
|
+
: undefined);
|
|
206
207
|
|
|
207
208
|
// Render and write
|
|
208
209
|
const rendered = await renderToFormat(transcript, format, {
|
|
209
210
|
sourcePath,
|
|
210
|
-
title
|
|
211
|
+
title,
|
|
211
212
|
});
|
|
212
213
|
await Bun.write(outputPath, rendered);
|
|
213
214
|
newOutputs.push(relativePath);
|
|
214
215
|
|
|
215
216
|
// Build segment cache
|
|
216
|
-
const segmentCache: SegmentCache = { title
|
|
217
|
+
const segmentCache: SegmentCache = { title };
|
|
217
218
|
segmentCache[format] = rendered;
|
|
218
219
|
newCache.segments.push(segmentCache);
|
|
219
220
|
|
|
@@ -224,7 +225,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
224
225
|
segmentIndex,
|
|
225
226
|
syncedAt: new Date().toISOString(),
|
|
226
227
|
firstUserMessage,
|
|
227
|
-
title
|
|
228
|
+
title,
|
|
228
229
|
messageCount: transcript.metadata.messageCount,
|
|
229
230
|
startTime: transcript.metadata.startTime,
|
|
230
231
|
endTime: transcript.metadata.endTime,
|