@arcreflex/agent-transcripts 0.1.10 → 0.1.12

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/src/title.ts CHANGED
@@ -1,27 +1,19 @@
1
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.
2
+ * Title generation: add LLM-generated titles to archive entries.
6
3
  */
7
4
 
8
- import { join } from "path";
9
- import { loadIndex, saveIndex } from "./utils/provenance.ts";
10
- import { getAdapters } from "./adapters/index.ts";
5
+ import {
6
+ listEntries,
7
+ saveEntry,
8
+ DEFAULT_ARCHIVE_DIR,
9
+ type ArchiveEntry,
10
+ } from "./archive.ts";
11
11
  import { renderTranscript } from "./render.ts";
12
- import { renderTranscriptHtml } from "./render-html.ts";
13
12
  import { generateTitle } from "./utils/openrouter.ts";
14
- import {
15
- computeContentHash,
16
- loadCache,
17
- saveCache,
18
- getCachedTitle,
19
- type CacheEntry,
20
- } from "./cache.ts";
21
13
 
22
14
  export interface TitleOptions {
23
- outputDir: string;
24
- force?: boolean; // regenerate all titles, not just missing ones
15
+ archiveDir?: string;
16
+ force?: boolean;
25
17
  quiet?: boolean;
26
18
  }
27
19
 
@@ -31,13 +23,14 @@ export interface TitleResult {
31
23
  errors: number;
32
24
  }
33
25
 
34
- /**
35
- * Generate titles for transcripts.json entries that don't have them.
36
- */
37
26
  export async function generateTitles(
38
27
  options: TitleOptions,
39
28
  ): Promise<TitleResult> {
40
- const { outputDir, force = false, quiet = false } = options;
29
+ const {
30
+ archiveDir = DEFAULT_ARCHIVE_DIR,
31
+ force = false,
32
+ quiet = false,
33
+ } = options;
41
34
 
42
35
  const result: TitleResult = { generated: 0, skipped: 0, errors: 0 };
43
36
 
@@ -48,123 +41,52 @@ export async function generateTitles(
48
41
  return result;
49
42
  }
50
43
 
51
- const index = await loadIndex(outputDir);
52
- const entries = Object.entries(index.entries);
44
+ const entries = await listEntries(archiveDir);
53
45
 
54
46
  if (entries.length === 0) {
55
47
  if (!quiet) {
56
- console.error("No entries in transcripts.json");
48
+ console.error("No entries in archive");
57
49
  }
58
50
  return result;
59
51
  }
60
52
 
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)
53
+ for (const entry of entries) {
67
54
  if (entry.title && !force) {
68
55
  result.skipped++;
69
56
  continue;
70
57
  }
71
58
 
72
59
  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;
60
+ // Use the first transcript for title generation
61
+ const transcript = entry.transcripts[0];
62
+ if (!transcript || transcript.metadata.messageCount === 0) {
84
63
  result.skipped++;
85
64
  continue;
86
65
  }
87
66
 
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
67
  const markdown = renderTranscript(transcript);
116
68
  const title = await generateTitle(markdown);
117
69
 
118
70
  if (title) {
119
71
  entry.title = title;
72
+ await saveEntry(archiveDir, entry);
120
73
  result.generated++;
121
74
  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({});
75
+ console.error(`Title: ${entry.sessionId} → ${title}`);
138
76
  }
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
77
  } else {
151
78
  result.skipped++;
152
- if (!quiet) {
153
- console.error(`Skip (no title generated): ${filename}`);
154
- }
155
79
  }
156
80
  } catch (error) {
157
81
  const message = error instanceof Error ? error.message : String(error);
158
- console.error(`Error: ${filename}: ${message}`);
82
+ console.error(`Error: ${entry.sessionId}: ${message}`);
159
83
  result.errors++;
160
84
  }
161
85
  }
162
86
 
163
- await saveIndex(outputDir, index);
164
-
165
87
  if (!quiet) {
166
88
  console.error(
167
- `\nTitle generation complete: ${result.generated} generated, ${result.skipped} skipped, ${result.errors} errors`,
89
+ `\nTitle generation: ${result.generated} generated, ${result.skipped} skipped, ${result.errors} errors`,
168
90
  );
169
91
  }
170
92
 
