@arcreflex/agent-transcripts 0.1.11 → 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/README.md CHANGED
@@ -35,7 +35,9 @@ src/
35
35
  test/
36
36
  fixtures/ # Snapshot test inputs/outputs
37
37
  snapshots.test.ts
38
+ adapters.test.ts
38
39
  archive.test.ts
40
+ watch.test.ts
39
41
  tree.test.ts
40
42
  naming.test.ts
41
43
  summary.test.ts
@@ -57,8 +59,9 @@ agent-transcripts convert <file> # Parse and render to stdout
57
59
  agent-transcripts convert <file> -o <dir> # Parse and render to directory
58
60
 
59
61
  # Archive management
60
- agent-transcripts archive <source> # Archive sessions from source dir
61
- agent-transcripts archive <source> --archive-dir ~/my-archive
62
+ agent-transcripts archive # Auto-discover from adapter defaults (~/.claude)
63
+ agent-transcripts archive /path --adapter claude-code # Explicit source + adapter
64
+ agent-transcripts archive --archive-dir ~/my-archive # Custom archive location
62
65
 
63
66
  # Serving
64
67
  agent-transcripts serve # Serve from default archive
@@ -66,8 +69,9 @@ agent-transcripts serve --archive-dir <dir> # Serve from custom archive
66
69
  agent-transcripts serve -p 8080 # Custom port
67
70
 
68
71
  # Watching
69
- agent-transcripts watch <source> # Keep archive updated continuously
70
- agent-transcripts watch <source> --poll-interval 60000
72
+ agent-transcripts watch # Auto-discover from adapter defaults
73
+ agent-transcripts watch /path --adapter claude-code # Explicit source + adapter
74
+ agent-transcripts watch --poll-interval 60000
71
75
 
72
76
  # Title generation
73
77
  agent-transcripts title # Generate titles for archive entries
@@ -98,10 +102,10 @@ HTML (rendered on demand, in-memory LRU)
98
102
 
99
103
  ### Archive
100
104
 
101
- The archive is the central data store at `~/.local/share/agent-transcripts/archive/`:
105
+ The archive is the central data store at `$XDG_DATA_HOME/agent-transcripts/archive/` (defaults to `~/.local/share/agent-transcripts/archive/`):
102
106
 
103
107
  ```
104
- ~/.local/share/agent-transcripts/archive/
108
+ $XDG_DATA_HOME/agent-transcripts/archive/
105
109
  {sessionId}.json → ArchiveEntry
106
110
  ```
107
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcreflex/agent-transcripts",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Transform AI coding agent session files into readable transcripts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { Glob } from "bun";
8
8
  import { basename, join, relative } from "path";
9
+ import { homedir } from "os";
9
10
  import { stat } from "fs/promises";
