@arcreflex/agent-transcripts 0.1.8 → 0.1.9
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/CLAUDE.md +4 -0
- package/README.md +40 -3
- package/bun.lock +89 -0
- package/package.json +3 -2
- package/src/adapters/claude-code.ts +203 -32
- package/src/cache.ts +129 -0
- package/src/cli.ts +86 -5
- package/src/convert.ts +6 -14
- package/src/render-html.ts +1096 -0
- package/src/render-index.ts +611 -0
- package/src/render.ts +6 -110
- package/src/serve.ts +308 -0
- package/src/sync.ts +131 -18
- package/src/title.ts +172 -0
- package/src/types.ts +7 -0
- package/src/utils/html.ts +12 -0
- package/src/utils/openrouter.ts +116 -0
- package/src/utils/provenance.ts +25 -41
- package/src/utils/tree.ts +116 -0
- package/test/fixtures/claude/non-message-parents.input.jsonl +9 -0
- package/test/fixtures/claude/non-message-parents.output.md +30 -0
package/src/title.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Title generation command.
|
|
3
|
+
*
|
|
4
|
+
* Adds LLM-generated titles to transcripts.json entries that don't have them.
|
|
5
|
+
* Can be run standalone or called from sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { loadIndex, saveIndex } from "./utils/provenance.ts";
|
|
10
|
+
import { getAdapters } from "./adapters/index.ts";
|
|
11
|
+
import { renderTranscript } from "./render.ts";
|
|
12
|
+
import { renderTranscriptHtml } from "./render-html.ts";
|
|
13
|
+
import { generateTitle } from "./utils/openrouter.ts";
|
|
14
|
+
import {
|
|
15
|
+
computeContentHash,
|
|
16
|
+
loadCache,
|
|
17
|
+
saveCache,
|
|
18
|
+
getCachedTitle,
|
|
19
|
+
type CacheEntry,
|
|
20
|
+
} from "./cache.ts";
|
|
21
|
+
|
|
22
|
+
export interface TitleOptions {
|
|
23
|
+
outputDir: string;
|
|
24
|
+
force?: boolean; // regenerate all titles, not just missing ones
|
|
25
|
+
quiet?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TitleResult {
|
|
29
|
+
generated: number;
|
|
30
|
+
skipped: number;
|
|
31
|
+
errors: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate titles for transcripts.json entries that don't have them.
|
|
36
|
+
*/
|
|
37
|
+
export async function generateTitles(
|
|
38
|
+
options: TitleOptions,
|
|
39
|
+
): Promise<TitleResult> {
|
|
40
|
+
const { outputDir, force = false, quiet = false } = options;
|
|
41
|
+
|
|
42
|
+
const result: TitleResult = { generated: 0, skipped: 0, errors: 0 };
|
|
43
|
+
|
|
44
|
+
if (!process.env.OPENROUTER_API_KEY) {
|
|
45
|
+
if (!quiet) {
|
|
46
|
+
console.error("OPENROUTER_API_KEY not set, skipping title generation");
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const index = await loadIndex(outputDir);
|
|
52
|
+
const entries = Object.entries(index.entries);
|
|
53
|
+
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
if (!quiet) {
|
|
56
|
+
console.error("No entries in transcripts.json");
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const adapters = getAdapters();
|
|
62
|
+
const adapterMap = new Map(adapters.map((a) => [a.name, a]));
|
|
63
|
+
|
|
64
|
+
// Process entries that need titles
|
|
65
|
+
for (const [filename, entry] of entries) {
|
|
66
|
+
// Skip if already has title (unless force)
|
|
67
|
+
if (entry.title && !force) {
|
|
68
|
+
result.skipped++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Read source and compute content hash
|
|
74
|
+
const content = await Bun.file(entry.source).text();
|
|
75
|
+
const contentHash = computeContentHash(content);
|
|
76
|
+
|
|
77
|
+
// Check cache for existing title
|
|
78
|
+
const cached = await loadCache(entry.source);
|
|
79
|
+
const segmentIndex = entry.segmentIndex ? entry.segmentIndex - 1 : 0;
|
|
80
|
+
const cachedTitle = getCachedTitle(cached, contentHash, segmentIndex);
|
|
81
|
+
|
|
82
|
+
if (cachedTitle && !force) {
|
|
83
|
+
entry.title = cachedTitle;
|
|
84
|
+
result.skipped++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Determine adapter from filename pattern (HTML files were synced with an adapter)
|
|
89
|
+
// We need to find which adapter was used - check the source path
|
|
90
|
+
let adapter = adapterMap.get("claude-code"); // default
|
|
91
|
+
for (const a of adapters) {
|
|
92
|
+
if (entry.source.includes(".claude/")) {
|
|
93
|
+
adapter = a;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!adapter) {
|
|
99
|
+
console.error(`Warning: No adapter found for ${entry.source}`);
|
|
100
|
+
result.errors++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const transcripts = adapter.parse(content, entry.source);
|
|
105
|
+
|
|
106
|
+
// Find the right transcript (by segment index if applicable)
|
|
107
|
+
const transcript = transcripts[segmentIndex];
|
|
108
|
+
|
|
109
|
+
if (!transcript) {
|
|
110
|
+
console.error(`Warning: Transcript not found for ${filename}`);
|
|
111
|
+
result.errors++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const markdown = renderTranscript(transcript);
|
|
116
|
+
const title = await generateTitle(markdown);
|
|
117
|
+
|
|
118
|
+
if (title) {
|
|
119
|
+
entry.title = title;
|
|
120
|
+
result.generated++;
|
|
121
|
+
if (!quiet) {
|
|
122
|
+
console.error(`Title: ${filename} → ${title}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Update cache with new title
|
|
126
|
+
// Start fresh if content changed to avoid stale md/html
|
|
127
|
+
// Deep copy segments to avoid mutating cached object
|
|
128
|
+
const newCache: CacheEntry = {
|
|
129
|
+
contentHash,
|
|
130
|
+
segments:
|
|
131
|
+
cached?.contentHash === contentHash
|
|
132
|
+
? cached.segments.map((s) => ({ ...s }))
|
|
133
|
+
: [],
|
|
134
|
+
};
|
|
135
|
+
// Ensure segment array is long enough
|
|
136
|
+
while (newCache.segments.length <= segmentIndex) {
|
|
137
|
+
newCache.segments.push({});
|
|
138
|
+
}
|
|
139
|
+
newCache.segments[segmentIndex].title = title;
|
|
140
|
+
|
|
141
|
+
// Re-render HTML with title if this is an HTML file
|
|
142
|
+
if (filename.endsWith(".html")) {
|
|
143
|
+
const html = await renderTranscriptHtml(transcript, { title });
|
|
144
|
+
const outputPath = join(outputDir, filename);
|
|
145
|
+
await Bun.write(outputPath, html);
|
|
146
|
+
newCache.segments[segmentIndex].html = html;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await saveCache(entry.source, newCache);
|
|
150
|
+
} else {
|
|
151
|
+
result.skipped++;
|
|
152
|
+
if (!quiet) {
|
|
153
|
+
console.error(`Skip (no title generated): ${filename}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
console.error(`Error: ${filename}: ${message}`);
|
|
159
|
+
result.errors++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await saveIndex(outputDir, index);
|
|
164
|
+
|
|
165
|
+
if (!quiet) {
|
|
166
|
+
console.error(
|
|
167
|
+
`\nTitle generation complete: ${result.generated} generated, ${result.skipped} skipped, ${result.errors} errors`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -10,6 +10,10 @@ export interface Transcript {
|
|
|
10
10
|
};
|
|
11
11
|
metadata: {
|
|
12
12
|
warnings: Warning[];
|
|
13
|
+
messageCount: number;
|
|
14
|
+
startTime: string; // ISO timestamp of first message
|
|
15
|
+
endTime: string; // ISO timestamp of last message
|
|
16
|
+
cwd?: string; // Working directory (if known)
|
|
13
17
|
};
|
|
14
18
|
messages: Message[];
|
|
15
19
|
}
|
|
@@ -31,6 +35,7 @@ interface BaseMessage {
|
|
|
31
35
|
sourceRef: string;
|
|
32
36
|
timestamp: string;
|
|
33
37
|
parentMessageRef?: string; // UUID of parent message (for tree reconstruction)
|
|
38
|
+
rawJson?: string; // Original JSON for raw view toggle
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export interface UserMessage extends BaseMessage {
|
|
@@ -57,6 +62,8 @@ export interface ToolCallGroup extends BaseMessage {
|
|
|
57
62
|
export interface ToolCall {
|
|
58
63
|
name: string;
|
|
59
64
|
summary: string;
|
|
65
|
+
input?: Record<string, unknown>;
|
|
66
|
+
result?: string;
|
|
60
67
|
error?: string;
|
|
61
68
|
}
|
|
62
69
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter API client for LLM-based title generation.
|
|
3
|
+
*
|
|
4
|
+
* Uses Gemini 2.5 Flash for fast, cheap title generation.
|
|
5
|
+
* Gracefully handles missing API key or API failures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
9
|
+
const MODEL = "google/gemini-2.5-flash";
|
|
10
|
+
|
|
11
|
+
// Approximate token limit for context (conservative estimate)
|
|
12
|
+
// Gemini Flash has 1M context, but we don't need anywhere near that
|
|
13
|
+
const MAX_CHARS = 32000; // ~8k tokens
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Truncate content with middle-cut strategy.
|
|
17
|
+
* Keeps beginning and end, removes middle if over limit.
|
|
18
|
+
*/
|
|
19
|
+
function truncateMiddle(content: string, maxChars: number): string {
|
|
20
|
+
if (content.length <= maxChars) return content;
|
|
21
|
+
|
|
22
|
+
const halfLimit = Math.floor(maxChars / 2);
|
|
23
|
+
const start = content.slice(0, halfLimit);
|
|
24
|
+
const end = content.slice(-halfLimit);
|
|
25
|
+
|
|
26
|
+
return `${start}\n\n[... middle truncated ...]\n\n${end}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface OpenRouterResponse {
|
|
30
|
+
choices?: Array<{
|
|
31
|
+
message?: {
|
|
32
|
+
content?: string;
|
|
33
|
+
};
|
|
34
|
+
}>;
|
|
35
|
+
error?: {
|
|
36
|
+
message?: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate a title for a transcript using OpenRouter.
|
|
42
|
+
*
|
|
43
|
+
* @param markdownContent - The full markdown transcript
|
|
44
|
+
* @returns Generated title, or undefined if generation fails/skipped
|
|
45
|
+
*/
|
|
46
|
+
export async function generateTitle(
|
|
47
|
+
markdownContent: string,
|
|
48
|
+
): Promise<string | undefined> {
|
|
49
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
50
|
+
|
|
51
|
+
if (!apiKey) {
|
|
52
|
+
// Silently skip - no API key means user doesn't want title generation
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const truncated = truncateMiddle(markdownContent, MAX_CHARS);
|
|
57
|
+
|
|
58
|
+
const prompt = `Generate a concise title (5-10 words) for this AI coding session transcript. The title should capture the main task or topic discussed.
|
|
59
|
+
|
|
60
|
+
Reply with just the title, no quotes, no punctuation at the end, no explanation.
|
|
61
|
+
|
|
62
|
+
Transcript:
|
|
63
|
+
${truncated}`;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(OPENROUTER_API_URL, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${apiKey}`,
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"HTTP-Referer": "https://github.com/arcreflex/agent-transcripts",
|
|
72
|
+
"X-Title": "agent-transcripts",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
model: MODEL,
|
|
76
|
+
messages: [{ role: "user", content: prompt }],
|
|
77
|
+
max_tokens: 50,
|
|
78
|
+
temperature: 0.3,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
console.error(
|
|
85
|
+
`Warning: OpenRouter API error (${response.status}): ${text.slice(0, 200)}`,
|
|
86
|
+
);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = (await response.json()) as OpenRouterResponse;
|
|
91
|
+
|
|
92
|
+
if (data.error) {
|
|
93
|
+
console.error(
|
|
94
|
+
`Warning: OpenRouter error: ${data.error.message || "Unknown error"}`,
|
|
95
|
+
);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const title = data.choices?.[0]?.message?.content?.trim();
|
|
100
|
+
|
|
101
|
+
if (!title) {
|
|
102
|
+
console.error("Warning: OpenRouter returned empty title");
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Clean up: remove quotes if present, trim trailing punctuation
|
|
107
|
+
return title
|
|
108
|
+
.replace(/^["']|["']$/g, "")
|
|
109
|
+
.replace(/[.!?]+$/, "")
|
|
110
|
+
.trim();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
113
|
+
console.error(`Warning: OpenRouter request failed: ${message}`);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/utils/provenance.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { join, resolve } from "path";
|
|
9
|
-
import { existsSync } from "fs";
|
|
10
9
|
import { rename, unlink } from "fs/promises";
|
|
11
10
|
|
|
12
11
|
const INDEX_FILENAME = "transcripts.json";
|
|
@@ -17,10 +16,15 @@ const INDEX_FILENAME = "transcripts.json";
|
|
|
17
16
|
|
|
18
17
|
export interface TranscriptEntry {
|
|
19
18
|
source: string; // absolute path to source
|
|
20
|
-
sourceMtime: number; // ms since epoch
|
|
21
19
|
sessionId: string; // full session ID from source filename
|
|
22
20
|
segmentIndex?: number; // for multi-transcript sources (1-indexed)
|
|
23
21
|
syncedAt: string; // ISO timestamp
|
|
22
|
+
firstUserMessage: string; // first user message content (for display)
|
|
23
|
+
title?: string; // copied from cache for index.html convenience
|
|
24
|
+
messageCount: number;
|
|
25
|
+
startTime: string; // ISO timestamp
|
|
26
|
+
endTime: string; // ISO timestamp
|
|
27
|
+
cwd?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface TranscriptsIndex {
|
|
@@ -120,45 +124,6 @@ export function getOutputsForSource(
|
|
|
120
124
|
return outputs;
|
|
121
125
|
}
|
|
122
126
|
|
|
123
|
-
/**
|
|
124
|
-
* Check if outputs for a source are stale.
|
|
125
|
-
* Returns true if:
|
|
126
|
-
* - No outputs exist for this source
|
|
127
|
-
* - Output count doesn't match expected
|
|
128
|
-
* - Any output file is missing from disk
|
|
129
|
-
* - Source mtime is newer than recorded mtime
|
|
130
|
-
*/
|
|
131
|
-
export function isStale(
|
|
132
|
-
index: TranscriptsIndex,
|
|
133
|
-
sourcePath: string,
|
|
134
|
-
sourceMtime: number,
|
|
135
|
-
expectedCount: number,
|
|
136
|
-
outputDir: string,
|
|
137
|
-
): boolean {
|
|
138
|
-
const outputs = getOutputsForSource(index, sourcePath);
|
|
139
|
-
|
|
140
|
-
if (outputs.length !== expectedCount) {
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Check if outputs actually exist on disk
|
|
145
|
-
for (const filename of outputs) {
|
|
146
|
-
if (!existsSync(join(outputDir, filename))) {
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Check if source has been modified since last sync
|
|
152
|
-
for (const filename of outputs) {
|
|
153
|
-
const entry = index.entries[filename];
|
|
154
|
-
if (entry && entry.sourceMtime < sourceMtime) {
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
127
|
/**
|
|
163
128
|
* Set or update an entry in the index.
|
|
164
129
|
* outputPath should be relative to the output directory.
|
|
@@ -226,3 +191,22 @@ export async function deleteOutputFiles(
|
|
|
226
191
|
}
|
|
227
192
|
}
|
|
228
193
|
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// Transcript Metadata Extraction
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
import type { Transcript } from "../types.ts";
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract the first user message from a transcript.
|
|
203
|
+
* Returns empty string if no user message found.
|
|
204
|
+
*/
|
|
205
|
+
export function extractFirstUserMessage(transcript: Transcript): string {
|
|
206
|
+
for (const msg of transcript.messages) {
|
|
207
|
+
if (msg.type === "user") {
|
|
208
|
+
return msg.content;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree/branch navigation utilities for transcript messages.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Message } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
export interface MessageTree {
|
|
8
|
+
bySourceRef: Map<string, Message[]>;
|
|
9
|
+
children: Map<string, Set<string>>;
|
|
10
|
+
parents: Map<string, string>;
|
|
11
|
+
roots: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build tree structure from messages.
|
|
16
|
+
* Returns maps for navigation and the messages grouped by sourceRef.
|
|
17
|
+
*/
|
|
18
|
+
export function buildTree(messages: Message[]): MessageTree {
|
|
19
|
+
const bySourceRef = new Map<string, Message[]>();
|
|
20
|
+
for (const msg of messages) {
|
|
21
|
+
const existing = bySourceRef.get(msg.sourceRef) || [];
|
|
22
|
+
existing.push(msg);
|
|
23
|
+
bySourceRef.set(msg.sourceRef, existing);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const children = new Map<string, Set<string>>();
|
|
27
|
+
const parents = new Map<string, string>();
|
|
28
|
+
|
|
29
|
+
for (const msg of messages) {
|
|
30
|
+
if (msg.parentMessageRef && bySourceRef.has(msg.parentMessageRef)) {
|
|
31
|
+
parents.set(msg.sourceRef, msg.parentMessageRef);
|
|
32
|
+
const existing = children.get(msg.parentMessageRef) || new Set();
|
|
33
|
+
existing.add(msg.sourceRef);
|
|
34
|
+
children.set(msg.parentMessageRef, existing);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const roots: string[] = [];
|
|
39
|
+
for (const sourceRef of bySourceRef.keys()) {
|
|
40
|
+
if (!parents.has(sourceRef)) {
|
|
41
|
+
roots.push(sourceRef);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { bySourceRef, children, parents, roots };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find the latest leaf in the tree (for primary branch).
|
|
50
|
+
*/
|
|
51
|
+
export function findLatestLeaf(
|
|
52
|
+
bySourceRef: Map<string, Message[]>,
|
|
53
|
+
children: Map<string, Set<string>>,
|
|
54
|
+
): string | undefined {
|
|
55
|
+
let latestLeaf: string | undefined;
|
|
56
|
+
let latestTime = 0;
|
|
57
|
+
|
|
58
|
+
for (const sourceRef of bySourceRef.keys()) {
|
|
59
|
+
const childSet = children.get(sourceRef);
|
|
60
|
+
if (!childSet || childSet.size === 0) {
|
|
61
|
+
const msgs = bySourceRef.get(sourceRef);
|
|
62
|
+
if (msgs && msgs.length > 0) {
|
|
63
|
+
const time = new Date(msgs[0].timestamp).getTime();
|
|
64
|
+
if (time > latestTime) {
|
|
65
|
+
latestTime = time;
|
|
66
|
+
latestLeaf = sourceRef;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return latestLeaf;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Trace path from root to target.
|
|
77
|
+
*/
|
|
78
|
+
export function tracePath(
|
|
79
|
+
target: string,
|
|
80
|
+
parents: Map<string, string>,
|
|
81
|
+
): string[] {
|
|
82
|
+
const path: string[] = [];
|
|
83
|
+
let current: string | undefined = target;
|
|
84
|
+
|
|
85
|
+
while (current) {
|
|
86
|
+
path.unshift(current);
|
|
87
|
+
current = parents.get(current);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get first line of message content for branch reference display.
|
|
95
|
+
*/
|
|
96
|
+
export function getFirstLine(msg: Message): string {
|
|
97
|
+
let text: string;
|
|
98
|
+
switch (msg.type) {
|
|
99
|
+
case "user":
|
|
100
|
+
case "assistant":
|
|
101
|
+
case "system":
|
|
102
|
+
case "error":
|
|
103
|
+
text = msg.content;
|
|
104
|
+
break;
|
|
105
|
+
case "tool_calls":
|
|
106
|
+
text = msg.calls.map((c) => c.name).join(", ");
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
text = "";
|
|
110
|
+
}
|
|
111
|
+
const firstLine = text.split("\n")[0].trim();
|
|
112
|
+
const maxLen = 60;
|
|
113
|
+
return firstLine.length > maxLen
|
|
114
|
+
? firstLine.slice(0, maxLen) + "..."
|
|
115
|
+
: firstLine;
|
|
116
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{"type": "user", "uuid": "msg-1", "message": {"role": "user", "content": "First message"}, "timestamp": "2024-01-15T10:00:00Z"}
|
|
2
|
+
{"type": "assistant", "uuid": "msg-2", "parentUuid": "msg-1", "message": {"role": "assistant", "content": [{"type": "text", "text": "First response"}]}, "timestamp": "2024-01-15T10:00:05Z"}
|
|
3
|
+
{"type": "progress", "uuid": "progress-1", "parentUuid": "msg-2", "content": "Processing...", "timestamp": "2024-01-15T10:00:10Z"}
|
|
4
|
+
{"type": "user", "uuid": "msg-3", "parentUuid": "progress-1", "message": {"role": "user", "content": "Second message (parent is progress record)"}, "timestamp": "2024-01-15T10:00:15Z"}
|
|
5
|
+
{"type": "assistant", "uuid": "msg-4", "parentUuid": "msg-3", "message": {"role": "assistant", "content": [{"type": "text", "text": "Second response"}]}, "timestamp": "2024-01-15T10:00:20Z"}
|
|
6
|
+
{"type": "progress", "uuid": "progress-2", "parentUuid": "msg-4", "content": "More processing...", "timestamp": "2024-01-15T10:00:25Z"}
|
|
7
|
+
{"type": "progress", "uuid": "progress-3", "parentUuid": "progress-2", "content": "Nested progress...", "timestamp": "2024-01-15T10:00:26Z"}
|
|
8
|
+
{"type": "user", "uuid": "msg-5", "parentUuid": "progress-3", "message": {"role": "user", "content": "Third message (parent is nested progress)"}, "timestamp": "2024-01-15T10:00:30Z"}
|
|
9
|
+
{"type": "assistant", "uuid": "msg-6", "parentUuid": "msg-5", "message": {"role": "assistant", "content": [{"type": "text", "text": "Third response"}]}, "timestamp": "2024-01-15T10:00:35Z"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Transcript
|
|
2
|
+
|
|
3
|
+
**Source**: `test/fixtures/claude/non-message-parents.input.jsonl`
|
|
4
|
+
**Adapter**: claude-code
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## User
|
|
9
|
+
|
|
10
|
+
First message
|
|
11
|
+
|
|
12
|
+
## Assistant
|
|
13
|
+
|
|
14
|
+
First response
|
|
15
|
+
|
|
16
|
+
## User
|
|
17
|
+
|
|
18
|
+
Second message (parent is progress record)
|
|
19
|
+
|
|
20
|
+
## Assistant
|
|
21
|
+
|
|
22
|
+
Second response
|
|
23
|
+
|
|
24
|
+
## User
|
|
25
|
+
|
|
26
|
+
Third message (parent is nested progress)
|
|
27
|
+
|
|
28
|
+
## Assistant
|
|
29
|
+
|
|
30
|
+
Third response
|