package/src/types.ts CHANGED
@@ -76,6 +76,7 @@ export interface ErrorMessage extends BaseMessage {
76
76
  * A session file discovered by an adapter.
77
77
  */
78
78
  export interface DiscoveredSession {
79
+ /** Absolute path to the session file. Must be absolute for archive traceability. */
79
80
  path: string;
80
81
  relativePath: string;
81
82
  mtime: number;
@@ -88,6 +89,8 @@ export interface DiscoveredSession {
88
89
  */
89
90
  export interface Adapter {
90
91
  name: string;
92
+ /** Versioned identifier for cache invalidation (e.g. "claude-code:1") */
93
+ version: string;
91
94
  /** Discover session files in the given directory */
92
95
  discover(source: string): Promise<DiscoveredSession[]>;
93
96
  /** Parse source content into one or more transcripts (split by conversation) */
@@ -7,22 +7,17 @@
7
7
  import type { Transcript } from "../types.ts";
8
8
  import { basename } from "path";
9
9
 
10
- /**
11
- * Extract date and time from transcript's first message timestamp.
12
- * Returns format: yyyy-mm-dd-hhmm (24-hour, local time)
13
- */
14
- function extractDateTime(transcript: Transcript): string {
15
- const firstMessage = transcript.messages[0];
16
- const date = firstMessage?.timestamp
17
- ? new Date(firstMessage.timestamp)
18
- : new Date();
19
-
20
- if (isNaN(date.getTime())) {
21
- return formatDateTime(new Date());
22
- }
10
+ /** Format a timestamp as yyyy-mm-dd-hhmm (24-hour, local time). */
11
+ export function formatDateTimePrefix(timestamp: string): string {
12
+ const date = timestamp ? new Date(timestamp) : new Date();
13
+ if (isNaN(date.getTime())) return formatDateTime(new Date());
23
14
  return formatDateTime(date);
24
15
  }
25
16
 
17
+ function extractDateTime(transcript: Transcript): string {
18
+ return formatDateTimePrefix(transcript.messages[0]?.timestamp ?? "");
19
+ }
20
+
26
21
  function formatDateTime(date: Date): string {
27
22
  const year = date.getFullYear();
28
23
  const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -2,10 +2,7 @@
2
2
  * Extract one-line summaries from tool call inputs.
3
3
  */
4
4
 
5
- function truncate(str: string, maxLen: number): string {
6
- if (str.length <= maxLen) return str;
7
- return str.slice(0, maxLen - 3) + "...";
8
- }
5
+ import { truncate } from "./text.ts";
9
6
 
10
7
  type ToolInput = Record<string, unknown>;
11
8
 
@@ -0,0 +1,5 @@
1
+ /** Truncate a string, appending "..." if it exceeds maxLen. */
2
+ export function truncate(str: string, maxLen: number): string {
3
+ if (str.length <= maxLen) return str;
4
+ return str.slice(0, maxLen - 3) + "...";
5
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared CSS theme tokens and base styles used by both
3
+ * render-html.ts (transcript pages) and render-index.ts (index page).
4
+ */
5
+
6
+ export const THEME_VARS = `
7
+ @import url('https://fonts.googleapis.com/css2?family=Berkeley+Mono:wght@400;500&family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&display=swap');
8
+
9
+ :root {
10
+ /* Typography */
11
+ --font-mono: 'Berkeley Mono', 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', Consolas, monospace;
12
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
13
+
14
+ /* Dark theme */
15
+ --bg: #0d0d0d;
16
+ --bg-elevated: #141414;
17
+ --bg-surface: #1a1a1a;
18
+ --fg: #e4e4e4;
19
+ --fg-secondary: #a3a3a3;
20
+ --muted: #666666;
21
+ --border: #2a2a2a;
22
+ --border-subtle: #222222;
23
+
24
+ /* Accent */
25
+ --accent: #f59e0b;
26
+ --accent-dim: #b45309;
27
+ --accent-glow: rgba(245, 158, 11, 0.15);
28
+
29
+ /* Links */
30
+ --link: #60a5fa;
31
+ --link-hover: #93c5fd;
32
+
33
+ /* Shadows */
34
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
35
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
36
+ }
37
+
38
+ @media (prefers-color-scheme: light) {
39
+ :root {
40
+ --bg: #fafafa;
41
+ --bg-elevated: #ffffff;
42
+ --bg-surface: #f5f5f5;
43
+ --fg: #171717;
44
+ --fg-secondary: #525252;
45
+ --muted: #a3a3a3;
46
+ --border: #e5e5e5;
47
+ --border-subtle: #f0f0f0;
48
+
49
+ --accent: #d97706;
50
+ --accent-dim: #92400e;
51
+ --accent-glow: rgba(217, 119, 6, 0.1);
52
+
53
+ --link: #2563eb;
54
+ --link-hover: #1d4ed8;
55
+
56
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
57
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
58
+ }
59
+ }`;
60
+
61
+ export const BASE_RESET = `
62
+ *, *::before, *::after { box-sizing: border-box; }
63
+
64
+ html {
65
+ font-size: 15px;
66
+ -webkit-font-smoothing: antialiased;
67
+ -moz-osx-font-smoothing: grayscale;
68
+ }
69
+
70
+ body {
71
+ font-family: var(--font-body);
72
+ background: var(--bg);
73
+ color: var(--fg);
74
+ line-height: 1.65;
75
+ margin: 0;
76
+ padding: 0;
77
+ min-height: 100vh;
78
+ }
79
+
80
+ a {
81
+ color: var(--link);
82
+ text-decoration: none;
83
+ transition: color 0.15s ease;
84
+ }
85
+
86
+ a:hover {
87
+ color: var(--link-hover);
88
+ }`;
89
+
90
+ export const SCROLLBAR_STYLES = `
91
+ ::-webkit-scrollbar {
92
+ width: 6px;
93
+ height: 6px;
94
+ }
95
+
96
+ ::-webkit-scrollbar-track {
97
+ background: var(--border-subtle);
98
+ }
99
+
100
+ ::-webkit-scrollbar-thumb {
101
+ background: var(--muted);
102
+ border-radius: 3px;
103
+ }
104
+
105
+ ::-webkit-scrollbar-thumb:hover {
106
+ background: var(--fg-secondary);
107
+ }`;
108
+
109
+ /**
110
+ * Accent bar on the left edge of the page, parameterized by container class.
111
+ */
112
+ export function accentBar(containerClass: string): string {
113
+ return `
114
+ .${containerClass}::before {
115
+ content: '';
116
+ position: fixed;
117
+ left: 0;
118
+ top: 0;
119
+ bottom: 0;
120
+ width: 2px;
121
+ background: linear-gradient(
122
+ 180deg,
123
+ transparent 0%,
124
+ var(--accent-dim) 15%,
125
+ var(--accent) 50%,
126
+ var(--accent-dim) 85%,
127
+ transparent 100%
128
+ );
129
+ opacity: 0.6;
130
+ }`;
131
+ }
132
+
133
+ /**
134
+ * Responsive breakpoint hiding the accent bar and shrinking fonts.
135
+ * containerClass: the wrapper class to adjust padding on.
136
+ */
137
+ export function responsiveBase(containerClass: string): string {
138
+ return `
139
+ @media (max-width: 640px) {
140
+ html {
141
+ font-size: 14px;
142
+ }
143
+
144
+ .${containerClass} {
145
+ padding: 1.5rem 1rem 3rem;
146
+ }
147
+
148
+ .${containerClass}::before {
149
+ display: none;
150
+ }
151
+ }`;
152
+ }
package/src/utils/tree.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Tree/branch navigation utilities for transcript messages.
3
3
  */
4
4
 
5
- import type { Message } from "../types.ts";
5
+ import type { Transcript, Message } from "../types.ts";
6
6
 
7
7
  export interface MessageTree {
8
8
  bySourceRef: Map<string, Message[]>;
@@ -114,3 +114,87 @@ export function getFirstLine(msg: Message): string {
114
114
  ? firstLine.slice(0, maxLen) + "..."
115
115
  : firstLine;
116
116
  }
117
+
118
+ // ============================================================================
119
+ // Tree walk: yields events as it walks the primary branch of a transcript.
120
+ // ============================================================================
121
+
122
+ export type TreeEvent =
123
+ | { type: "messages"; messages: Message[] }
124
+ | { type: "branch_note"; branches: BranchInfo[] }
125
+ | { type: "head_not_found"; head: string }
126
+ | { type: "empty" };
127
+
128
+ export interface BranchInfo {
129
+ sourceRef: string;
130
+ firstLine: string;
131
+ }
132
+
133
+ export interface WalkOptions {
134
+ head?: string;
135
+ }
136
+
137
+ /**
138
+ * Walk the primary branch of a transcript, yielding render events.
139
+ * Handles tree construction, target resolution, path tracing, and branch detection.
140
+ */
141
+ export function* walkTranscriptTree(
142
+ transcript: Transcript,
143
+ options: WalkOptions = {},
144
+ ): Generator<TreeEvent> {
145
+ if (transcript.messages.length === 0) {
146
+ yield { type: "empty" };
147
+ return;
148
+ }
149
+
150
+ const { head } = options;
151
+ const { bySourceRef, children, parents } = buildTree(transcript.messages);
152
+
153
+ let target: string | undefined;
154
+ if (head) {
155
+ if (!bySourceRef.has(head)) {
156
+ yield { type: "head_not_found", head };
157
+ return;
158
+ }
159
+ target = head;
160
+ } else {
161
+ target = findLatestLeaf(bySourceRef, children);
162
+ }
163
+
164
+ if (!target) {
165
+ // Fallback: yield all messages in order
166
+ yield { type: "messages", messages: transcript.messages };
167
+ return;
168
+ }
169
+
170
+ const path = tracePath(target, parents);
171
+ const pathSet = new Set(path);
172
+
173
+ for (const sourceRef of path) {
174
+ const msgs = bySourceRef.get(sourceRef);
175
+ if (!msgs) continue;
176
+
177
+ yield { type: "messages", messages: msgs };
178
+
179
+ // Branch notes (only when not using explicit head)
180
+ if (!head) {
181
+ const childSet = children.get(sourceRef);
182
+ if (childSet && childSet.size > 1) {
183
+ const branches: BranchInfo[] = [];
184
+ for (const childRef of childSet) {
185
+ if (pathSet.has(childRef)) continue;
186
+ const branchMsgs = bySourceRef.get(childRef);
187
+ if (branchMsgs && branchMsgs.length > 0) {
188
+ branches.push({
189
+ sourceRef: childRef,
190
+ firstLine: getFirstLine(branchMsgs[0]),
191
+ });
192
+ }
193
+ }
194
+ if (branches.length > 0) {
195
+ yield { type: "branch_note", branches };
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
package/src/watch.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Watch module: keep archive in sync with source directories.
3
+ *
4
+ * Uses fs.watch for change detection with periodic full scan as fallback.
5
+ * Multiple watchers can safely target the same archive — writes are atomic
6
+ * (tmp + rename) and archiving is idempotent.
7
+ */
8
+
9
+ import { watch, type FSWatcher } from "fs";
10
+ import { getAdapters } from "./adapters/index.ts";
11
+ import {
12
+ archiveAll,
13
+ DEFAULT_ARCHIVE_DIR,
14
+ type ArchiveResult,
15
+ } from "./archive.ts";
16
+
17
+ export interface WatchOptions {
18
+ archiveDir?: string;
19
+ pollIntervalMs?: number;
20
+ onUpdate?: (result: ArchiveResult) => void;
21
+ onError?: (error: Error) => void;
22
+ quiet?: boolean;
23
+ }
24
+
25
+ export class ArchiveWatcher {
26
+ private sourceDirs: string[];
27
+ private archiveDir: string;
28
+ private pollIntervalMs: number;
29
+ private onUpdate?: (result: ArchiveResult) => void;
30
+ private onError?: (error: Error) => void;
31
+ private quiet: boolean;
32
+ private watchers: FSWatcher[] = [];
33
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
34
+ private scanning = false;
35
+
36
+ constructor(sourceDirs: string[], options: WatchOptions = {}) {
37
+ this.sourceDirs = sourceDirs;
38
+ this.archiveDir = options.archiveDir ?? DEFAULT_ARCHIVE_DIR;
39
+ this.pollIntervalMs = options.pollIntervalMs ?? 30_000;
40
+ this.onUpdate = options.onUpdate;
41
+ this.onError = options.onError;
42
+ this.quiet = options.quiet ?? false;
43
+ }
44
+
45
+ async start(): Promise<void> {
46
+ // Initial scan
47
+ await this.scan();
48
+
49
+ // Set up fs.watch on each source dir
50
+ for (const dir of this.sourceDirs) {
51
+ try {
52
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => {
53
+ if (filename && filename.endsWith(".jsonl")) {
54
+ this.debouncedScan();
55
+ }
56
+ });
57
+ this.watchers.push(watcher);
58
+ } catch (err) {
59
+ if (!this.quiet) {
60
+ console.error(
61
+ `Warning: could not watch ${dir}: ${err instanceof Error ? err.message : String(err)}`,
62
+ );
63
+ }
64
+ }
65
+ }
66
+
67
+ // Periodic fallback scan
68
+ this.pollTimer = setInterval(() => this.scan(), this.pollIntervalMs);
69
+ }
70
+
71
+ stop(): void {
72
+ for (const w of this.watchers) {
73
+ w.close();
74
+ }
75
+ this.watchers = [];
76
+
77
+ if (this.pollTimer) {
78
+ clearInterval(this.pollTimer);
79
+ this.pollTimer = null;
80
+ }
81
+ }
82
+
83
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
84
+
85
+ private debouncedScan(): void {
86
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
87
+ this.debounceTimer = setTimeout(() => this.scan(), 500);
88
+ }
89
+
90
+ private async scan(): Promise<void> {
91
+ if (this.scanning) return;
92
+ this.scanning = true;
93
+
94
+ try {
95
+ const adapters = getAdapters();
96
+ for (const dir of this.sourceDirs) {
97
+ const result = await archiveAll(this.archiveDir, dir, adapters, {
98
+ quiet: this.quiet,
99
+ });
100
+ if (result.updated.length > 0 || result.errors.length > 0) {
101
+ this.onUpdate?.(result);
102
+ }
103
+ }
104
+ } catch (err) {
105
+ const error = err instanceof Error ? err : new Error(String(err));
106
+ this.onError?.(error);
107
+ } finally {
108
+ this.scanning = false;
109
+ }
110
+ }
111
+ }