openbuilder 0.1.0

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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * transcriber.ts — Whisper transcription via OpenAI API
3
+ *
4
+ * Sends WAV audio chunks to the OpenAI Whisper API and returns
5
+ * transcription text with timestamps.
6
+ */
7
+
8
+ import { createReadStream } from "node:fs";
9
+
10
+ export interface TranscriptionSegment {
11
+ start: number; // seconds
12
+ end: number; // seconds
13
+ text: string;
14
+ }
15
+
16
+ export interface TranscriptionResult {
17
+ text: string;
18
+ segments: TranscriptionSegment[];
19
+ }
20
+
21
+ export interface TranscriberOptions {
22
+ /** OpenAI API key (defaults to OPENAI_API_KEY env var) */
23
+ apiKey?: string;
24
+ /** Whisper model name (default "whisper-1") */
25
+ model?: string;
26
+ /** Language hint for Whisper (e.g. "en") */
27
+ language?: string;
28
+ }
29
+
30
+ export class Transcriber {
31
+ private client: InstanceType<typeof import("openai").default> | null = null;
32
+ private model: string;
33
+ private language?: string;
34
+ private apiKey?: string;
35
+
36
+ constructor(options: TranscriberOptions = {}) {
37
+ this.model = options.model ?? "whisper-1";
38
+ this.language = options.language;
39
+ this.apiKey = options.apiKey;
40
+ }
41
+
42
+ /** Lazily initialize the OpenAI client */
43
+ private async getClient(): Promise<InstanceType<typeof import("openai").default>> {
44
+ if (this.client) return this.client;
45
+
46
+ try {
47
+ const { default: OpenAI } = await import("openai");
48
+ this.client = new OpenAI({
49
+ apiKey: this.apiKey ?? process.env.OPENAI_API_KEY,
50
+ });
51
+ return this.client;
52
+ } catch {
53
+ throw new Error(
54
+ "OpenAI package not found. Install it with: npm install openai",
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Transcribe a WAV audio chunk using the Whisper API.
61
+ *
62
+ * Returns text and segment-level timestamps.
63
+ * Retries on rate limit (429) errors with exponential backoff.
64
+ */
65
+ async transcribeChunk(wavPath: string): Promise<TranscriptionResult> {
66
+ const client = await this.getClient();
67
+
68
+ let lastError: Error | undefined;
69
+
70
+ for (let attempt = 0; attempt < 3; attempt++) {
71
+ try {
72
+ const response = await client.audio.transcriptions.create({
73
+ model: this.model,
74
+ file: createReadStream(wavPath),
75
+ response_format: "verbose_json",
76
+ timestamp_granularities: ["segment"],
77
+ ...(this.language ? { language: this.language } : {}),
78
+ });
79
+
80
+ // The response with verbose_json includes segments
81
+ const raw = response as Record<string, unknown>;
82
+ const segments: TranscriptionSegment[] = [];
83
+
84
+ if (Array.isArray(raw.segments)) {
85
+ for (const seg of raw.segments) {
86
+ if (seg && typeof seg === "object") {
87
+ const s = seg as { start?: number; end?: number; text?: string };
88
+ segments.push({
89
+ start: s.start ?? 0,
90
+ end: s.end ?? 0,
91
+ text: (s.text ?? "").trim(),
92
+ });
93
+ }
94
+ }
95
+ }
96
+
97
+ return {
98
+ text: (response.text ?? "").trim(),
99
+ segments,
100
+ };
101
+ } catch (err: unknown) {
102
+ lastError = err instanceof Error ? err : new Error(String(err));
103
+
104
+ // Check for rate limit (429) — retry with backoff
105
+ const statusCode =
106
+ err && typeof err === "object" && "status" in err
107
+ ? (err as { status: number }).status
108
+ : 0;
109
+
110
+ if (statusCode === 429 && attempt < 2) {
111
+ const delay = Math.pow(2, attempt + 1) * 1000; // 2s, 4s
112
+ console.log(` Whisper rate limited, retrying in ${delay / 1000}s...`);
113
+ await new Promise((r) => setTimeout(r, delay));
114
+ continue;
115
+ }
116
+
117
+ // Non-retryable error
118
+ break;
119
+ }
120
+ }
121
+
122
+ throw new Error(
123
+ `Whisper transcription failed: ${lastError?.message ?? "unknown error"}`,
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * generator.ts — Meeting report generation
3
+ *
4
+ * Combines AI analysis results with speaker analytics to produce
5
+ * a structured markdown meeting report.
6
+ */
7
+
8
+ import type { MeetingAnalytics } from "../analytics/speaker-stats.js";
9
+
10
+ export interface MeetingAnalysis {
11
+ summary: string;
12
+ chapters: Array<{ timestamp: string; title: string; description: string }>;
13
+ actionItems: Array<{ description: string; assignee: string | null }>;
14
+ keyDecisions: string[];
15
+ keyQuestions: Array<{ question: string; status: string }>;
16
+ }
17
+
18
+ export interface ReportOptions {
19
+ meetingId?: string;
20
+ date?: string;
21
+ transcriptPath?: string;
22
+ analysis: MeetingAnalysis;
23
+ analytics: MeetingAnalytics;
24
+ }
25
+
26
+ /** Parse the JSON response from AI into a MeetingAnalysis object. */
27
+ export function parseAnalysisResponse(response: string): MeetingAnalysis {
28
+ // Strip markdown fences if the AI included them
29
+ let cleaned = response.trim();
30
+ if (cleaned.startsWith("```")) {
31
+ cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
32
+ }
33
+
34
+ const parsed = JSON.parse(cleaned) as Record<string, unknown>;
35
+
36
+ return {
37
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
38
+ chapters: Array.isArray(parsed.chapters)
39
+ ? parsed.chapters.map((c: Record<string, unknown>) => ({
40
+ timestamp: String(c.timestamp ?? ""),
41
+ title: String(c.title ?? ""),
42
+ description: String(c.description ?? ""),
43
+ }))
44
+ : [],
45
+ actionItems: Array.isArray(parsed.actionItems)
46
+ ? parsed.actionItems.map((a: Record<string, unknown>) => ({
47
+ description: String(a.description ?? ""),
48
+ assignee: a.assignee ? String(a.assignee) : null,
49
+ }))
50
+ : [],
51
+ keyDecisions: Array.isArray(parsed.keyDecisions)
52
+ ? parsed.keyDecisions.map((d: unknown) => String(d))
53
+ : [],
54
+ keyQuestions: Array.isArray(parsed.keyQuestions)
55
+ ? parsed.keyQuestions.map((q: Record<string, unknown>) => ({
56
+ question: String(q.question ?? ""),
57
+ status: String(q.status ?? "unknown"),
58
+ }))
59
+ : [],
60
+ };
61
+ }
62
+
63
+ /** Generate a formatted markdown meeting report. */
64
+ export function generateReport(options: ReportOptions): string {
65
+ const { analysis, analytics, meetingId, date, transcriptPath } = options;
66
+ const reportDate = date ?? new Date().toISOString().split("T")[0]!;
67
+ const title = meetingId ? `${meetingId} — ${reportDate}` : reportDate;
68
+
69
+ const lines: string[] = [];
70
+
71
+ lines.push(`# Meeting Report: ${title}`);
72
+ lines.push("");
73
+
74
+ // Summary
75
+ lines.push("## Summary");
76
+ lines.push("");
77
+ lines.push(analysis.summary || "(No summary generated)");
78
+ lines.push("");
79
+
80
+ // Chapters
81
+ if (analysis.chapters.length > 0) {
82
+ lines.push("## Chapters");
83
+ lines.push("");
84
+ for (let i = 0; i < analysis.chapters.length; i++) {
85
+ const ch = analysis.chapters[i]!;
86
+ lines.push(`${i + 1}. [${ch.timestamp}] ${ch.title} — ${ch.description}`);
87
+ }
88
+ lines.push("");
89
+ }
90
+
91
+ // Action Items
92
+ if (analysis.actionItems.length > 0) {
93
+ lines.push("## Action Items");
94
+ lines.push("");
95
+ for (const item of analysis.actionItems) {
96
+ const assignee = item.assignee ? ` (@${item.assignee})` : "";
97
+ lines.push(`- [ ] ${item.description}${assignee}`);
98
+ }
99
+ lines.push("");
100
+ }
101
+
102
+ // Key Decisions
103
+ if (analysis.keyDecisions.length > 0) {
104
+ lines.push("## Key Decisions");
105
+ lines.push("");
106
+ for (const decision of analysis.keyDecisions) {
107
+ lines.push(`- ${decision}`);
108
+ }
109
+ lines.push("");
110
+ }
111
+
112
+ // Key Questions
113
+ if (analysis.keyQuestions.length > 0) {
114
+ lines.push("## Key Questions");
115
+ lines.push("");
116
+ for (const q of analysis.keyQuestions) {
117
+ lines.push(`- ${q.question} (${q.status})`);
118
+ }
119
+ lines.push("");
120
+ }
121
+
122
+ // Speaker Analytics
123
+ if (analytics.speakers.length > 0) {
124
+ lines.push("## Speaker Analytics");
125
+ lines.push("");
126
+ lines.push("| Speaker | Talk Time | % of Meeting | Words |");
127
+ lines.push("|---------|-----------|--------------|-------|");
128
+ for (const speaker of analytics.speakers) {
129
+ lines.push(
130
+ `| ${speaker.speaker} | ${speaker.talkTimeFormatted} | ${speaker.percentage}% | ${speaker.wordCount.toLocaleString()} |`,
131
+ );
132
+ }
133
+ lines.push("");
134
+ }
135
+
136
+ // Metadata
137
+ lines.push("## Metadata");
138
+ lines.push("");
139
+ lines.push(`- Duration: ${analytics.totalDurationFormatted}`);
140
+ lines.push(`- Participants: ${analytics.participantCount}`);
141
+ if (transcriptPath) {
142
+ lines.push(`- Transcript: ${transcriptPath}`);
143
+ }
144
+ lines.push(`- Generated: ${new Date().toISOString()}`);
145
+ lines.push(`- Powered by: OpenBuilder (https://github.com/superliangbot/openbuilder)`);
146
+ lines.push("");
147
+
148
+ return lines.join("\n");
149
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * config.ts — Configuration file management for OpenBuilder
3
+ *
4
+ * Config file: ~/.openbuilder/config.json
5
+ * Environment variables override config file values.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ export const OPENBUILDER_DIR = join(homedir(), ".openbuilder");
13
+ export const CONFIG_FILE = join(OPENBUILDER_DIR, "config.json");
14
+ export const AUTH_FILE = join(OPENBUILDER_DIR, "auth.json");
15
+ export const AUTH_META_FILE = join(OPENBUILDER_DIR, "auth-meta.json");
16
+ export const PID_FILE = join(OPENBUILDER_DIR, "builder.pid");
17
+ export const WORKSPACE_DIR = join(homedir(), ".openclaw", "workspace", "openbuilder");
18
+ export const TRANSCRIPTS_DIR = join(WORKSPACE_DIR, "transcripts");
19
+ export const REPORTS_DIR = join(WORKSPACE_DIR, "reports");
20
+ export const SCREENSHOT_READY_FILE = join(WORKSPACE_DIR, "screenshot-ready.json");
21
+
22
+ export interface OpenBuilderConfig {
23
+ aiProvider?: "claude" | "openai";
24
+ anthropicApiKey?: string;
25
+ openaiApiKey?: string;
26
+ botName?: string;
27
+ defaultDuration?: string;
28
+ captureMode?: "audio" | "captions" | "auto";
29
+ whisperModel?: string;
30
+ }
31
+
32
+ const ENV_MAP: Record<string, keyof OpenBuilderConfig> = {
33
+ OPENBUILDER_AI_PROVIDER: "aiProvider",
34
+ ANTHROPIC_API_KEY: "anthropicApiKey",
35
+ OPENAI_API_KEY: "openaiApiKey",
36
+ OPENBUILDER_BOT_NAME: "botName",
37
+ OPENBUILDER_DEFAULT_DURATION: "defaultDuration",
38
+ OPENBUILDER_CAPTURE_MODE: "captureMode",
39
+ OPENBUILDER_WHISPER_MODEL: "whisperModel",
40
+ };
41
+
42
+ /** Read the config file from disk. Returns empty object if not found. */
43
+ export function readConfig(): OpenBuilderConfig {
44
+ if (!existsSync(CONFIG_FILE)) return {};
45
+ try {
46
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as OpenBuilderConfig;
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /** Write the config file to disk. */
53
+ export function writeConfig(config: OpenBuilderConfig): void {
54
+ mkdirSync(OPENBUILDER_DIR, { recursive: true });
55
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
56
+ }
57
+
58
+ /**
59
+ * Get the effective config — file values with environment variable overrides.
60
+ * Environment variables always take precedence over config file values.
61
+ */
62
+ export function getConfig(): OpenBuilderConfig {
63
+ const fileConfig = readConfig();
64
+
65
+ // Apply environment variable overrides
66
+ for (const [envVar, configKey] of Object.entries(ENV_MAP)) {
67
+ const envValue = process.env[envVar];
68
+ if (envValue !== undefined && envValue !== "") {
69
+ (fileConfig as Record<string, string>)[configKey] = envValue;
70
+ }
71
+ }
72
+
73
+ return fileConfig;
74
+ }
75
+
76
+ /** Get a single config value (with env override). */
77
+ export function getConfigValue(key: keyof OpenBuilderConfig): string | undefined {
78
+ const config = getConfig();
79
+ return config[key] as string | undefined;
80
+ }
81
+
82
+ /** Set a single config value in the config file. */
83
+ export function setConfigValue(key: keyof OpenBuilderConfig, value: string): void {
84
+ const config = readConfig();
85
+ (config as Record<string, string>)[key] = value;
86
+ writeConfig(config);
87
+ }
88
+
89
+ /** Delete a config value from the config file. */
90
+ export function deleteConfigValue(key: keyof OpenBuilderConfig): void {
91
+ const config = readConfig();
92
+ delete config[key];
93
+ writeConfig(config);
94
+ }
95
+
96
+ /** Ensure all required directories exist. */
97
+ export function ensureDirs(): void {
98
+ mkdirSync(OPENBUILDER_DIR, { recursive: true });
99
+ mkdirSync(WORKSPACE_DIR, { recursive: true });
100
+ mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
101
+ mkdirSync(REPORTS_DIR, { recursive: true });
102
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * transcript-parser.ts — Parse OpenBuilder transcript files
3
+ *
4
+ * Transcript format: [HH:MM:SS] Speaker: text
5
+ * Handles both live-captured and manually-created transcripts.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+
10
+ export interface TranscriptLine {
11
+ timestamp: string;
12
+ hours: number;
13
+ minutes: number;
14
+ seconds: number;
15
+ totalSeconds: number;
16
+ speaker: string;
17
+ text: string;
18
+ }
19
+
20
+ export interface ParsedTranscript {
21
+ lines: TranscriptLine[];
22
+ speakers: string[];
23
+ durationSeconds: number;
24
+ wordCount: number;
25
+ rawText: string;
26
+ }
27
+
28
+ const LINE_RE = /^\[(\d{2}):(\d{2}):(\d{2})\]\s+(.+?):\s+(.+)$/;
29
+
30
+ /** Parse a single transcript line. Returns null if the line doesn't match. */
31
+ export function parseTranscriptLine(line: string): TranscriptLine | null {
32
+ const match = line.trim().match(LINE_RE);
33
+ if (!match) return null;
34
+
35
+ const hours = parseInt(match[1]!, 10);
36
+ const minutes = parseInt(match[2]!, 10);
37
+ const seconds = parseInt(match[3]!, 10);
38
+
39
+ return {
40
+ timestamp: `${match[1]}:${match[2]}:${match[3]}`,
41
+ hours,
42
+ minutes,
43
+ seconds,
44
+ totalSeconds: hours * 3600 + minutes * 60 + seconds,
45
+ speaker: match[4]!.trim(),
46
+ text: match[5]!.trim(),
47
+ };
48
+ }
49
+
50
+ /** Parse an entire transcript file or string. */
51
+ export function parseTranscript(input: string): ParsedTranscript {
52
+ const rawLines = input.split("\n").filter((l) => l.trim().length > 0);
53
+ const lines: TranscriptLine[] = [];
54
+
55
+ for (const rawLine of rawLines) {
56
+ const parsed = parseTranscriptLine(rawLine);
57
+ if (parsed) lines.push(parsed);
58
+ }
59
+
60
+ const speakers = [...new Set(lines.map((l) => l.speaker))];
61
+ const wordCount = lines.reduce((sum, l) => sum + l.text.split(/\s+/).length, 0);
62
+
63
+ // Duration: difference between first and last timestamp
64
+ let durationSeconds = 0;
65
+ if (lines.length >= 2) {
66
+ durationSeconds = lines[lines.length - 1]!.totalSeconds - lines[0]!.totalSeconds;
67
+ }
68
+
69
+ return { lines, speakers, durationSeconds, wordCount, rawText: input };
70
+ }
71
+
72
+ /** Read and parse a transcript file from disk. */
73
+ export function parseTranscriptFile(filePath: string): ParsedTranscript {
74
+ const content = readFileSync(filePath, "utf-8");
75
+ return parseTranscript(content);
76
+ }
77
+
78
+ /**
79
+ * Format transcript as a clean string for AI processing.
80
+ * Includes all lines with timestamps and speaker names.
81
+ */
82
+ export function formatTranscriptForAI(transcript: ParsedTranscript): string {
83
+ return transcript.lines
84
+ .map((l) => `[${l.timestamp}] ${l.speaker}: ${l.text}`)
85
+ .join("\n");
86
+ }
87
+
88
+ /**
89
+ * Chunk a transcript into segments that fit within a token limit.
90
+ * Uses a rough estimate of 4 characters per token.
91
+ * Each chunk preserves complete lines (never splits mid-line).
92
+ */
93
+ export function chunkTranscript(
94
+ transcript: ParsedTranscript,
95
+ maxCharsPerChunk: number = 30000,
96
+ ): string[] {
97
+ const chunks: string[] = [];
98
+ let currentChunk = "";
99
+
100
+ for (const line of transcript.lines) {
101
+ const formattedLine = `[${line.timestamp}] ${line.speaker}: ${line.text}\n`;
102
+
103
+ if (currentChunk.length + formattedLine.length > maxCharsPerChunk && currentChunk.length > 0) {
104
+ chunks.push(currentChunk.trim());
105
+ currentChunk = "";
106
+ }
107
+
108
+ currentChunk += formattedLine;
109
+ }
110
+
111
+ if (currentChunk.trim().length > 0) {
112
+ chunks.push(currentChunk.trim());
113
+ }
114
+
115
+ return chunks.length > 0 ? chunks : ["(empty transcript)"];
116
+ }