whspr 1.0.13 → 1.0.14

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/dist/index.js CHANGED
@@ -3,7 +3,8 @@ import { record, convertToMp3 } from "./recorder.js";
3
3
  import { transcribe } from "./transcribe.js";
4
4
  import { postprocess } from "./postprocess.js";
5
5
  import { copyToClipboard } from "./utils/clipboard.js";
6
- import chalk from "chalk";
6
+ import { calculateCost, formatCost } from "./utils/pricing.js";
7
+ import { renderStartupHeader, formatCompactStats, formatStatus, colors, BOX } from "./ui.js";
7
8
  import fs from "fs";
8
9
  import path from "path";
9
10
  import os from "os";
@@ -99,7 +100,7 @@ function loadCustomPrompt(verbose) {
99
100
  const settings = loadSettings();
100
101
  const verbose = settings.verbose || process.argv.includes("--verbose") || process.argv.includes("-v");
101
102
  function status(message) {
102
- process.stdout.write(`\x1b[2K\r${chalk.blue(message)}`);
103
+ process.stdout.write(`\x1b[2K\r${formatStatus(message)}`);
103
104
  }
104
105
  function clearStatus() {
105
106
  process.stdout.write("\x1b[2K\r");
@@ -119,18 +120,25 @@ async function main() {
119
120
  // Check for required API keys before recording
120
121
  // Always need GROQ_API_KEY for Whisper transcription
121
122
  if (!process.env.GROQ_API_KEY) {
122
- console.error(chalk.red("Error: GROQ_API_KEY environment variable is not set"));
123
- console.log(chalk.gray("Get your API key at https://console.groq.com/keys"));
124
- console.log(chalk.gray("Then run: export GROQ_API_KEY=\"your-api-key\""));
123
+ console.error(colors.error("Error: GROQ_API_KEY environment variable is not set"));
124
+ console.log(colors.metadata("Get your API key at https://console.groq.com/keys"));
125
+ console.log(colors.metadata("Then run: export GROQ_API_KEY=\"your-api-key\""));
125
126
  process.exit(1);
126
127
  }
127
128
  // Check for provider-specific API key for post-processing
128
129
  if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
129
- console.error(chalk.red("Error: ANTHROPIC_API_KEY environment variable is not set"));
130
- console.log(chalk.gray("Get your API key at https://console.anthropic.com/settings/keys"));
131
- console.log(chalk.gray("Then run: export ANTHROPIC_API_KEY=\"your-api-key\""));
130
+ console.error(colors.error("Error: ANTHROPIC_API_KEY environment variable is not set"));
131
+ console.log(colors.metadata("Get your API key at https://console.anthropic.com/settings/keys"));
132
+ console.log(colors.metadata("Then run: export ANTHROPIC_API_KEY=\"your-api-key\""));
132
133
  process.exit(1);
133
134
  }
135
+ // Load custom prompt early to show in startup header
136
+ const { prompt: customPrompt, sources: vocabSources } = loadCustomPrompt(verbose);
137
+ // Display startup header
138
+ renderStartupHeader({
139
+ model: modelConfig,
140
+ vocabSources,
141
+ });
134
142
  try {
135
143
  // 1. Record audio
136
144
  const recording = await record(verbose);
@@ -144,16 +152,14 @@ async function main() {
144
152
  const rawText = await transcribe(mp3Path, settings.transcriptionModel ?? DEFAULTS.transcriptionModel, settings.language ?? DEFAULTS.language);
145
153
  if (verbose) {
146
154
  clearStatus();
147
- console.log(chalk.gray(`Raw: ${rawText}`));
148
- }
149
- // 4. Read WHSPR.md or WHISPER.md (global from ~/.whspr/ and/or local)
150
- const { prompt: customPrompt, sources: vocabSources } = loadCustomPrompt(verbose);
151
- if (customPrompt && verbose) {
152
- console.log(chalk.gray(`Using custom vocabulary from: ${vocabSources.join(" + ")}`));
155
+ console.log(colors.metadata(`Raw: ${rawText}`));
156
+ if (customPrompt) {
157
+ console.log(colors.metadata(`Using custom vocabulary from: ${vocabSources.join(" + ")}`));
158
+ }
153
159
  }
154
- // 5. Post-process with progress bar
160
+ // 4. Post-process with progress bar
155
161
  status("Post-processing... 0%");
156
- let fixedText = await postprocess(rawText, customPrompt, {
162
+ const postprocessResult = await postprocess(rawText, customPrompt, {
157
163
  provider,
158
164
  modelName,
159
165
  systemPrompt: settings.systemPrompt ?? DEFAULTS.systemPrompt,
@@ -162,27 +168,31 @@ async function main() {
162
168
  onProgress: (progress) => {
163
169
  const barWidth = 20;
164
170
  const filled = Math.round((progress / 100) * barWidth);
165
- const bar = "".repeat(filled) + "".repeat(barWidth - filled);
171
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(barWidth - filled);
166
172
  status(`Post-processing... [${bar}] ${progress}%`);
167
173
  },
168
174
  });
169
- // 6. Apply suffix if configured
175
+ let fixedText = postprocessResult.text;
176
+ // 5. Apply suffix if configured
170
177
  if (settings.suffix) {
171
178
  fixedText = fixedText + settings.suffix;
172
179
  }
173
- // 7. Output and copy
180
+ // 6. Output and copy
174
181
  clearStatus();
175
182
  const processTime = ((Date.now() - processStart) / 1000).toFixed(1);
176
183
  const wordCount = fixedText.trim().split(/\s+/).filter(w => w.length > 0).length;
177
184
  const charCount = fixedText.length;
178
- // Log stats
179
- console.log(chalk.dim("Audio: ") + chalk.white(formatDuration(recording.durationSeconds)) +
180
- chalk.dim(" • Processing: ") + chalk.white(processTime + "s"));
185
+ // Calculate cost if usage info is available
186
+ let costString;
187
+ if (postprocessResult.usage) {
188
+ const cost = calculateCost(modelName, postprocessResult.usage);
189
+ costString = formatCost(cost);
190
+ }
181
191
  // Draw box
182
192
  const termWidth = Math.min(process.stdout.columns || 60, 80);
183
193
  const lineWidth = termWidth - 2;
184
194
  const label = " TRANSCRIPT ";
185
- console.log(chalk.dim("┌─") + chalk.cyan(label) + chalk.dim("─".repeat(lineWidth - label.length - 1) + "┐"));
195
+ console.log(colors.dim(BOX.topLeft + BOX.horizontal) + colors.header.bold(label) + colors.dim(BOX.horizontal.repeat(lineWidth - label.length - 1) + BOX.topRight));
186
196
  const lines = fixedText.split("\n");
187
197
  for (const line of lines) {
188
198
  // Wrap long lines
@@ -190,18 +200,23 @@ async function main() {
190
200
  while (remaining.length > 0) {
191
201
  const chunk = remaining.slice(0, lineWidth - 2);
192
202
  remaining = remaining.slice(lineWidth - 2);
193
- console.log(chalk.dim(" ") + chalk.white(chunk.padEnd(lineWidth - 2)) + chalk.dim(" "));
203
+ console.log(colors.dim(BOX.vertical + " ") + colors.white(chunk.padEnd(lineWidth - 2)) + colors.dim(" " + BOX.vertical));
194
204
  }
195
205
  if (line.length === 0) {
196
- console.log(chalk.dim(" " + " ".repeat(lineWidth - 2) + " "));
206
+ console.log(colors.dim(BOX.vertical + " " + " ".repeat(lineWidth - 2) + " " + BOX.vertical));
197
207
  }
198
208
  }
199
- const stats = ` ${wordCount} words ${charCount} chars `;
200
- const bottomLine = "─".repeat(lineWidth - stats.length - 1) + " ";
201
- console.log(chalk.dim("└" + bottomLine) + chalk.dim(stats) + chalk.dim("┘"));
209
+ const stats = ` ${wordCount} words \u2022 ${charCount} chars `;
210
+ const bottomLine = BOX.horizontal.repeat(lineWidth - stats.length - 1) + " ";
211
+ console.log(colors.dim(BOX.bottomLeft + bottomLine) + colors.metadata(stats) + colors.dim(BOX.bottomRight));
202
212
  await copyToClipboard(fixedText);
203
- console.log(chalk.green("✓") + chalk.gray(" Copied to clipboard"));
204
- // 8. Clean up
213
+ console.log(formatCompactStats({
214
+ audioDuration: formatDuration(recording.durationSeconds),
215
+ processingTime: processTime + "s",
216
+ cost: costString,
217
+ }));
218
+ console.log(colors.success("\u2713") + colors.metadata(" Copied to clipboard"));
219
+ // 7. Clean up
205
220
  fs.unlinkSync(mp3Path);
206
221
  }
207
222
  catch (error) {
@@ -211,8 +226,8 @@ async function main() {
211
226
  fs.mkdirSync(backupDir, { recursive: true });
212
227
  const backupPath = path.join(backupDir, `recording-${Date.now()}.mp3`);
213
228
  fs.renameSync(mp3Path, backupPath);
214
- console.error(chalk.red(`Error: ${error}`));
215
- console.log(chalk.yellow(`Recording saved to: ${backupPath}`));
229
+ console.error(colors.error(`Error: ${error}`));
230
+ console.log(colors.info(`Recording saved to: ${backupPath}`));
216
231
  process.exit(1);
217
232
  }
218
233
  }
@@ -222,7 +237,7 @@ async function main() {
222
237
  if (error instanceof Error && error.message === "cancelled") {
223
238
  process.exit(0);
224
239
  }
225
- console.error(chalk.red(`Recording error: ${error}`));
240
+ console.error(colors.error(`Recording error: ${error}`));
226
241
  process.exit(1);
227
242
  }
228
243
  }
@@ -1,4 +1,5 @@
1
1
  import { ProviderType } from "./utils/providers.js";
2
+ import type { UsageInfo } from "./utils/pricing.js";
2
3
  export interface PostprocessOptions {
3
4
  provider: ProviderType;
4
5
  modelName: string;
@@ -7,4 +8,8 @@ export interface PostprocessOptions {
7
8
  transcriptionPrefix: string;
8
9
  onProgress?: (progress: number) => void;
9
10
  }
10
- export declare function postprocess(rawTranscription: string, customPrompt: string | null, options: PostprocessOptions): Promise<string>;
11
+ export interface PostprocessResult {
12
+ text: string;
13
+ usage?: UsageInfo;
14
+ }
15
+ export declare function postprocess(rawTranscription: string, customPrompt: string | null, options: PostprocessOptions): Promise<PostprocessResult>;
@@ -35,7 +35,12 @@ export async function postprocess(rawTranscription, customPrompt, options) {
35
35
  onProgress(progress);
36
36
  }
37
37
  }
38
- return accumulated.trim();
38
+ // Capture usage info after stream completes
39
+ const usage = await textStream.usage;
40
+ const usageInfo = usage?.inputTokens !== undefined && usage?.outputTokens !== undefined
41
+ ? { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }
42
+ : undefined;
43
+ return { text: accumulated.trim(), usage: usageInfo };
39
44
  }, 3, "postprocess");
40
45
  return result;
41
46
  }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ export declare const BOX: {
2
+ readonly topLeft: "┌";
3
+ readonly topRight: "┐";
4
+ readonly bottomLeft: "└";
5
+ readonly bottomRight: "┘";
6
+ readonly horizontal: "─";
7
+ readonly vertical: "│";
8
+ readonly teeRight: "├";
9
+ readonly teeLeft: "┤";
10
+ };
11
+ export declare const colors: {
12
+ readonly header: import("chalk").ChalkInstance;
13
+ readonly action: import("chalk").ChalkInstance;
14
+ readonly info: import("chalk").ChalkInstance;
15
+ readonly metadata: import("chalk").ChalkInstance;
16
+ readonly success: import("chalk").ChalkInstance;
17
+ readonly error: import("chalk").ChalkInstance;
18
+ readonly dim: import("chalk").ChalkInstance;
19
+ readonly white: import("chalk").ChalkInstance;
20
+ };
21
+ export interface StartupConfig {
22
+ model: string;
23
+ vocabSources: string[];
24
+ }
25
+ export declare function renderStartupHeader(config: StartupConfig): void;
26
+ export interface CompactStats {
27
+ audioDuration: string;
28
+ processingTime: string;
29
+ cost?: string;
30
+ }
31
+ export declare function formatCompactStats(stats: CompactStats): string;
32
+ export declare function statusPrefix(): string;
33
+ export declare function formatStatus(message: string): string;
package/dist/ui.js ADDED
@@ -0,0 +1,66 @@
1
+ import chalk from "chalk";
2
+ // Box-drawing characters
3
+ export const BOX = {
4
+ topLeft: "┌",
5
+ topRight: "┐",
6
+ bottomLeft: "└",
7
+ bottomRight: "┘",
8
+ horizontal: "─",
9
+ vertical: "│",
10
+ teeRight: "├",
11
+ teeLeft: "┤",
12
+ };
13
+ // Semantic colors following Research-Agent aesthetics
14
+ export const colors = {
15
+ header: chalk.blue,
16
+ action: chalk.cyan,
17
+ info: chalk.yellow.italic,
18
+ metadata: chalk.gray,
19
+ success: chalk.green,
20
+ error: chalk.red,
21
+ dim: chalk.dim,
22
+ white: chalk.white,
23
+ };
24
+ export function renderStartupHeader(config) {
25
+ const termWidth = Math.min(process.stdout.columns || 60, 66);
26
+ const innerWidth = termWidth - 4; // Account for "│ " and " │"
27
+ const headerLabel = " WHSPR ";
28
+ const topLine = BOX.topLeft + BOX.horizontal + colors.header.bold(headerLabel) +
29
+ colors.dim(BOX.horizontal.repeat(termWidth - headerLabel.length - 3) + BOX.topRight);
30
+ console.log(topLine);
31
+ // Model line
32
+ const modelLabel = "Model: ";
33
+ const modelValue = config.model;
34
+ const modelLine = `${modelLabel}${modelValue}`;
35
+ console.log(colors.dim(BOX.vertical + " ") +
36
+ colors.metadata(modelLabel) + colors.white(modelValue) +
37
+ " ".repeat(Math.max(0, innerWidth - modelLine.length)) +
38
+ colors.dim(" " + BOX.vertical));
39
+ // Vocab line (only show if sources exist)
40
+ if (config.vocabSources.length > 0) {
41
+ const vocabLabel = "Vocab: ";
42
+ const vocabValue = config.vocabSources.join(" + ");
43
+ const vocabLine = `${vocabLabel}${vocabValue}`;
44
+ console.log(colors.dim(BOX.vertical + " ") +
45
+ colors.metadata(vocabLabel) + colors.info(vocabValue) +
46
+ " ".repeat(Math.max(0, innerWidth - vocabLine.length)) +
47
+ colors.dim(" " + BOX.vertical));
48
+ }
49
+ // Bottom border
50
+ console.log(colors.dim(BOX.bottomLeft + BOX.horizontal.repeat(termWidth - 2) + BOX.bottomRight));
51
+ console.log(); // Empty line after header
52
+ }
53
+ export function formatCompactStats(stats) {
54
+ let result = colors.metadata("Audio: ") + colors.white(stats.audioDuration) +
55
+ colors.metadata(" \u2022 Processing: ") + colors.white(stats.processingTime);
56
+ if (stats.cost) {
57
+ result += colors.metadata(" \u2022 Cost: ") + colors.white(stats.cost);
58
+ }
59
+ return result;
60
+ }
61
+ export function statusPrefix() {
62
+ return colors.dim(BOX.teeRight + BOX.horizontal + " ");
63
+ }
64
+ export function formatStatus(message) {
65
+ return statusPrefix() + colors.action(message);
66
+ }
@@ -0,0 +1,11 @@
1
+ export interface ModelPricing {
2
+ input: number;
3
+ output: number;
4
+ }
5
+ export declare const MODEL_PRICING: Record<string, ModelPricing>;
6
+ export interface UsageInfo {
7
+ inputTokens: number;
8
+ outputTokens: number;
9
+ }
10
+ export declare function calculateCost(modelName: string, usage: UsageInfo): number;
11
+ export declare function formatCost(cost: number): string;
@@ -0,0 +1,29 @@
1
+ export const MODEL_PRICING = {
2
+ // Groq models
3
+ "openai/gpt-oss-120b": { input: 0.00, output: 0.00 }, // Free tier pricing
4
+ // Anthropic models
5
+ "claude-sonnet-4-5": { input: 3.00, output: 15.00 },
6
+ "claude-haiku-4-5": { input: 0.80, output: 4.00 },
7
+ "claude-opus-4-5": { input: 15.00, output: 75.00 },
8
+ };
9
+ export function calculateCost(modelName, usage) {
10
+ const pricing = MODEL_PRICING[modelName];
11
+ if (!pricing) {
12
+ return 0;
13
+ }
14
+ const inputCost = (usage.inputTokens / 1_000_000) * pricing.input;
15
+ const outputCost = (usage.outputTokens / 1_000_000) * pricing.output;
16
+ return inputCost + outputCost;
17
+ }
18
+ export function formatCost(cost) {
19
+ if (cost === 0) {
20
+ return "$0.00";
21
+ }
22
+ if (cost < 0.0001) {
23
+ return `$${cost.toFixed(6)}`;
24
+ }
25
+ if (cost < 0.01) {
26
+ return `$${cost.toFixed(4)}`;
27
+ }
28
+ return `$${cost.toFixed(2)}`;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whspr",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "CLI tool for audio transcription with Groq Whisper API",
5
5
  "type": "module",
6
6
  "bin": {