@settinghead/voxlert 0.3.5

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/assets/cortana.png +0 -0
  4. package/assets/deckard-cain.png +0 -0
  5. package/assets/demo-thumbnail.png +0 -0
  6. package/assets/glados.png +0 -0
  7. package/assets/hl-hev-suit.png +0 -0
  8. package/assets/logo.png +0 -0
  9. package/assets/red-alert-eva.png +0 -0
  10. package/assets/sc1-adjutant.gif +0 -0
  11. package/assets/sc1-kerrigan.gif +0 -0
  12. package/assets/sc1-protoss-advisor.jpg +0 -0
  13. package/assets/sc2-adjutant.jpg +0 -0
  14. package/assets/sc2-kerrigan.jpg +0 -0
  15. package/assets/ss1-shodan.png +0 -0
  16. package/config.default.json +35 -0
  17. package/openclaw-plugin/index.ts +100 -0
  18. package/openclaw-plugin/openclaw.plugin.json +21 -0
  19. package/package.json +51 -0
  20. package/packs/hl-hev-suit/pack.json +72 -0
  21. package/packs/hl-hev-suit/voice.wav +0 -0
  22. package/packs/red-alert-eva/pack.json +73 -0
  23. package/packs/red-alert-eva/voice.wav +0 -0
  24. package/packs/sc1-adjutant/pack.json +31 -0
  25. package/packs/sc1-adjutant/voice.wav +0 -0
  26. package/packs/sc1-kerrigan/pack.json +69 -0
  27. package/packs/sc1-kerrigan/voice.wav +0 -0
  28. package/packs/sc1-protoss-advisor/pack.json +70 -0
  29. package/packs/sc1-protoss-advisor/voice.wav +0 -0
  30. package/packs/sc2-adjutant/pack.json +14 -0
  31. package/packs/sc2-adjutant/voice.wav +0 -0
  32. package/packs/sc2-kerrigan/pack.json +69 -0
  33. package/packs/sc2-kerrigan/voice.wav +0 -0
  34. package/packs/sc2-protoss-advisor/pack.json +70 -0
  35. package/packs/sc2-protoss-advisor/voice.wav +0 -0
  36. package/packs/ss1-shodan/pack.json +69 -0
  37. package/packs/ss1-shodan/voice.wav +0 -0
  38. package/skills/voxlert-config/SKILL.md +44 -0
  39. package/src/activity-log.js +58 -0
  40. package/src/audio.js +381 -0
  41. package/src/cli.js +86 -0
  42. package/src/codex-config.js +149 -0
  43. package/src/commands/codex-notify.js +70 -0
  44. package/src/commands/config.js +141 -0
  45. package/src/commands/cost.js +20 -0
  46. package/src/commands/cursor-hook.js +52 -0
  47. package/src/commands/help.js +25 -0
  48. package/src/commands/hook-utils.js +73 -0
  49. package/src/commands/hook.js +27 -0
  50. package/src/commands/index.js +45 -0
  51. package/src/commands/log.js +92 -0
  52. package/src/commands/notification.js +50 -0
  53. package/src/commands/pack-helpers.js +157 -0
  54. package/src/commands/pack.js +25 -0
  55. package/src/commands/setup.js +13 -0
  56. package/src/commands/test.js +14 -0
  57. package/src/commands/uninstall.js +60 -0
  58. package/src/commands/version.js +12 -0
  59. package/src/commands/voice.js +14 -0
  60. package/src/commands/volume.js +38 -0
  61. package/src/config.js +230 -0
  62. package/src/cost.js +124 -0
  63. package/src/cursor-hooks.js +93 -0
  64. package/src/formats.js +55 -0
  65. package/src/hooks.js +129 -0
  66. package/src/llm.js +237 -0
  67. package/src/overlay.js +212 -0
  68. package/src/overlay.jxa +186 -0
  69. package/src/pack-registry.js +28 -0
  70. package/src/packs.js +182 -0
  71. package/src/paths.js +39 -0
  72. package/src/postinstall.js +13 -0
  73. package/src/providers.js +129 -0
  74. package/src/setup-ui.js +177 -0
  75. package/src/setup.js +504 -0
  76. package/src/tts-test.js +243 -0
  77. package/src/upgrade-check.js +137 -0
  78. package/src/voxlert.js +200 -0
  79. package/voxlert.sh +4 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Voxlert — Native macOS overlay notification.
