@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/CLAUDE.md +3 -1
- package/README.md +60 -53
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +1 -0
- package/src/adapters/index.ts +0 -6
- package/src/archive.ts +267 -0
- package/src/cli.ts +96 -63
- package/src/convert.ts +19 -86
- package/src/parse.ts +0 -3
- package/src/render-html.ts +38 -195
- package/src/render-index.ts +15 -178
- package/src/render.ts +25 -88
- package/src/serve.ts +124 -215
- package/src/title.ts +24 -102
- package/src/types.ts +3 -0
- package/src/utils/naming.ts +8 -13
- package/src/utils/summary.ts +1 -4
- package/src/utils/text.ts +5 -0
- package/src/utils/theme.ts +152 -0
- package/src/utils/tree.ts +85 -1
- package/src/watch.ts +111 -0
- package/test/archive.test.ts +264 -0
- package/test/fixtures/claude/branching.input.jsonl +6 -0
- package/test/fixtures/claude/branching.output.md +25 -0
- package/test/naming.test.ts +98 -0
- package/test/summary.test.ts +144 -0
- package/test/tree.test.ts +217 -0
- package/tsconfig.json +1 -1
- package/src/cache.ts +0 -129
- package/src/sync.ts +0 -295
- package/src/utils/provenance.ts +0 -212
package/src/title.ts
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Title generation
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
24
|
-
force?: boolean;
|
|
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 {
|
|
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
|
|
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
|
|
48
|
+
console.error("No entries in archive");
|
|
57
49
|
}
|
|
58
50
|
return result;
|
|
59
51
|
}
|
|
60
52
|
|
|
61
|
-
const
|
|
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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
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: ${
|
|
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: ${
|
|
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
|
|
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) */
|
package/src/utils/naming.ts
CHANGED
|
@@ -7,22 +7,17 @@
|
|
|
7
7
|
import type { Transcript } from "../types.ts";
|
|
8
8
|
import { basename } from "path";
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
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");
|
package/src/utils/summary.ts
CHANGED
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
* Extract one-line summaries from tool call inputs.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
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,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
|
+
}
|