@tacone/prosey 0.2.6 → 0.3.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,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ resolveSummarizeCmd,
4
+ resolveSummarizePrompt,
5
+ resolveTranscribeCmd,
6
+ resolveTranscribePrompt,
7
+ } from "./config-resolve";
8
+ import type { ProseyConfig } from "./config";
9
+
10
+ describe("resolveSummarizeCmd", () => {
11
+ test("returns summarize.command when present", () => {
12
+ const config: ProseyConfig = {
13
+ summarize: { command: "my-sum-cmd", prompt: "x" },
14
+ ai: { command: "ai-cmd" },
15
+ };
16
+ expect(resolveSummarizeCmd(config)).toBe("my-sum-cmd");
17
+ });
18
+
19
+ test("falls back to ai.command when summarize.command is missing", () => {
20
+ const config: ProseyConfig = {
21
+ summarize: { prompt: "x" },
22
+ ai: { command: "ai-cmd" },
23
+ };
24
+ expect(resolveSummarizeCmd(config)).toBe("ai-cmd");
25
+ });
26
+
27
+ test("returns null when no command is configured", () => {
28
+ const config: ProseyConfig = {};
29
+ expect(resolveSummarizeCmd(config)).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("resolveTranscribeCmd", () => {
34
+ test("returns transcribe.command when present", () => {
35
+ const config: ProseyConfig = {
36
+ transcribe: { command: "my-transcribe-cmd", prompt: "x" },
37
+ ai: { command: "ai-cmd" },
38
+ };
39
+ expect(resolveTranscribeCmd(config)).toBe("my-transcribe-cmd");
40
+ });
41
+
42
+ test("falls back to ai.command when transcribe.command is missing", () => {
43
+ const config: ProseyConfig = {
44
+ ai: { command: "ai-cmd" },
45
+ transcribe: { prompt: "x" },
46
+ };
47
+ expect(resolveTranscribeCmd(config)).toBe("ai-cmd");
48
+ });
49
+
50
+ test("falls back to summarize.command when both transcribe and ai are missing", () => {
51
+ const config: ProseyConfig = {
52
+ summarize: { command: "old-sum-cmd", prompt: "x" },
53
+ };
54
+ expect(resolveTranscribeCmd(config)).toBe("old-sum-cmd");
55
+ });
56
+
57
+ test("returns null when no command is configured", () => {
58
+ const config: ProseyConfig = {};
59
+ expect(resolveTranscribeCmd(config)).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("resolveTranscribePrompt", () => {
64
+ test("returns transcribe.prompt when present", () => {
65
+ const config: ProseyConfig = {
66
+ transcribe: { prompt: "my-transcribe-prompt" },
67
+ };
68
+ expect(resolveTranscribePrompt(config)).toBe("my-transcribe-prompt");
69
+ });
70
+
71
+ test("falls back to summarize.prompt when transcribe.prompt is missing", () => {
72
+ const config: ProseyConfig = {
73
+ summarize: { prompt: "my-sum-prompt" },
74
+ };
75
+ expect(resolveTranscribePrompt(config)).toBe("my-sum-prompt");
76
+ });
77
+
78
+ test("returns null when neither transcribe nor summarize prompts exist", () => {
79
+ const config: ProseyConfig = {};
80
+ expect(resolveTranscribePrompt(config)).toBeNull();
81
+ });
82
+
83
+ test("transcribe.prompt takes precedence over summarize.prompt", () => {
84
+ const config: ProseyConfig = {
85
+ transcribe: { prompt: "trans-prompt" },
86
+ summarize: { prompt: "sum-prompt" },
87
+ };
88
+ expect(resolveTranscribePrompt(config)).toBe("trans-prompt");
89
+ });
90
+ });
91
+
92
+ describe("resolveSummarizePrompt", () => {
93
+ test("returns summarize.prompt when present", () => {
94
+ const config: ProseyConfig = {
95
+ summarize: { prompt: "my-sum-prompt" },
96
+ };
97
+ expect(resolveSummarizePrompt(config)).toBe("my-sum-prompt");
98
+ });
99
+
100
+ test("returns null when summarize.prompt is missing", () => {
101
+ const config: ProseyConfig = {};
102
+ expect(resolveSummarizePrompt(config)).toBeNull();
103
+ });
104
+ });
@@ -0,0 +1,17 @@
1
+ import type { ProseyConfig } from "./config";
2
+
3
+ export function resolveSummarizeCmd(config: ProseyConfig): string | null {
4
+ return config.summarize?.command ?? config.ai?.command ?? null;
5
+ }
6
+
7
+ export function resolveSummarizePrompt(config: ProseyConfig): string | null {
8
+ return config.summarize?.prompt ?? null;
9
+ }
10
+
11
+ export function resolveTranscribeCmd(config: ProseyConfig): string | null {
12
+ return config.transcribe?.command ?? config.ai?.command ?? config.summarize?.command ?? null;
13
+ }
14
+
15
+ export function resolveTranscribePrompt(config: ProseyConfig): string | null {
16
+ return config.transcribe?.prompt ?? config.summarize?.prompt ?? null;
17
+ }
@@ -53,7 +53,9 @@ describe("loadConfig", () => {
53
53
  expect(existsSync(tmpConfig)).toBe(true);
54
54
 
55
55
  const content = await readFile(tmpConfig, "utf8");
56
+ expect(content).toContain("[ai]");
56
57
  expect(content).toContain("[summarize]");
58
+ expect(content).toContain("[transcribe]");
57
59
  expect(content).toContain("command = ");
58
60
  });
59
61
 
@@ -63,7 +65,7 @@ describe("loadConfig", () => {
63
65
  await loadConfig();
64
66
  const config = await loadConfig();
65
67
  expect(config.summarize?.prompt).toBeString();
66
- expect(config.summarize?.command).toBeString();
68
+ expect(config.ai?.command).toBeString();
67
69
  });
68
70
 
69
71
  test("handles invalid TOML gracefully", async () => {
package/src/config.ts CHANGED
@@ -8,10 +8,17 @@ import { load } from "js-toml";
8
8
  export interface ProseyConfig {
9
9
  pager?: string;
10
10
  hints?: boolean;
11
+ ai?: {
12
+ command?: string;
13
+ };
11
14
  summarize?: {
12
15
  prompt?: string;
13
16
  command?: string;
14
17
  };
18
+ transcribe?: {
19
+ prompt?: string;
20
+ command?: string;
21
+ };
15
22
  }
16
23
 
17
24
  const FALLBACK_CONFIG_TOML = `# Default prosey configuration
@@ -27,6 +34,11 @@ pager = "auto"
27
34
  # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
28
35
  hints = true
29
36
 
37
+ [ai]
38
+ # Default command for AI operations (summarize, transcribe).
39
+ # Can be overridden per-section via the command key below.
40
+ command = "opencode run"
41
+
30
42
  [summarize]
31
43
  # Prompt sent to the command via stdin.
32
44
  # Customize this to change how transcripts are summarized.
@@ -34,23 +46,20 @@ prompt = """
34
46
  Write a comprehensive summary of the following transcription.
35
47
  """
36
48
 
37
- # Command to execute with the prompt and transcript piped via stdin.
38
- # The transcript is appended to the prompt automatically.
39
- #
40
- # Available options:
41
- #
42
- # opencode run — full access (default)
43
- # opencode run --permissions read — read-only (view files, no edits)
44
- #
45
- # claude -p "" --print — full access (--print for clean output)
46
- # claude --permission-mode plan -p "" --print — read-only (plan/read only)
47
- #
48
- # copilot -sp "" — full access (-s = silent, -p = prompt)
49
- # copilot -sp "" --deny-all-tools read-only (no shell/write access)
50
- #
51
- # codex --sandbox default -p "" — full access
52
- # codex --sandbox read-only -p "" — read-only
53
- command = "opencode run"
49
+ # Command override for summarize. Uncomment to use a different command
50
+ # than the one specified in [ai].
51
+ # command = "opencode run"
52
+
53
+ [transcribe]
54
+ # Prompt sent to the command via stdin.
55
+ # Customize this to change how transcripts are formatted as markdown.
56
+ prompt = """
57
+ Convert this transcript to clean, readable markdown.
58
+ """
59
+
60
+ # Command override for transcribe. Uncomment to use a different command
61
+ # than the one specified in [ai].
62
+ # command = "opencode run"
54
63
  `;
55
64
 
56
65
  async function readDefaultConfig(): Promise<string> {
@@ -11,6 +11,18 @@ pager = "auto"
11
11
  # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
12
12
  hints = true
13
13
 
14
+ [ai]
15
+
16
+ # Default command for AI operations (summarize, transcribe).
17
+ # Can be overridden per-section via the command key below.
18
+
19
+ # Examples:
20
+ #
21
+ # OPENCODE_PERMISSION='{"read":"allow","write":"deny","edit":"deny","bash":"deny","glob":"deny","grep":"deny","webfetch":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny","lsp":"deny"}' opencode run --pure
22
+ # copilot -s --deny-all-tools --read-only
23
+
24
+ command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":\"deny\",\"bash\":\"deny\",\"glob\":\"deny\",\"grep\":\"deny\",\"webfetch\":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny\",\"lsp\":\"deny\"}' opencode run --pure --variant low"
25
+
14
26
  [summarize]
15
27
 
16
28
  # Prompt sent to the command via stdin.
@@ -20,14 +32,30 @@ prompt = """
20
32
  Write a comprehensive summary of the following transcription.
21
33
  """
22
34
 
23
- # Command to execute with the prompt and transcript piped via stdin.
24
- # The transcript will be appended to the prompt automatically.
25
- #
26
- # Examples:
27
- #
28
- # OPENCODE_PERMISSION='{"read":"allow","write":"deny","edit":"deny","bash":"deny","glob":"deny","grep":"deny","webfetch":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny","lsp":"deny"}' opencode run --pure
29
- # claude --permission-mode plan --print --read-only
30
- # copilot -s --deny-all-tools --read-only
31
- # codex --sandbox read-only --read-only
32
- #
33
- command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":\"deny\",\"bash\":\"deny\",\"glob\":\"deny\",\"grep\":\"deny\",\"webfetch\":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny\",\"lsp\":\"deny\"}' opencode run --pure"
35
+ # Command override for summarize. Uncomment to use a different command
36
+ # than the one specified in [ai].
37
+ # command = "opencode run"
38
+
39
+ [transcribe]
40
+
41
+ # Prompt sent to the command via stdin.
42
+ # Customize this to change how transcripts are formatted as markdown.
43
+
44
+ prompt = """
45
+ Format the following text using markdown. Do not rephrase.
46
+
47
+ - use headers
48
+ - use the INFO section to correct spellings in the text (e.g. Pi vs Pie)
49
+ - use the INFO Title field as the main title
50
+ - use timestamps (if available) as second level headers (just text, no time value)
51
+ - format the text appropriately adding paragraphs, blockquote where it applies
52
+ - aim for max 3-5 sentences (or 350-400 chars) for paragraph on average
53
+ - consider using bold for technical term/proper noun repeated multiple times
54
+ - don't overthink
55
+
56
+ Only output the formatted text without adding anything.
57
+ """
58
+
59
+ # Command override for transcribe. Uncomment to use a different command
60
+ # than the one specified in [ai].
61
+ # command = "opencode run"
@@ -0,0 +1,229 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
3
+
4
+ describe("extractChapters", () => {
5
+ test("extracts chapters from standard format", () => {
6
+ const desc = `00:00 Intro
7
+ 01:00 Karpathy's Viral Tweet
8
+ 02:26 The Age of Meat Computers`;
9
+ const chapters = extractChapters(desc);
10
+ expect(chapters).toEqual([
11
+ { time: 0, title: "Intro" },
12
+ { time: 60, title: "Karpathy's Viral Tweet" },
13
+ { time: 146, title: "The Age of Meat Computers" },
14
+ ]);
15
+ });
16
+
17
+ test("handles single-digit minutes (M:SS)", () => {
18
+ const desc = `0:00 Intro
19
+ 1:30 First Topic`;
20
+ const chapters = extractChapters(desc);
21
+ expect(chapters).toEqual([
22
+ { time: 0, title: "Intro" },
23
+ { time: 90, title: "First Topic" },
24
+ ]);
25
+ });
26
+
27
+ test("handles hours (HH:MM:SS)", () => {
28
+ const desc = `00:00:00 Start
29
+ 01:30:00 Midway
30
+ 02:15:45 End`;
31
+ const chapters = extractChapters(desc);
32
+ expect(chapters).toEqual([
33
+ { time: 0, title: "Start" },
34
+ { time: 5400, title: "Midway" },
35
+ { time: 8145, title: "End" },
36
+ ]);
37
+ });
38
+
39
+ test("handles brackets [MM:SS]", () => {
40
+ const desc = `[00:00] Intro
41
+ [01:00] Topic`;
42
+ const chapters = extractChapters(desc);
43
+ expect(chapters).toEqual([
44
+ { time: 0, title: "Intro" },
45
+ { time: 60, title: "Topic" },
46
+ ]);
47
+ });
48
+
49
+ test("handles parentheses (MM:SS)", () => {
50
+ const desc = `(00:00) Intro
51
+ (01:00) Topic`;
52
+ const chapters = extractChapters(desc);
53
+ expect(chapters).toEqual([
54
+ { time: 0, title: "Intro" },
55
+ { time: 60, title: "Topic" },
56
+ ]);
57
+ });
58
+
59
+ test("handles dash separator", () => {
60
+ const desc = `00:00 - Intro
61
+ 01:00 - Topic`;
62
+ const chapters = extractChapters(desc);
63
+ expect(chapters).toEqual([
64
+ { time: 0, title: "Intro" },
65
+ { time: 60, title: "Topic" },
66
+ ]);
67
+ });
68
+
69
+ test("handles en-dash separator", () => {
70
+ const desc = `00:00 – Intro\n01:00 – Topic`;
71
+ const chapters = extractChapters(desc);
72
+ expect(chapters).toEqual([
73
+ { time: 0, title: "Intro" },
74
+ { time: 60, title: "Topic" },
75
+ ]);
76
+ });
77
+
78
+ test("handles em-dash separator", () => {
79
+ const desc = `00:00 — Intro\n01:00 — Topic`;
80
+ const chapters = extractChapters(desc);
81
+ expect(chapters).toEqual([
82
+ { time: 0, title: "Intro" },
83
+ { time: 60, title: "Topic" },
84
+ ]);
85
+ });
86
+
87
+ test("handles colon separator", () => {
88
+ const desc = `00:00: Intro\n01:00: Topic`;
89
+ const chapters = extractChapters(desc);
90
+ expect(chapters).toEqual([
91
+ { time: 0, title: "Intro" },
92
+ { time: 60, title: "Topic" },
93
+ ]);
94
+ });
95
+
96
+ test("handles dot separator", () => {
97
+ const desc = `00:00 . Intro\n01:00 . Topic`;
98
+ const chapters = extractChapters(desc);
99
+ expect(chapters).toEqual([
100
+ { time: 0, title: "Intro" },
101
+ { time: 60, title: "Topic" },
102
+ ]);
103
+ });
104
+
105
+ test("strips leading whitespace", () => {
106
+ const desc = ` 00:00 Intro
107
+ 01:00 Indented Topic`;
108
+ const chapters = extractChapters(desc);
109
+ expect(chapters).toEqual([
110
+ { time: 0, title: "Intro" },
111
+ { time: 60, title: "Indented Topic" },
112
+ ]);
113
+ });
114
+
115
+ test("sorts by time order even if input is out of order", () => {
116
+ const desc = `03:00 Last
117
+ 01:00 Middle
118
+ 00:00 First`;
119
+ const chapters = extractChapters(desc);
120
+ expect(chapters.map((c) => c.title)).toEqual(["First", "Middle", "Last"]);
121
+ });
122
+
123
+ test("deduplicates by time (keeps first)", () => {
124
+ const desc = `01:00 First
125
+ 01:00 Second
126
+ 02:00 Third`;
127
+ const chapters = extractChapters(desc);
128
+ expect(chapters).toEqual([
129
+ { time: 60, title: "First" },
130
+ { time: 120, title: "Third" },
131
+ ]);
132
+ });
133
+
134
+ test("skips malformed lines", () => {
135
+ const desc = `This is not a chapter
136
+ 00:00 Real Chapter
137
+ Just some text without a timestamp
138
+ 01:30 Another Chapter`;
139
+ const chapters = extractChapters(desc);
140
+ expect(chapters).toEqual([
141
+ { time: 0, title: "Real Chapter" },
142
+ { time: 90, title: "Another Chapter" },
143
+ ]);
144
+ });
145
+
146
+ test("skips timestamp with no title", () => {
147
+ const desc = `00:00
148
+ 01:00 Title`;
149
+ const chapters = extractChapters(desc);
150
+ expect(chapters).toEqual([{ time: 60, title: "Title" }]);
151
+ });
152
+
153
+ test("returns empty array for empty description", () => {
154
+ expect(extractChapters("")).toEqual([]);
155
+ });
156
+
157
+ test("returns empty array for description with no timestamps", () => {
158
+ const desc = `Hello world
159
+ This is a video description
160
+ With no timestamps at all`;
161
+ expect(extractChapters(desc)).toEqual([]);
162
+ });
163
+
164
+ test("extracts the sample format from the user", () => {
165
+ const desc = `00:00 Intro
166
+ 01:00 Karpathy's Viral Tweet
167
+ 01:41 The Overnight 11% Breakthrough
168
+ 02:26 "The Age of Meat Computers Is Over"
169
+ 03:03 85,000 Stars & Shopify's Results
170
+ 04:07 Gary Tan & Applying It to Business
171
+ 04:53 Chamath's Content Engine Use Case
172
+ 05:32 How the System Works: The 3 Files
173
+ 06:27 The Self-Improvement Loop Explained
174
+ 07:42 701x Faster: 36,500 Experiments a Year
175
+ 08:24 The 3 Must-Have Rules
176
+ 10:11 The 3 Nice-to-Haves
177
+ 11:51 What You Can Point It At
178
+ 14:35 The One-Line Master Prompt
179
+ 15:06 Live Test 1: A Faster Website (800ms to 90ms)
180
+ 17:34 Live Test 2: Cold Email Subject Lines
181
+ 19:55 Live Test 3: Facebook Ads on Autopilot`;
182
+ const chapters = extractChapters(desc);
183
+ expect(chapters).toHaveLength(17);
184
+ expect(chapters[0]).toEqual({ time: 0, title: "Intro" });
185
+ expect(chapters[16]).toEqual({
186
+ time: 1195,
187
+ title: "Live Test 3: Facebook Ads on Autopilot",
188
+ });
189
+ });
190
+ });
191
+
192
+ describe("formatChaptersAsJson", () => {
193
+ test("formats chapters as key-value JSON", () => {
194
+ const chapters = [
195
+ { time: 0, title: "Intro" },
196
+ { time: 90, title: "Chapter 1" },
197
+ ];
198
+ const result = JSON.parse(formatChaptersAsJson(chapters));
199
+ expect(result).toEqual({ "00:00": "Intro", "01:30": "Chapter 1" });
200
+ });
201
+
202
+ test("formats chapters with hours", () => {
203
+ const chapters = [{ time: 3661, title: "After 1 Hour" }];
204
+ const result = JSON.parse(formatChaptersAsJson(chapters));
205
+ expect(result).toEqual({ "01:01:01": "After 1 Hour" });
206
+ });
207
+
208
+ test("returns not available for empty chapters", () => {
209
+ expect(formatChaptersAsJson([])).toBe("not available");
210
+ });
211
+ });
212
+
213
+ describe("formatChaptersAsText", () => {
214
+ test("formats chapters without hours", () => {
215
+ const chapters = [
216
+ { time: 0, title: "Intro" },
217
+ { time: 90, title: "Chapter 1" },
218
+ ];
219
+ expect(formatChaptersAsText(chapters)).toBe("00:00 Intro\n01:30 Chapter 1");
220
+ });
221
+
222
+ test("formats chapters with hours", () => {
223
+ const chapters = [
224
+ { time: 0, title: "Start" },
225
+ { time: 3661, title: "After 1 Hour" },
226
+ ];
227
+ expect(formatChaptersAsText(chapters)).toBe("00:00 Start\n01:01:01 After 1 Hour");
228
+ });
229
+ });
@@ -0,0 +1,66 @@
1
+ export interface Chapter {
2
+ time: number;
3
+ title: string;
4
+ }
5
+
6
+ const lineRegex =
7
+ /^\s*[\[\(]?(?:(?:(\d{1,2}):)?(\d{1,2}):(\d{2}))[\]\)]?(?:\s*[-–—:.]\s*|\s+)(.+)$/;
8
+
9
+ export function extractChapters(description: string): Chapter[] {
10
+ const lines = description.split("\n");
11
+ const chapters: Chapter[] = [];
12
+
13
+ for (const line of lines) {
14
+ const match = line.match(lineRegex);
15
+ if (!match) continue;
16
+
17
+ const hours = match[1] ? parseInt(match[1], 10) : 0;
18
+ const minutes = parseInt(match[2]!, 10);
19
+ const seconds = parseInt(match[3]!, 10);
20
+ const title = match[4]!.trim();
21
+ if (!title) continue;
22
+
23
+ const time = hours * 3600 + minutes * 60 + seconds;
24
+ chapters.push({ time, title });
25
+ }
26
+
27
+ chapters.sort((a, b) => a.time - b.time);
28
+
29
+ const seen = new Set<number>();
30
+ return chapters.filter((c) => {
31
+ if (seen.has(c.time)) return false;
32
+ seen.add(c.time);
33
+ return true;
34
+ });
35
+ }
36
+
37
+ export function formatChaptersAsJson(chapters: Chapter[]): string {
38
+ if (chapters.length === 0) return "not available";
39
+ const obj: Record<string, string> = {};
40
+ for (const ch of chapters) {
41
+ const h = Math.floor(ch.time / 3600);
42
+ const m = Math.floor((ch.time % 3600) / 60);
43
+ const s = ch.time % 60;
44
+ const key =
45
+ h > 0
46
+ ? `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
47
+ : `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
48
+ obj[key] = ch.title;
49
+ }
50
+ return JSON.stringify(obj);
51
+ }
52
+
53
+ export function formatChaptersAsText(chapters: Chapter[]): string {
54
+ return chapters
55
+ .map((c) => {
56
+ const h = Math.floor(c.time / 3600);
57
+ const m = Math.floor((c.time % 3600) / 60);
58
+ const s = c.time % 60;
59
+ const timeStr =
60
+ h > 0
61
+ ? `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
62
+ : `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
63
+ return `${timeStr} ${c.title}`;
64
+ })
65
+ .join("\n");
66
+ }
@@ -0,0 +1,12 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { $ } from "bun";
3
+ import { join } from "node:path";
4
+
5
+ const BIN = join(import.meta.dir, "..", "bin", "prosey");
6
+
7
+ describe("dry-run", () => {
8
+ test("is listed in help text", async () => {
9
+ const { stdout } = await $`${BIN} --help`.quiet();
10
+ expect(stdout.toString()).toContain("--dry-run");
11
+ });
12
+ });