10
11
  import type {
11
12
  Adapter,
@@ -816,6 +817,7 @@ async function discoverByGlob(source: string): Promise<DiscoveredSession[]> {
816
817
  export const claudeCodeAdapter: Adapter = {
817
818
  name: "claude-code",
818
819
  version: "claude-code:1",
820
+ defaultSource: process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
819
821
 
820
822
  async discover(source: string): Promise<DiscoveredSession[]> {
821
823
  // Try index-based discovery first, fall back to glob
@@ -2,7 +2,7 @@
2
2
  * Adapter registry with path-based detection.
3
3
  */
4
4
 
5
- import type { Adapter } from "../types.ts";
5
+ import type { Adapter, SourceSpec } from "../types.ts";
6
6
  import { claudeCodeAdapter } from "./claude-code.ts";
7
7
 
8
8
  const adapters: Record<string, Adapter> = {
@@ -39,8 +39,10 @@ export function listAdapters(): string[] {
39
39
  }
40
40
 
41
41
  /**
42
- * Get all registered adapters.
42
+ * Get default source specs from adapters that define a defaultSource.
43
43
  */
44
- export function getAdapters(): Adapter[] {
45
- return Object.values(adapters);
44
+ export function getDefaultSources(): SourceSpec[] {
45
+ return Object.values(adapters)
46
+ .filter((a): a is Adapter & { defaultSource: string } => !!a.defaultSource)
47
+ .map((a) => ({ adapter: a, source: a.defaultSource }));
46
48
  }
package/src/archive.ts CHANGED
@@ -12,8 +12,8 @@ import type { Adapter, DiscoveredSession, Transcript } from "./types.ts";
12
12
  import { extractSessionId } from "./utils/naming.ts";
13
13
 
14
14
  export const DEFAULT_ARCHIVE_DIR = join(
15
- homedir(),
16
- ".local/share/agent-transcripts/archive",
15
+ process.env.XDG_DATA_HOME || join(homedir(), ".local/share"),
16
+ "agent-transcripts/archive",
17
17
  );
18
18
 
19
19
  const ARCHIVE_SCHEMA_VERSION = 1;
package/src/cli.ts CHANGED
@@ -19,8 +19,9 @@ import { convertToDirectory } from "./convert.ts";
19
19
  import { generateTitles } from "./title.ts";
20
20
  import { serve } from "./serve.ts";
21
21
  import { archiveAll, DEFAULT_ARCHIVE_DIR } from "./archive.ts";
22
- import { getAdapters } from "./adapters/index.ts";
22
+ import { getAdapter, getDefaultSources } from "./adapters/index.ts";
23
23
  import { ArchiveWatcher } from "./watch.ts";
24
+ import type { SourceSpec } from "./types.ts";
24
25
 
25
26
  // Shared options
26
27
  const inputArg = positional({
@@ -55,16 +56,52 @@ const archiveDirOpt = option({
55
56
  description: `Archive directory (default: ${DEFAULT_ARCHIVE_DIR})`,
56
57
  });
57
58
 
59
+ /**
60
+ * Resolve source specs from CLI args.
61
+ * - No source: use adapter defaults
62
+ * - Source + adapter: explicit pair
63
+ * - Source without adapter: error
64
+ */
65
+ function resolveSourceSpecs(
66
+ source: string | undefined,
67
+ adapterName: string | undefined,
68
+ ): SourceSpec[] {
69
+ if (!source) {
70
+ const defaults = getDefaultSources();
71
+ if (defaults.length === 0) {
72
+ console.error("Error: no adapters have a default source directory.");
73
+ process.exit(1);
74
+ }
75
+ return defaults;
76
+ }
77
+
78
+ if (!adapterName) {
79
+ console.error(
80
+ "Error: --adapter is required when specifying a source directory.",
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ const adapter = getAdapter(adapterName);
86
+ if (!adapter) {
87
+ console.error(`Error: unknown adapter "${adapterName}".`);
88
+ process.exit(1);
89
+ }
90
+
91
+ return [{ adapter, source }];
92
+ }
93
+
58
94
  // Archive subcommand
59
95
  const archiveCmd = command({
60
96
  name: "archive",
61
97
  description: "Archive session files from source directory",
62
98
  args: {
63
99
  source: positional({
64
- type: string,
100
+ type: optional(string),
65
101
  displayName: "source",
66
- description: "Source directory to scan for session files",
102
+ description: "Source directory to scan (omit to use adapter defaults)",
67
103
  }),
104
+ adapter: adapterOpt,
68
105
  archiveDir: archiveDirOpt,
69
106
  quiet: flag({
70
107
  long: "quiet",
@@ -72,13 +109,26 @@ const archiveCmd = command({
72
109
  description: "Suppress progress output",
73
110
  }),
74
111
  },
75
- async handler({ source, archiveDir, quiet }) {
112
+ async handler({ source, adapter: adapterName, archiveDir, quiet }) {
76
113
  const dir = archiveDir ?? DEFAULT_ARCHIVE_DIR;
77
- const result = await archiveAll(dir, source, getAdapters(), { quiet });
114
+ const specs = resolveSourceSpecs(source, adapterName);
115
+
116
+ let totalUpdated = 0;
117
+ let totalCurrent = 0;
118
+ let totalErrors = 0;
119
+
120
+ for (const spec of specs) {
121
+ const result = await archiveAll(dir, spec.source, [spec.adapter], {
122
+ quiet,
123
+ });
124
+ totalUpdated += result.updated.length;
125
+ totalCurrent += result.current.length;
126
+ totalErrors += result.errors.length;
127
+ }
78
128
 
79
129
  if (!quiet) {
80
130
  console.error(
81
- `\nArchive complete: ${result.updated.length} updated, ${result.current.length} current, ${result.errors.length} errors`,
131
+ `\nArchive complete: ${totalUpdated} updated, ${totalCurrent} current, ${totalErrors} errors`,
82
132
  );
83
133
  }
84
134
  },
@@ -143,10 +193,11 @@ const watchCmd = command({
143
193
  description: "Watch source directories and keep archive updated",
144
194
  args: {
145
195
  source: positional({
146
- type: string,
196
+ type: optional(string),
147
197
  displayName: "source",
148
- description: "Source directory to watch for session files",
198
+ description: "Source directory to watch (omit to use adapter defaults)",
149
199
  }),
200
+ adapter: adapterOpt,
150
201
  archiveDir: archiveDirOpt,
151
202
  pollInterval: option({
152
203
  type: optional(number),
@@ -159,8 +210,16 @@ const watchCmd = command({
159
210
  description: "Suppress progress output",
160
211
  }),
161
212
  },
162
- async handler({ source, archiveDir, pollInterval, quiet }) {
163
- const watcher = new ArchiveWatcher([source], {
213
+ async handler({
214
+ source,
215
+ adapter: adapterName,
216
+ archiveDir,
217
+ pollInterval,
218
+ quiet,
219
+ }) {
220
+ const specs = resolveSourceSpecs(source, adapterName);
221
+
222
+ const watcher = new ArchiveWatcher(specs, {
164
223
  archiveDir: archiveDir ?? undefined,
165
224
  pollIntervalMs: pollInterval ?? undefined,
166
225
  quiet,
@@ -174,8 +233,9 @@ const watchCmd = command({
174
233
  },
175
234
  });
176
235
 
236
+ const dirs = [...new Set(specs.map((s) => s.source))].join(", ");
177
237
  if (!quiet) {
178
- console.error(`Watching ${source}...`);
238
+ console.error(`Watching ${dirs}...`);
179
239
  }
180
240
 
181
241
  await watcher.start();
package/src/types.ts CHANGED
@@ -91,8 +91,16 @@ export interface Adapter {
91
91
  name: string;
92
92
  /** Versioned identifier for cache invalidation (e.g. "claude-code:1") */
93
93
  version: string;
94
+ /** Default source directory for auto-discovery (if the adapter has one) */
95
+ defaultSource?: string;
94
96
  /** Discover session files in the given directory */
95
97
  discover(source: string): Promise<DiscoveredSession[]>;
96
98
  /** Parse source content into one or more transcripts (split by conversation) */
97
99
  parse(content: string, sourcePath: string): Transcript[];
98
100
  }
101
+
102
+ /** An adapter paired with its source directory. */
103
+ export interface SourceSpec {
104
+ adapter: Adapter;
105
+ source: string;
106
+ }
package/src/watch.ts CHANGED
@@ -2,13 +2,12 @@
2
2
  * Watch module: keep archive in sync with source directories.
3
3
  *
4
4
  * Uses fs.watch for change detection with periodic full scan as fallback.
5
- * Lockfile prevents concurrent watchers on the same archive.
5
+ * Multiple watchers can safely target the same archive — writes are atomic
6
+ * (tmp + rename) and archiving is idempotent.
6
7
  */
7
8
 
8
- import { watch, unlinkSync, type FSWatcher } from "fs";
9
- import { join } from "path";
10
- import { mkdir, writeFile, readFile } from "fs/promises";
11
- import { getAdapters } from "./adapters/index.ts";
9
+ import { watch, type FSWatcher } from "fs";
10
+ import type { SourceSpec } from "./types.ts";
12
11
  import {
13
12
  archiveAll,
14
13
  DEFAULT_ARCHIVE_DIR,
@@ -23,65 +22,8 @@ export interface WatchOptions {
23
22
  quiet?: boolean;
24
23
  }
25
24
 
26
- function lockPath(archiveDir: string): string {
27
- return join(archiveDir, "archive.lock");
28
- }
29
-
30
- async function acquireLock(archiveDir: string): Promise<boolean> {
31
- await mkdir(archiveDir, { recursive: true });
32
- const lock = lockPath(archiveDir);
33
- try {
34
- await writeFile(lock, String(process.pid), { flag: "wx" });
35
- return true;
36
- } catch (err: unknown) {
37
- if (
38
- err &&
39
- typeof err === "object" &&
40
- "code" in err &&
41
- err.code === "EEXIST"
42
- ) {
43
- // Lock file exists — check if the holding process is still alive
44
- try {
45
- const existing = await readFile(lock, "utf-8");
46
- const pid = parseInt(existing, 10);
47
- if (!isNaN(pid)) {
48
- try {
49
- process.kill(pid, 0);
50
- return false; // Process is alive, lock is held
51
- } catch {
52
- // Process is dead, stale lock — reclaim
53
- }
54
- }
55
- await writeFile(lock, String(process.pid), { flag: "w" });
56
- return true;
57
- } catch {
58
- return false;
59
- }
60
- }
61
- throw err;
62
- }
63
- }
64
-
65
- function releaseLock(archiveDir: string): void {
66
- try {
67
- unlinkSync(lockPath(archiveDir));
68
- } catch (err: unknown) {
69
- if (
70
- err &&
71
- typeof err === "object" &&
72
- "code" in err &&
73
- err.code === "ENOENT"
74
- ) {
75
- return;
76
- }
77
- console.error(
78
- `Warning: failed to release lock: ${err instanceof Error ? err.message : String(err)}`,
79
- );
80
- }
81
- }
82
-
83
25
  export class ArchiveWatcher {
84
- private sourceDirs: string[];
26
+ private sources: SourceSpec[];
85
27
  private archiveDir: string;
86
28
  private pollIntervalMs: number;
87
29
  private onUpdate?: (result: ArchiveResult) => void;
@@ -91,8 +33,8 @@ export class ArchiveWatcher {
91
33
  private pollTimer: ReturnType<typeof setInterval> | null = null;
92
34
  private scanning = false;
93
35
 
94
- constructor(sourceDirs: string[], options: WatchOptions = {}) {
95
- this.sourceDirs = sourceDirs;
36
+ constructor(sources: SourceSpec[], options: WatchOptions = {}) {
37
+ this.sources = sources;
96
38
  this.archiveDir = options.archiveDir ?? DEFAULT_ARCHIVE_DIR;
97
39
  this.pollIntervalMs = options.pollIntervalMs ?? 30_000;
98
40
  this.onUpdate = options.onUpdate;
@@ -101,29 +43,30 @@ export class ArchiveWatcher {
101
43
  }
102
44
 
103
45
  async start(): Promise<void> {
104
- const locked = await acquireLock(this.archiveDir);
105
- if (!locked) {
106
- throw new Error(
107
- `Another watcher is already running on ${this.archiveDir}`,
108
- );
109
- }
110
-
111
46
  // Initial scan
112
47
  await this.scan();
113
48
 
114
- // Set up fs.watch on each source dir
115
- for (const dir of this.sourceDirs) {
49
+ // Set up fs.watch on each unique source dir
50
+ const watchedDirs = new Set<string>();
51
+ for (const spec of this.sources) {
52
+ if (watchedDirs.has(spec.source)) continue;
53
+ watchedDirs.add(spec.source);
54
+
116
55
  try {
117
- const watcher = watch(dir, { recursive: true }, (_event, filename) => {
118
- if (filename && filename.endsWith(".jsonl")) {
119
- this.debouncedScan();
120
- }
121
- });
56
+ const watcher = watch(
57
+ spec.source,
58
+ { recursive: true },
59
+ (_event, filename) => {
60
+ if (filename && filename.endsWith(".jsonl")) {
61
+ this.debouncedScan();
62
+ }
63
+ },
64
+ );
122
65
  this.watchers.push(watcher);
123
66
  } catch (err) {
124
67
  if (!this.quiet) {
125
68
  console.error(
126
- `Warning: could not watch ${dir}: ${err instanceof Error ? err.message : String(err)}`,
69
+ `Warning: could not watch ${spec.source}: ${err instanceof Error ? err.message : String(err)}`,
127
70
  );
128
71
  }
129
72
  }
@@ -143,8 +86,6 @@ export class ArchiveWatcher {
143
86
  clearInterval(this.pollTimer);
144
87
  this.pollTimer = null;
145
88
  }
146
-
147
- releaseLock(this.archiveDir);
148
89
  }
149
90
 
150
91
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -159,11 +100,13 @@ export class ArchiveWatcher {
159
100
  this.scanning = true;
160
101
 
161
102
  try {
162
- const adapters = getAdapters();
163
- for (const dir of this.sourceDirs) {
164
- const result = await archiveAll(this.archiveDir, dir, adapters, {
165
- quiet: this.quiet,
166
- });
103
+ for (const spec of this.sources) {
104
+ const result = await archiveAll(
105
+ this.archiveDir,
106
+ spec.source,
107
+ [spec.adapter],
108
+ { quiet: this.quiet },
109
+ );
167
110
  if (result.updated.length > 0 || result.errors.length > 0) {
168
111
  this.onUpdate?.(result);
169
112
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { getDefaultSources, getAdapter } from "../src/adapters/index.ts";
3
+ import { claudeCodeAdapter } from "../src/adapters/claude-code.ts";
4
+
5
+ describe("getDefaultSources", () => {
6
+ it("returns claude-code with its defaultSource", () => {
7
+ const specs = getDefaultSources();
8
+ expect(specs.length).toBeGreaterThanOrEqual(1);
9
+
10
+ const cc = specs.find((s) => s.adapter.name === "claude-code");
11
+ expect(cc).toBeDefined();
12
+ expect(cc?.adapter).toBe(claudeCodeAdapter);
13
+ expect(typeof cc?.source).toBe("string");
14
+ expect(cc?.source.length).toBeGreaterThan(0);
15
+ });
16
+
17
+ it("only includes adapters with defaultSource set", () => {
18
+ for (const spec of getDefaultSources()) {
19
+ expect(spec.source).toBeTruthy();
20
+ }
21
+ });
22
+ });
23
+
24
+ describe("getAdapter", () => {
25
+ it("returns adapter by name", () => {
26
+ expect(getAdapter("claude-code")).toBe(claudeCodeAdapter);
27
+ });
28
+
29
+ it("returns undefined for unknown adapter", () => {
30
+ expect(getAdapter("nonexistent")).toBeUndefined();
31
+ });
32
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
2
+ import { join } from "path";
3
+ import { mkdtemp, rm } from "fs/promises";
4
+ import { tmpdir } from "os";
5
+ import { ArchiveWatcher } from "../src/watch.ts";
6
+ import { claudeCodeAdapter } from "../src/adapters/claude-code.ts";
7
+ import { listEntries } from "../src/archive.ts";
8
+
9
+ const fixturesDir = join(import.meta.dir, "fixtures/claude");
10
+
11
+ let archiveDir: string;
12
+ let sourceDir: string;
13
+
14
+ beforeEach(async () => {
15
+ archiveDir = await mkdtemp(join(tmpdir(), "watch-archive-"));
16
+ sourceDir = await mkdtemp(join(tmpdir(), "watch-source-"));
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await rm(archiveDir, { recursive: true, force: true });
21
+ await rm(sourceDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("ArchiveWatcher with SourceSpec[]", () => {
25
+ it("archives from a single source spec on initial scan", async () => {
26
+ // Copy a fixture into the source dir
27
+ const content = await Bun.file(
28
+ join(fixturesDir, "basic-conversation.input.jsonl"),
29
+ ).text();
30
+ await Bun.write(join(sourceDir, "session.jsonl"), content);
31
+
32
+ const updates: string[] = [];
33
+ const watcher = new ArchiveWatcher(
34
+ [{ adapter: claudeCodeAdapter, source: sourceDir }],
35
+ {
36
+ archiveDir,
37
+ quiet: true,
38
+ onUpdate(result) {
39
+ updates.push(...result.updated);
40
+ },
41
+ },
42
+ );
43
+
44
+ await watcher.start();
45
+ watcher.stop();
46
+
47
+ expect(updates.length).toBe(1);
48
+ const entries = await listEntries(archiveDir);
49
+ expect(entries.length).toBe(1);
50
+ });
51
+
52
+ it("archives from multiple source specs", async () => {
53
+ const sourceDir2 = await mkdtemp(join(tmpdir(), "watch-source2-"));
54
+
55
+ const content = await Bun.file(
56
+ join(fixturesDir, "basic-conversation.input.jsonl"),
57
+ ).text();
58
+ await Bun.write(join(sourceDir, "a.jsonl"), content);
59
+ await Bun.write(join(sourceDir2, "b.jsonl"), content);
60
+
61
+ const updates: string[] = [];
62
+ const watcher = new ArchiveWatcher(
63
+ [
64
+ { adapter: claudeCodeAdapter, source: sourceDir },
65
+ { adapter: claudeCodeAdapter, source: sourceDir2 },
66
+ ],
67
+ {
68
+ archiveDir,
69
+ quiet: true,
70
+ onUpdate(result) {
71
+ updates.push(...result.updated);
72
+ },
73
+ },
74
+ );
75
+
76
+ await watcher.start();
77
+ watcher.stop();
78
+
79
+ expect(updates.length).toBe(2);
80
+ const entries = await listEntries(archiveDir);
81
+ expect(entries.length).toBe(2);
82
+
83
+ await rm(sourceDir2, { recursive: true, force: true });
84
+ });
85
+ });