@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/cache.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache module for agent-transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Stores derived content (rendered outputs, titles) keyed by source path,
|
|
5
|
+
* invalidated by content hash. Cache lives at ~/.cache/agent-transcripts/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { mkdir, rename, unlink } from "fs/promises";
|
|
11
|
+
|
|
12
|
+
const CACHE_DIR = join(homedir(), ".cache", "agent-transcripts");
|
|
13
|
+
|
|
14
|
+
export interface SegmentCache {
|
|
15
|
+
title?: string;
|
|
16
|
+
html?: string;
|
|
17
|
+
md?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CacheEntry {
|
|
21
|
+
contentHash: string;
|
|
22
|
+
segments: SegmentCache[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute a hash of file content for cache invalidation.
|
|
27
|
+
*/
|
|
28
|
+
export function computeContentHash(content: string): string {
|
|
29
|
+
return Bun.hash(content).toString(16);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the cache file path for a source file.
|
|
34
|
+
* Uses hash of source path to avoid filesystem issues with special chars.
|
|
35
|
+
*/
|
|
36
|
+
function getCachePath(sourcePath: string): string {
|
|
37
|
+
const pathHash = Bun.hash(sourcePath).toString(16);
|
|
38
|
+
return join(CACHE_DIR, `${pathHash}.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure cache directory exists.
|
|
43
|
+
*/
|
|
44
|
+
async function ensureCacheDir(): Promise<void> {
|
|
45
|
+
await mkdir(CACHE_DIR, { recursive: true, mode: 0o755 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load cache entry for a source file.
|
|
50
|
+
* Returns undefined if no cache exists or cache is corrupt.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadCache(
|
|
53
|
+
sourcePath: string,
|
|
54
|
+
): Promise<CacheEntry | undefined> {
|
|
55
|
+
const cachePath = getCachePath(sourcePath);
|
|
56
|
+
try {
|
|
57
|
+
const content = await Bun.file(cachePath).text();
|
|
58
|
+
return JSON.parse(content) as CacheEntry;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Save cache entry for a source file.
|
|
66
|
+
* Uses atomic write (temp file + rename) to prevent corruption.
|
|
67
|
+
*/
|
|
68
|
+
export async function saveCache(
|
|
69
|
+
sourcePath: string,
|
|
70
|
+
entry: CacheEntry,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await ensureCacheDir();
|
|
73
|
+
|
|
74
|
+
const cachePath = getCachePath(sourcePath);
|
|
75
|
+
const tmpPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
|
|
76
|
+
|
|
77
|
+
const content = JSON.stringify(entry, null, 2) + "\n";
|
|
78
|
+
await Bun.write(tmpPath, content);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await rename(tmpPath, cachePath);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
try {
|
|
84
|
+
await unlink(tmpPath);
|
|
85
|
+
} catch {
|
|
86
|
+
// Ignore cleanup errors
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if cache is valid for the given content hash and format.
|
|
94
|
+
* Returns the cached segments if valid, undefined otherwise.
|
|
95
|
+
*/
|
|
96
|
+
export function getCachedSegments(
|
|
97
|
+
cached: CacheEntry | undefined,
|
|
98
|
+
contentHash: string,
|
|
99
|
+
format: "html" | "md",
|
|
100
|
+
): SegmentCache[] | undefined {
|
|
101
|
+
if (!cached || cached.contentHash !== contentHash) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// Check that all segments have the requested format
|
|
105
|
+
if (cached.segments.length === 0) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
for (const seg of cached.segments) {
|
|
109
|
+
if (!seg[format]) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return cached.segments;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get cached title for a specific segment.
|
|
118
|
+
* Returns undefined if cache is invalid or title not present.
|
|
119
|
+
*/
|
|
120
|
+
export function getCachedTitle(
|
|
121
|
+
cached: CacheEntry | undefined,
|
|
122
|
+
contentHash: string,
|
|
123
|
+
segmentIndex: number,
|
|
124
|
+
): string | undefined {
|
|
125
|
+
if (!cached || cached.contentHash !== contentHash) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
return cached.segments[segmentIndex]?.title;
|
|
129
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
subcommands,
|
|
8
8
|
run,
|
|
9
9
|
string,
|
|
10
|
+
number,
|
|
10
11
|
option,
|
|
11
12
|
optional,
|
|
12
13
|
positional,
|
|
@@ -14,8 +15,20 @@ import {
|
|
|
14
15
|
} from "cmd-ts";
|
|
15
16
|
import { parseToTranscripts } from "./parse.ts";
|
|
16
17
|
import { renderTranscript } from "./render.ts";
|
|
17
|
-
import { sync } from "./sync.ts";
|
|
18
|
+
import { sync, type OutputFormat } from "./sync.ts";
|
|
18
19
|
import { convertToDirectory } from "./convert.ts";
|
|
20
|
+
import { generateTitles } from "./title.ts";
|
|
21
|
+
import { serve } from "./serve.ts";
|
|
22
|
+
|
|
23
|
+
// Custom type for format option
|
|
24
|
+
const formatType = {
|
|
25
|
+
async from(value: string): Promise<OutputFormat> {
|
|
26
|
+
if (value !== "md" && value !== "html") {
|
|
27
|
+
throw new Error(`Invalid format: ${value}. Must be "md" or "html".`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
19
32
|
|
|
20
33
|
// Shared options
|
|
21
34
|
const inputArg = positional({
|
|
@@ -47,7 +60,7 @@ const headOpt = option({
|
|
|
47
60
|
// Sync subcommand
|
|
48
61
|
const syncCmd = command({
|
|
49
62
|
name: "sync",
|
|
50
|
-
description: "Sync session files to markdown
|
|
63
|
+
description: "Sync session files to transcripts (markdown or HTML)",
|
|
51
64
|
args: {
|
|
52
65
|
source: positional({
|
|
53
66
|
type: string,
|
|
@@ -60,6 +73,15 @@ const syncCmd = command({
|
|
|
60
73
|
short: "o",
|
|
61
74
|
description: "Output directory for transcripts",
|
|
62
75
|
}),
|
|
76
|
+
format: option({
|
|
77
|
+
type: optional(formatType),
|
|
78
|
+
long: "format",
|
|
79
|
+
description: "Output format: md (default) or html",
|
|
80
|
+
}),
|
|
81
|
+
noTitle: flag({
|
|
82
|
+
long: "no-title",
|
|
83
|
+
description: "Skip LLM title generation (for HTML format)",
|
|
84
|
+
}),
|
|
63
85
|
force: flag({
|
|
64
86
|
long: "force",
|
|
65
87
|
short: "f",
|
|
@@ -71,8 +93,65 @@ const syncCmd = command({
|
|
|
71
93
|
description: "Suppress progress output",
|
|
72
94
|
}),
|
|
73
95
|
},
|
|
74
|
-
async handler({ source, output, force, quiet }) {
|
|
75
|
-
await sync({ source, output, force, quiet });
|
|
96
|
+
async handler({ source, output, format, noTitle, force, quiet }) {
|
|
97
|
+
await sync({ source, output, format, noTitle, force, quiet });
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Title subcommand
|
|
102
|
+
const titleCmd = command({
|
|
103
|
+
name: "title",
|
|
104
|
+
description: "Generate LLM titles for transcripts.json entries",
|
|
105
|
+
args: {
|
|
106
|
+
output: positional({
|
|
107
|
+
type: string,
|
|
108
|
+
displayName: "output",
|
|
109
|
+
description: "Output directory containing transcripts.json",
|
|
110
|
+
}),
|
|
111
|
+
force: flag({
|
|
112
|
+
long: "force",
|
|
113
|
+
short: "f",
|
|
114
|
+
description: "Regenerate all titles, not just missing ones",
|
|
115
|
+
}),
|
|
116
|
+
quiet: flag({
|
|
117
|
+
long: "quiet",
|
|
118
|
+
short: "q",
|
|
119
|
+
description: "Suppress progress output",
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
async handler({ output, force, quiet }) {
|
|
123
|
+
await generateTitles({ outputDir: output, force, quiet });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Serve subcommand
|
|
128
|
+
const serveCmd = command({
|
|
129
|
+
name: "serve",
|
|
130
|
+
description: "Serve transcripts via HTTP (dynamic rendering with caching)",
|
|
131
|
+
args: {
|
|
132
|
+
source: positional({
|
|
133
|
+
type: string,
|
|
134
|
+
displayName: "source",
|
|
135
|
+
description: "Source directory to scan for session files",
|
|
136
|
+
}),
|
|
137
|
+
port: option({
|
|
138
|
+
type: optional(number),
|
|
139
|
+
long: "port",
|
|
140
|
+
short: "p",
|
|
141
|
+
description: "Port to listen on (default: 3000)",
|
|
142
|
+
}),
|
|
143
|
+
quiet: flag({
|
|
144
|
+
long: "quiet",
|
|
145
|
+
short: "q",
|
|
146
|
+
description: "Suppress request logging",
|
|
147
|
+
}),
|
|
148
|
+
noCache: flag({
|
|
149
|
+
long: "no-cache",
|
|
150
|
+
description: "Bypass HTML cache (for development)",
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
async handler({ source, port, quiet, noCache }) {
|
|
154
|
+
await serve({ source, port: port ?? 3000, quiet, noCache });
|
|
76
155
|
},
|
|
77
156
|
});
|
|
78
157
|
|
|
@@ -119,7 +198,7 @@ const convertCmd = command({
|
|
|
119
198
|
},
|
|
120
199
|
});
|
|
121
200
|
|
|
122
|
-
const SUBCOMMANDS = ["convert", "sync"] as const;
|
|
201
|
+
const SUBCOMMANDS = ["convert", "sync", "title", "serve"] as const;
|
|
123
202
|
|
|
124
203
|
// Main CLI with subcommands
|
|
125
204
|
const cli = subcommands({
|
|
@@ -128,6 +207,8 @@ const cli = subcommands({
|
|
|
128
207
|
cmds: {
|
|
129
208
|
convert: convertCmd,
|
|
130
209
|
sync: syncCmd,
|
|
210
|
+
title: titleCmd,
|
|
211
|
+
serve: serveCmd,
|
|
131
212
|
},
|
|
132
213
|
});
|
|
133
214
|
|
package/src/convert.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
deleteOutputFiles,
|
|
19
19
|
setEntry,
|
|
20
20
|
normalizeSourcePath,
|
|
21
|
+
extractFirstUserMessage,
|
|
21
22
|
} from "./utils/provenance.ts";
|
|
22
23
|
|
|
23
24
|
export interface ConvertToDirectoryOptions {
|
|
@@ -55,19 +56,6 @@ export async function convertToDirectory(
|
|
|
55
56
|
const removedEntries =
|
|
56
57
|
sourcePath !== "<stdin>" ? removeEntriesForSource(index, sourcePath) : [];
|
|
57
58
|
|
|
58
|
-
// Get source mtime for index entry
|
|
59
|
-
let sourceMtime = Date.now();
|
|
60
|
-
if (sourcePath !== "<stdin>") {
|
|
61
|
-
try {
|
|
62
|
-
const stat = await Bun.file(sourcePath).stat();
|
|
63
|
-
if (stat) {
|
|
64
|
-
sourceMtime = stat.mtime.getTime();
|
|
65
|
-
}
|
|
66
|
-
} catch {
|
|
67
|
-
// Use current time as fallback
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
59
|
const sessionId = extractSessionId(inputPath);
|
|
72
60
|
const newOutputs: string[] = [];
|
|
73
61
|
|
|
@@ -95,10 +83,14 @@ export async function convertToDirectory(
|
|
|
95
83
|
if (sourcePath !== "<stdin>") {
|
|
96
84
|
setEntry(index, relativePath, {
|
|
97
85
|
source: sourcePath,
|
|
98
|
-
sourceMtime,
|
|
99
86
|
sessionId,
|
|
100
87
|
segmentIndex,
|
|
101
88
|
syncedAt: new Date().toISOString(),
|
|
89
|
+
firstUserMessage: extractFirstUserMessage(transcript),
|
|
90
|
+
messageCount: transcript.metadata.messageCount,
|
|
91
|
+
startTime: transcript.metadata.startTime,
|
|
92
|
+
endTime: transcript.metadata.endTime,
|
|
93
|
+
cwd: transcript.metadata.cwd,
|
|
102
94
|
});
|
|
103
95
|
}
|
|
104
96
|
|