3
+ *
4
+ * Usage:
5
+ * osascript -l JavaScript overlay.jxa <message> <gradient_json> <icon_path> <slot> <dismiss_secs> [subtitle]
6
+ *
7
+ * Arguments:
8
+ * message — Main phrase text
9
+ * gradient_json — JSON array of [r,g,b] triples (0-1), e.g. '[[0.1,0.4,0.7],[0.05,0.2,0.5]]'
10
+ * icon_path — Absolute path to character icon (or "" for no icon)
11
+ * slot — Vertical slot index (0-4)
12
+ * dismiss_secs — Seconds before auto-dismiss
13
+ * subtitle — Optional subtitle text (project name · character)
14
+ */
15
+
16
+ ObjC.import("Cocoa");
17
+ ObjC.import("QuartzCore");
18
+
19
+ function run(argv) {
20
+ var message = argv[0] || "Notification";
21
+ var gradientJson = argv[1] || "[[0.15,0.15,0.2],[0.1,0.1,0.15]]";
22
+ var iconPath = argv[2] || "";
23
+ var slot = parseInt(argv[3], 10) || 0;
24
+ var dismissSecs = parseFloat(argv[4]) || 4;
25
+ var subtitle = argv[5] || "";
26
+
27
+ // Parse gradient colors
28
+ var colors;
29
+ try {
30
+ colors = JSON.parse(gradientJson);
31
+ } catch (e) {
32
+ colors = [[0.15, 0.15, 0.2], [0.1, 0.1, 0.15]];
33
+ }
34
+
35
+ // Ensure we have at least 2 color stops
36
+ if (!Array.isArray(colors) || colors.length < 2) {
37
+ colors = [[0.15, 0.15, 0.2], [0.1, 0.1, 0.15]];
38
+ }
39
+
40
+ // Window dimensions
41
+ var winWidth = 500;
42
+ var winHeight = 80;
43
+ var cornerRadius = 12;
44
+ var iconSize = 48;
45
+ var iconPadding = 16;
46
+
47
+ // Get screen geometry
48
+ var screen = $.NSScreen.mainScreen;
49
+ var frame = screen.visibleFrame;
50
+ var screenWidth = frame.size.width;
51
+ var screenTop = frame.origin.y + frame.size.height;
52
+
53
+ // Position: top-center, stacked by slot
54
+ var x = (screenWidth - winWidth) / 2 + frame.origin.x;
55
+ var slotGap = winHeight + 8;
56
+ var y = screenTop - winHeight - 12 - (slot * slotGap);
57
+
58
+ // Create borderless, always-on-top window
59
+ var winRect = $.NSMakeRect(x, y, winWidth, winHeight);
60
+ var styleMask = $.NSWindowStyleMaskBorderless;
61
+ var win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer(
62
+ winRect, styleMask, $.NSBackingStoreBuffered, false
63
+ );
64
+ win.setLevel($.NSStatusWindowLevel + 1);
65
+ win.setOpaque(false);
66
+ win.setAlphaValue(0.95);
67
+ win.setBackgroundColor($.NSColor.clearColor);
68
+ win.setHasShadow(true);
69
+ win.setIgnoresMouseEvents(true);
70
+
71
+ // Content view with rounded corners
72
+ var contentView = win.contentView;
73
+ contentView.wantsLayer = true;
74
+ var layer = contentView.layer;
75
+ layer.cornerRadius = cornerRadius;
76
+ layer.masksToBounds = true;
77
+
78
+ // Animated gradient background
79
+ var gradientLayer = $.CAGradientLayer.layer;
80
+ gradientLayer.frame = layer.bounds;
81
+
82
+ // Build NSColor array for gradient
83
+ var nsColors = $.NSMutableArray.alloc.init;
84
+ for (var i = 0; i < colors.length; i++) {
85
+ var c = colors[i];
86
+ var r = c[0] || 0, g = c[1] || 0, b = c[2] || 0;
87
+ var nsColor = $.NSColor.colorWithCalibratedRedGreenBlueAlpha(r, g, b, 1.0);
88
+ nsColors.addObject(nsColor.CGColor);
89
+ }
90
+ gradientLayer.colors = nsColors;
91
+ gradientLayer.startPoint = $.CGPointMake(0, 0);
92
+ gradientLayer.endPoint = $.CGPointMake(1, 1);
93
+ layer.insertSublayerAtIndex(gradientLayer, 0);
94
+
95
+ // Animate gradient flow
96
+ var anim = $.CABasicAnimation.animationWithKeyPath("startPoint");
97
+ anim.fromValue = $.NSValue.valueWithPoint($.NSMakePoint(0, 0));
98
+ anim.toValue = $.NSValue.valueWithPoint($.NSMakePoint(1, 0));
99
+ anim.duration = 3.0;
100
+ anim.repeatCount = Infinity;
101
+ anim.autoreverses = true;
102
+ gradientLayer.addAnimationForKey(anim, "flowStart");
103
+
104
+ var anim2 = $.CABasicAnimation.animationWithKeyPath("endPoint");
105
+ anim2.fromValue = $.NSValue.valueWithPoint($.NSMakePoint(1, 1));
106
+ anim2.toValue = $.NSValue.valueWithPoint($.NSMakePoint(0, 1));
107
+ anim2.duration = 3.0;
108
+ anim2.repeatCount = Infinity;
109
+ anim2.autoreverses = true;
110
+ gradientLayer.addAnimationForKey(anim2, "flowEnd");
111
+
112
+ // Text area offset (shift right if icon present)
113
+ var textLeft = iconPadding;
114
+ var hasIcon = false;
115
+
116
+ // Character icon
117
+ if (iconPath && iconPath !== "") {
118
+ var img = $.NSImage.alloc.initWithContentsOfFile($(iconPath));
119
+ if (img && img.isValid) {
120
+ hasIcon = true;
121
+ var imgView = $.NSImageView.alloc.initWithFrame(
122
+ $.NSMakeRect(iconPadding, (winHeight - iconSize) / 2, iconSize, iconSize)
123
+ );
124
+ imgView.setImage(img);
125
+ imgView.setImageScaling($.NSImageScaleProportionallyUpOrDown);
126
+ contentView.addSubview(imgView);
127
+ textLeft = iconPadding + iconSize + 12;
128
+ }
129
+ }
130
+
131
+ var textWidth = winWidth - textLeft - iconPadding;
132
+
133
+ // Main phrase text (bold 16pt, white)
134
+ var phraseLabel = $.NSTextField.alloc.initWithFrame(
135
+ $.NSMakeRect(textLeft, subtitle ? 28 : 22, textWidth, 30)
136
+ );
137
+ phraseLabel.stringValue = $(message);
138
+ phraseLabel.bezeled = false;
139
+ phraseLabel.drawsBackground = false;
140
+ phraseLabel.editable = false;
141
+ phraseLabel.selectable = false;
142
+ phraseLabel.textColor = $.NSColor.whiteColor;
143
+ phraseLabel.font = $.NSFont.boldSystemFontOfSize(16);
144
+ phraseLabel.lineBreakMode = $.NSLineBreakByTruncatingTail;
145
+ contentView.addSubview(phraseLabel);
146
+
147
+ // Subtitle (12pt, white 70% opacity)
148
+ if (subtitle) {
149
+ var subLabel = $.NSTextField.alloc.initWithFrame(
150
+ $.NSMakeRect(textLeft, 10, textWidth, 20)
151
+ );
152
+ subLabel.stringValue = $(subtitle);
153
+ subLabel.bezeled = false;
154
+ subLabel.drawsBackground = false;
155
+ subLabel.editable = false;
156
+ subLabel.selectable = false;
157
+ subLabel.textColor = $.NSColor.colorWithCalibratedRedGreenBlueAlpha(1, 1, 1, 0.7);
158
+ subLabel.font = $.NSFont.systemFontOfSize(12);
159
+ subLabel.lineBreakMode = $.NSLineBreakByTruncatingTail;
160
+ contentView.addSubview(subLabel);
161
+ }
162
+
163
+ // Show window (skip NSViewAnimation fade-in — in JXA the animation target can be lost/bridge as null)
164
+ win.setAlphaValue(0.95);
165
+ win.orderFrontRegardless;
166
+
167
+ var app = $.NSApplication.sharedApplication;
168
+ app.activateIgnoringOtherApps(false);
169
+
170
+ // Run loop with timeout
171
+ var runLoop = $.NSRunLoop.currentRunLoop;
172
+ var endDate = $.NSDate.dateWithTimeIntervalSinceNow(dismissSecs);
173
+ while (runLoop.runModeBeforeDate($.NSDefaultRunLoopMode, endDate)) {
174
+ if ($.NSDate.date.compare(endDate) !== $.NSOrderedAscending) break;
175
+ }
176
+
177
+ // Fade out by animating alpha (avoids NSViewAnimation null-target issue in JXA)
178
+ var fadeOutEnd = $.NSDate.dateWithTimeIntervalSinceNow(0.35);
179
+ for (var a = 0.95; a > 0; a -= 0.15) {
180
+ win.setAlphaValue(Math.max(0, a));
181
+ runLoop.runModeBeforeDate($.NSDefaultRunLoopMode, $.NSDate.dateWithTimeIntervalSinceNow(0.05));
182
+ }
183
+ win.setAlphaValue(0);
184
+
185
+ win.close;
186
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Registry of voice packs that can be downloaded from the GitHub repo.
3
+ * Used by the setup wizard so users can choose which packs to install
4
+ * without shipping them all in the npm package.
5
+ *
6
+ * baseUrl should point at raw GitHub content (branch or tag).
7
+ */
8
+
9
+ const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/settinghead/voxlert/main/packs";
10
+
11
+ export const PACK_REGISTRY = [
12
+ { id: "sc2-adjutant", name: "StarCraft 2 Adjutant" },
13
+ { id: "sc1-adjutant", name: "StarCraft 1 Adjutant" },
14
+ { id: "red-alert-eva", name: "Red Alert EVA" },
15
+ { id: "sc1-kerrigan", name: "SC1 Kerrigan" },
16
+ { id: "sc2-kerrigan", name: "SC2 Kerrigan" },
17
+ { id: "sc1-protoss-advisor", name: "Protoss Advisor (SC1)" },
18
+ { id: "sc2-protoss-advisor", name: "Protoss Advisor (SC2)" },
19
+ { id: "ss1-shodan", name: "SHODAN" },
20
+ { id: "hl-hev-suit", name: "HEV Suit" },
21
+ ];
22
+
23
+ /** Default pack ids to pre-select in setup when offering download. */
24
+ export const DEFAULT_DOWNLOAD_PACK_IDS = ["sc2-adjutant", "sc1-adjutant", "red-alert-eva"];
25
+
26
+ export function getPackRegistryBaseUrl() {
27
+ return process.env.VOXLERT_PACKS_BASE_URL || GITHUB_RAW_BASE;
28
+ }
package/src/packs.js ADDED
@@ -0,0 +1,182 @@
1
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { execSync } from "child_process";
4
+ import { createHash } from "crypto";
5
+ import { PACKS_DIR, PACK_VOLUME_CACHE_DIR } from "./paths.js";
6
+
7
+ // Target mean volume in dBFS — voices are normalized to this level
8
+ const TARGET_MEAN_DB = -16;
9
+
10
+ /**
11
+ * Analyze a WAV file's mean volume using ffmpeg volumedetect.
12
+ * Returns mean_volume in dBFS, or null on failure.
13
+ */
14
+ function analyzeVolume(wavPath) {
15
+ try {
16
+ const output = execSync(
17
+ `ffmpeg -i "${wavPath}" -af volumedetect -f null /dev/null 2>&1`,
18
+ { encoding: "utf-8", timeout: 10000 },
19
+ );
20
+ const match = output.match(/mean_volume:\s*([-\d.]+)\s*dB/);
21
+ if (match) return parseFloat(match[1]);
22
+ } catch {
23
+ // ffmpeg not available or analysis failed
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Get volume offset in dB for a pack's voice file.
30
+ * Caches result in ~/.voxlert/pack-volume/<pack-id>.json.
31
+ */
32
+ function getVolumeOffsetDb(voicePath, packId) {
33
+ if (!voicePath || !existsSync(voicePath)) return 0;
34
+
35
+ let stats;
36
+ try {
37
+ stats = statSync(voicePath);
38
+ } catch {
39
+ return 0;
40
+ }
41
+
42
+ const voiceKey = createHash("sha1").update(voicePath).digest("hex");
43
+
44
+ // Check cache
45
+ const cachePath = join(PACK_VOLUME_CACHE_DIR, `${packId || "_legacy"}.json`);
46
+ try {
47
+ const cached = JSON.parse(readFileSync(cachePath, "utf-8"));
48
+ if (
49
+ cached.voiceKey === voiceKey &&
50
+ cached.mtimeMs === Math.round(stats.mtimeMs) &&
51
+ cached.size === stats.size &&
52
+ typeof cached.offsetDb === "number"
53
+ ) {
54
+ return cached.offsetDb;
55
+ }
56
+ } catch {
57
+ // no cache or invalid
58
+ }
59
+
60
+ // Analyze and cache
61
+ const meanDb = analyzeVolume(voicePath);
62
+ if (meanDb == null) return 0;
63
+
64
+ const offsetDb = Math.round((TARGET_MEAN_DB - meanDb) * 10) / 10;
65
+ try {
66
+ mkdirSync(PACK_VOLUME_CACHE_DIR, { recursive: true });
67
+ writeFileSync(
68
+ cachePath,
69
+ JSON.stringify({
70
+ voiceKey,
71
+ mtimeMs: Math.round(stats.mtimeMs),
72
+ size: stats.size,
73
+ meanDb,
74
+ offsetDb,
75
+ }) + "\n",
76
+ );
77
+ } catch {
78
+ // best-effort caching
79
+ }
80
+ return offsetDb;
81
+ }
82
+
83
+ /**
84
+ * Load a voice pack by id (from config.active_pack).
85
+ * Returns { id, name, echo, voicePath, style, fallback_phrases }.
86
+ * Falls back to legacy config.voice if no active_pack is set.
87
+ */
88
+ export function loadPack(config) {
89
+ let packId = config.active_pack;
90
+
91
+ // Random mode: pick a random pack each time
92
+ if (packId === "random") {
93
+ const packs = listPacks();
94
+ if (packs.length > 0) {
95
+ packId = packs[Math.floor(Math.random() * packs.length)].id;
96
+ } else {
97
+ packId = null;
98
+ }
99
+ }
100
+
101
+ // Legacy fallback: no active_pack configured
102
+ if (!packId) {
103
+ return {
104
+ id: "_legacy",
105
+ name: "Legacy",
106
+ echo: true,
107
+ voicePath: config.voice || "default.wav",
108
+ volumeOffsetDb: 0,
109
+ style: null,
110
+ fallback_phrases: null,
111
+ };
112
+ }
113
+
114
+ const packDir = join(PACKS_DIR, packId);
115
+ const packJsonPath = join(packDir, "pack.json");
116
+
117
+ let packData;
118
+ try {
119
+ packData = JSON.parse(readFileSync(packJsonPath, "utf-8"));
120
+ } catch {
121
+ // Pack not found or invalid — fall back to defaults
122
+ return {
123
+ id: packId,
124
+ name: packId,
125
+ echo: true,
126
+ voicePath: config.voice || "default.wav",
127
+ volumeOffsetDb: 0,
128
+ style: null,
129
+ fallback_phrases: null,
130
+ };
131
+ }
132
+
133
+ // Resolve voice path: relative to pack dir, or fall back to config.voice
134
+ let voicePath = config.voice || "default.wav";
135
+ if (packData.voice) {
136
+ const resolved = resolve(packDir, packData.voice);
137
+ if (existsSync(resolved)) {
138
+ voicePath = resolved;
139
+ }
140
+ }
141
+
142
+ return {
143
+ id: packId,
144
+ name: packData.name || packId,
145
+ echo: packData.echo !== false,
146
+ voicePath,
147
+ volumeOffsetDb: getVolumeOffsetDb(voicePath, packId),
148
+ tts_params: packData.tts_params || null,
149
+ audio_filter: packData.audio_filter || null,
150
+ post_process: packData.post_process || null,
151
+ style: packData.style || packData.system_prompt || null,
152
+ examples: packData.examples || null,
153
+ fallback_phrases: packData.fallback_phrases || null,
154
+ overlay_colors: packData.overlay_colors || null,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * List all available voice packs.
160
+ * Returns [{ id, name }].
161
+ */
162
+ export function listPacks() {
163
+ const packs = [];
164
+ let entries;
165
+ try {
166
+ entries = readdirSync(PACKS_DIR, { withFileTypes: true });
167
+ } catch {
168
+ return packs;
169
+ }
170
+
171
+ for (const entry of entries) {
172
+ if (!entry.isDirectory()) continue;
173
+ const packJsonPath = join(PACKS_DIR, entry.name, "pack.json");
174
+ try {
175
+ const data = JSON.parse(readFileSync(packJsonPath, "utf-8"));
176
+ packs.push({ id: entry.name, name: data.name || entry.name });
177
+ } catch {
178
+ // skip invalid packs
179
+ }
180
+ }
181
+ return packs;
182
+ }
package/src/paths.js ADDED
@@ -0,0 +1,39 @@
1
+ import { dirname, join } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { homedir } from "os";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ export const SCRIPT_DIR = dirname(__dirname);
7
+
8
+ // Detect npm global install (src/ lives inside node_modules)
9
+ export const IS_NPM_GLOBAL = SCRIPT_DIR.includes("node_modules");
10
+
11
+ // User-level config and state in ~/.voxlert
12
+ export const STATE_DIR = join(homedir(), ".voxlert");
13
+
14
+ // When npm global: mutable data goes to ~/.voxlert/
15
+ // When git clone: unchanged behavior (install dir)
16
+ export const CONFIG_PATH = IS_NPM_GLOBAL
17
+ ? join(STATE_DIR, "config.json")
18
+ : join(SCRIPT_DIR, "config.json");
19
+ export const PACKS_DIR = IS_NPM_GLOBAL
20
+ ? join(STATE_DIR, "packs")
21
+ : join(SCRIPT_DIR, "packs");
22
+ export const CACHE_DIR = IS_NPM_GLOBAL
23
+ ? join(STATE_DIR, "cache")
24
+ : join(SCRIPT_DIR, "cache");
25
+ export const COLLECT_DIR = IS_NPM_GLOBAL
26
+ ? join(STATE_DIR, "llm_collect")
27
+ : join(SCRIPT_DIR, "llm_collect");
28
+
29
+ // Packs shipped with the npm package (read-only)
30
+ export const BUNDLED_PACKS_DIR = join(SCRIPT_DIR, "packs");
31
+
32
+ export const GLOBAL_USER_CONFIG_PATH = join(STATE_DIR, "config.json");
33
+ export const QUEUE_DIR = join(STATE_DIR, "queue");
34
+ export const LOCK_FILE = join(STATE_DIR, "playback.lock");
35
+ export const LOG_FILE = join(STATE_DIR, "fallback.log");
36
+ export const MAIN_LOG_FILE = join(STATE_DIR, "voxlert.log");
37
+ export const HOOK_DEBUG_LOG = join(STATE_DIR, "hook-debug.log");
38
+ export const USAGE_FILE = join(STATE_DIR, "usage.jsonl");
39
+ export const PACK_VOLUME_CACHE_DIR = join(STATE_DIR, "pack-volume");
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+
3
+ // npm postinstall — prints setup instructions only, no file mutations.
4
+ console.log(`
5
+ ╔══════════════════════════════════════════════╗
6
+ ║ Voxlert installed! ║
7
+ ║ ║
8
+ ║ Run the setup wizard: ║
9
+ ║ ║
10
+ ║ voxlert setup ║
11
+ ║ ║
12
+ ╚══════════════════════════════════════════════╝
13
+ `);
@@ -0,0 +1,129 @@
1
+ // LLM provider registry — base URLs, auth patterns, default models, signup URLs.
2
+ // Anthropic uses its own Messages API format; all others are OpenAI-compatible.
3
+
4
+ export const LLM_PROVIDERS = {
5
+ openrouter: {
6
+ name: "OpenRouter",
7
+ description: "200+ models, cheap",
8
+ baseUrl: "https://openrouter.ai/api/v1",
9
+ defaultModel: "google/gemma-3-27b-it",
10
+ signupUrl: "https://openrouter.ai/keys",
11
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
12
+ format: "openai",
13
+ },
14
+ openai: {
15
+ name: "OpenAI",
16
+ description: "GPT-4o-mini",
17
+ baseUrl: "https://api.openai.com/v1",
18
+ defaultModel: "gpt-4o-mini",
19
+ signupUrl: "https://platform.openai.com/api-keys",
20
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
21
+ format: "openai",
22
+ },
23
+ gemini: {
24
+ name: "Google Gemini",
25
+ description: "free tier available",
26
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
27
+ defaultModel: "gemini-2.0-flash",
28
+ signupUrl: "https://aistudio.google.com/apikey",
29
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
30
+ format: "openai",
31
+ },
32
+ anthropic: {
33
+ name: "Anthropic",
34
+ description: "Claude 3.5 Haiku",
35
+ baseUrl: "https://api.anthropic.com",
36
+ defaultModel: "claude-3-5-haiku-latest",
37
+ signupUrl: "https://console.anthropic.com/settings/keys",
38
+ authHeader: (key) => ({
39
+ "x-api-key": key,
40
+ "anthropic-version": "2023-06-01",
41
+ }),
42
+ format: "anthropic",
43
+ },
44
+ };
45
+
46
+ export function getProvider(id) {
47
+ return LLM_PROVIDERS[id] || null;
48
+ }
49
+
50
+ /**
51
+ * Resolve the API key for a given provider from config.
52
+ * Checks unified llm_api_key first, then legacy openrouter_api_key for backward compat.
53
+ */
54
+ export function getApiKey(config) {
55
+ if (config.llm_api_key) return config.llm_api_key;
56
+ // Backward compat: openrouter_api_key
57
+ if (config.openrouter_api_key) return config.openrouter_api_key;
58
+ return "";
59
+ }
60
+
61
+ /**
62
+ * Resolve the model for a given provider from config.
63
+ */
64
+ export function getModel(config) {
65
+ const provider = getProvider(config.llm_backend);
66
+ if (config.llm_model) return config.llm_model;
67
+ // Backward compat: openrouter_model
68
+ if (config.llm_backend === "openrouter" && config.openrouter_model) {
69
+ return config.openrouter_model;
70
+ }
71
+ return provider ? provider.defaultModel : "gpt-4o-mini";
72
+ }
73
+
74
+ /**
75
+ * Build the request body for the given provider format.
76
+ */
77
+ export function formatRequestBody(provider, model, messages, maxTokens, temperature) {
78
+ if (provider.format === "anthropic") {
79
+ // Convert OpenAI-style messages to Anthropic Messages API format
80
+ const system = messages.find((m) => m.role === "system")?.content || "";
81
+ const userMessages = messages
82
+ .filter((m) => m.role !== "system")
83
+ .map((m) => ({ role: m.role, content: m.content }));
84
+ return JSON.stringify({
85
+ model,
86
+ max_tokens: maxTokens,
87
+ temperature,
88
+ system,
89
+ messages: userMessages,
90
+ });
91
+ }
92
+ // OpenAI-compatible format (openrouter, openai, gemini)
93
+ return JSON.stringify({
94
+ model,
95
+ messages,
96
+ max_tokens: maxTokens,
97
+ temperature,
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Parse the response body from a provider into { phrase, usage }.
103
+ */
104
+ export function parseResponse(provider, data) {
105
+ if (provider.format === "anthropic") {
106
+ const text = data.content?.[0]?.text || "";
107
+ const usage = data.usage
108
+ ? {
109
+ prompt_tokens: data.usage.input_tokens || 0,
110
+ completion_tokens: data.usage.output_tokens || 0,
111
+ total_tokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
112
+ }
113
+ : null;
114
+ return { text, usage };
115
+ }
116
+ // OpenAI-compatible
117
+ const text = data.choices?.[0]?.message?.content || "";
118
+ const usage = data.usage || null;
119
+ return { text, usage };
120
+ }
121
+
122
+ /**
123
+ * Get the full chat completions endpoint URL for a provider.
124
+ */
125
+ export function getEndpointUrl(provider) {
126
+ const base = provider.baseUrl.replace(/\/+$/, "");
127
+ if (provider.format === "anthropic") return `${base}/v1/messages`;
128
+ return `${base}/chat/completions`;
129
+ }