@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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/assets/cortana.png +0 -0
- package/assets/deckard-cain.png +0 -0
- package/assets/demo-thumbnail.png +0 -0
- package/assets/glados.png +0 -0
- package/assets/hl-hev-suit.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/red-alert-eva.png +0 -0
- package/assets/sc1-adjutant.gif +0 -0
- package/assets/sc1-kerrigan.gif +0 -0
- package/assets/sc1-protoss-advisor.jpg +0 -0
- package/assets/sc2-adjutant.jpg +0 -0
- package/assets/sc2-kerrigan.jpg +0 -0
- package/assets/ss1-shodan.png +0 -0
- package/config.default.json +35 -0
- package/openclaw-plugin/index.ts +100 -0
- package/openclaw-plugin/openclaw.plugin.json +21 -0
- package/package.json +51 -0
- package/packs/hl-hev-suit/pack.json +72 -0
- package/packs/hl-hev-suit/voice.wav +0 -0
- package/packs/red-alert-eva/pack.json +73 -0
- package/packs/red-alert-eva/voice.wav +0 -0
- package/packs/sc1-adjutant/pack.json +31 -0
- package/packs/sc1-adjutant/voice.wav +0 -0
- package/packs/sc1-kerrigan/pack.json +69 -0
- package/packs/sc1-kerrigan/voice.wav +0 -0
- package/packs/sc1-protoss-advisor/pack.json +70 -0
- package/packs/sc1-protoss-advisor/voice.wav +0 -0
- package/packs/sc2-adjutant/pack.json +14 -0
- package/packs/sc2-adjutant/voice.wav +0 -0
- package/packs/sc2-kerrigan/pack.json +69 -0
- package/packs/sc2-kerrigan/voice.wav +0 -0
- package/packs/sc2-protoss-advisor/pack.json +70 -0
- package/packs/sc2-protoss-advisor/voice.wav +0 -0
- package/packs/ss1-shodan/pack.json +69 -0
- package/packs/ss1-shodan/voice.wav +0 -0
- package/skills/voxlert-config/SKILL.md +44 -0
- package/src/activity-log.js +58 -0
- package/src/audio.js +381 -0
- package/src/cli.js +86 -0
- package/src/codex-config.js +149 -0
- package/src/commands/codex-notify.js +70 -0
- package/src/commands/config.js +141 -0
- package/src/commands/cost.js +20 -0
- package/src/commands/cursor-hook.js +52 -0
- package/src/commands/help.js +25 -0
- package/src/commands/hook-utils.js +73 -0
- package/src/commands/hook.js +27 -0
- package/src/commands/index.js +45 -0
- package/src/commands/log.js +92 -0
- package/src/commands/notification.js +50 -0
- package/src/commands/pack-helpers.js +157 -0
- package/src/commands/pack.js +25 -0
- package/src/commands/setup.js +13 -0
- package/src/commands/test.js +14 -0
- package/src/commands/uninstall.js +60 -0
- package/src/commands/version.js +12 -0
- package/src/commands/voice.js +14 -0
- package/src/commands/volume.js +38 -0
- package/src/config.js +230 -0
- package/src/cost.js +124 -0
- package/src/cursor-hooks.js +93 -0
- package/src/formats.js +55 -0
- package/src/hooks.js +129 -0
- package/src/llm.js +237 -0
- package/src/overlay.js +212 -0
- package/src/overlay.jxa +186 -0
- package/src/pack-registry.js +28 -0
- package/src/packs.js +182 -0
- package/src/paths.js +39 -0
- package/src/postinstall.js +13 -0
- package/src/providers.js +129 -0
- package/src/setup-ui.js +177 -0
- package/src/setup.js +504 -0
- package/src/tts-test.js +243 -0
- package/src/upgrade-check.js +137 -0
- package/src/voxlert.js +200 -0
- package/voxlert.sh +4 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { packList, packShow, packUse } from "./pack-helpers.js";
|
|
2
|
+
|
|
3
|
+
export const packCommand = {
|
|
4
|
+
name: "pack",
|
|
5
|
+
aliases: [],
|
|
6
|
+
help: [
|
|
7
|
+
" voxlert pack list List available voice packs",
|
|
8
|
+
" voxlert pack show Show active pack details",
|
|
9
|
+
" voxlert pack use <pack-id> Switch active voice pack",
|
|
10
|
+
],
|
|
11
|
+
skipSetupWizard: false,
|
|
12
|
+
skipUpgradeCheck: false,
|
|
13
|
+
async run(context) {
|
|
14
|
+
const [, sub, arg] = context.args;
|
|
15
|
+
if (sub === "list" || sub === "ls") {
|
|
16
|
+
packList();
|
|
17
|
+
} else if (sub === "show") {
|
|
18
|
+
packShow();
|
|
19
|
+
} else if (sub === "use") {
|
|
20
|
+
await packUse(arg);
|
|
21
|
+
} else {
|
|
22
|
+
packList();
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const setupCommand = {
|
|
2
|
+
name: "setup",
|
|
3
|
+
aliases: [],
|
|
4
|
+
help: [
|
|
5
|
+
" voxlert setup Interactive setup wizard (LLM, voice, TTS, hooks)",
|
|
6
|
+
],
|
|
7
|
+
skipSetupWizard: true,
|
|
8
|
+
skipUpgradeCheck: false,
|
|
9
|
+
async run() {
|
|
10
|
+
const { runSetup } = await import("../setup.js");
|
|
11
|
+
await runSetup();
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { testPipeline } from "./pack-helpers.js";
|
|
2
|
+
|
|
3
|
+
export const testCommand = {
|
|
4
|
+
name: "test",
|
|
5
|
+
aliases: [],
|
|
6
|
+
help: [
|
|
7
|
+
" voxlert test \"<text>\" Run full pipeline: LLM -> TTS -> audio playback",
|
|
8
|
+
],
|
|
9
|
+
skipSetupWizard: false,
|
|
10
|
+
skipUpgradeCheck: false,
|
|
11
|
+
async run(context) {
|
|
12
|
+
await testPipeline(context.args.slice(1).join(" "));
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "fs";
|
|
2
|
+
import confirm from "@inquirer/confirm";
|
|
3
|
+
import { unregisterHooks, removeSkill } from "../hooks.js";
|
|
4
|
+
import { unregisterCursorHooks } from "../cursor-hooks.js";
|
|
5
|
+
import { unregisterCodexNotify } from "../codex-config.js";
|
|
6
|
+
import { STATE_DIR } from "../paths.js";
|
|
7
|
+
|
|
8
|
+
async function runUninstall() {
|
|
9
|
+
console.log("Removing Voxlert hooks and skill...\n");
|
|
10
|
+
|
|
11
|
+
const claudeRemoved = unregisterHooks();
|
|
12
|
+
if (claudeRemoved > 0) {
|
|
13
|
+
console.log(` Removed ${claudeRemoved} hook(s) from ~/.claude/settings.json`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const cursorRemoved = unregisterCursorHooks();
|
|
17
|
+
if (cursorRemoved > 0) {
|
|
18
|
+
console.log(` Removed ${cursorRemoved} hook(s) from ~/.cursor/hooks.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const codexRemoved = unregisterCodexNotify();
|
|
22
|
+
if (codexRemoved) {
|
|
23
|
+
console.log(" Removed notify from ~/.codex/config.toml");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const skillRemoved = removeSkill();
|
|
27
|
+
if (skillRemoved) {
|
|
28
|
+
console.log(" Removed voxlert-config skill");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (claudeRemoved === 0 && cursorRemoved === 0 && !codexRemoved && !skillRemoved) {
|
|
32
|
+
console.log(" No Voxlert hooks or skill were found.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (existsSync(STATE_DIR)) {
|
|
36
|
+
const removeData = await confirm({
|
|
37
|
+
message: `Remove config and cache (${STATE_DIR})?`,
|
|
38
|
+
default: false,
|
|
39
|
+
});
|
|
40
|
+
if (removeData) {
|
|
41
|
+
rmSync(STATE_DIR, { recursive: true });
|
|
42
|
+
console.log(` Removed ${STATE_DIR}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log("\nUninstall complete. You can still run 'voxlert' if installed via npm; run 'npm uninstall -g @settinghead/voxlert' to remove the CLI.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const uninstallCommand = {
|
|
50
|
+
name: "uninstall",
|
|
51
|
+
aliases: [],
|
|
52
|
+
help: [
|
|
53
|
+
" voxlert uninstall Remove hooks from Claude Code, Cursor, and Codex, optionally config/cache",
|
|
54
|
+
],
|
|
55
|
+
skipSetupWizard: true,
|
|
56
|
+
skipUpgradeCheck: false,
|
|
57
|
+
async run() {
|
|
58
|
+
await runUninstall();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const versionCommand = {
|
|
2
|
+
name: "version",
|
|
3
|
+
aliases: ["--version", "-v"],
|
|
4
|
+
help: [
|
|
5
|
+
" voxlert --version Show version",
|
|
6
|
+
],
|
|
7
|
+
skipSetupWizard: true,
|
|
8
|
+
skipUpgradeCheck: false,
|
|
9
|
+
async run(context) {
|
|
10
|
+
console.log(context.pkg.version);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { voicePick } from "./pack-helpers.js";
|
|
2
|
+
|
|
3
|
+
export const voiceCommand = {
|
|
4
|
+
name: "voice",
|
|
5
|
+
aliases: ["voices"],
|
|
6
|
+
help: [
|
|
7
|
+
" voxlert voice Interactive voice pack picker",
|
|
8
|
+
],
|
|
9
|
+
skipSetupWizard: false,
|
|
10
|
+
skipUpgradeCheck: false,
|
|
11
|
+
async run() {
|
|
12
|
+
await voicePick();
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
2
|
+
import { askLine } from "./pack-helpers.js";
|
|
3
|
+
|
|
4
|
+
async function setVolume(value) {
|
|
5
|
+
let num;
|
|
6
|
+
if (value == null || value === "") {
|
|
7
|
+
const config = loadConfig(process.cwd());
|
|
8
|
+
const current = Math.round((config.volume ?? 0.5) * 100);
|
|
9
|
+
const answer = await askLine(`Current volume: ${current}. Enter new volume (0-100): `);
|
|
10
|
+
num = Number(answer);
|
|
11
|
+
} else {
|
|
12
|
+
num = Number(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (isNaN(num) || num < 0 || num > 100) {
|
|
16
|
+
console.error("Volume must be a number between 0 and 100.");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const config = loadConfig(process.cwd());
|
|
21
|
+
config.volume = num / 100;
|
|
22
|
+
saveConfig(config);
|
|
23
|
+
console.log(`Volume set to ${num}%`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const volumeCommand = {
|
|
27
|
+
name: "volume",
|
|
28
|
+
aliases: ["vol"],
|
|
29
|
+
help: [
|
|
30
|
+
" voxlert volume Show current volume and prompt for new value",
|
|
31
|
+
" voxlert volume <0-100> Set playback volume (0 = mute, 100 = max)",
|
|
32
|
+
],
|
|
33
|
+
skipSetupWizard: false,
|
|
34
|
+
skipUpgradeCheck: false,
|
|
35
|
+
async run(context) {
|
|
36
|
+
await setVolume(context.args[1]);
|
|
37
|
+
},
|
|
38
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { CONFIG_PATH, GLOBAL_USER_CONFIG_PATH, SCRIPT_DIR } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
// Hook event name -> internal category
|
|
7
|
+
export const EVENT_MAP = {
|
|
8
|
+
Stop: "task.complete",
|
|
9
|
+
SessionStart: "session.start",
|
|
10
|
+
SessionEnd: "session.end",
|
|
11
|
+
UserPromptSubmit: "task.acknowledge",
|
|
12
|
+
PermissionRequest: "input.required",
|
|
13
|
+
PreCompact: "resource.limit",
|
|
14
|
+
PostToolUseFailure: "task.error",
|
|
15
|
+
Notification: "notification",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Events where we call the LLM for a contextual phrase
|
|
19
|
+
export const CONTEXTUAL_EVENTS = new Set(["Stop", "PostToolUseFailure"]);
|
|
20
|
+
|
|
21
|
+
// Fallback phrases when LLM is unavailable or for non-contextual events
|
|
22
|
+
export const FALLBACK_PHRASES = {
|
|
23
|
+
"session.start": [
|
|
24
|
+
"Systems online.",
|
|
25
|
+
"Awaiting orders",
|
|
26
|
+
"Station operational.",
|
|
27
|
+
"All systems nominal.",
|
|
28
|
+
"Ready for deployment.",
|
|
29
|
+
],
|
|
30
|
+
"task.complete": [
|
|
31
|
+
"Mission complete.",
|
|
32
|
+
"Objective secured",
|
|
33
|
+
"All tasks fulfilled.",
|
|
34
|
+
"Operation completed.",
|
|
35
|
+
"Orders carried out.",
|
|
36
|
+
"Target achieved.",
|
|
37
|
+
],
|
|
38
|
+
"task.acknowledge": [
|
|
39
|
+
"Orders received.",
|
|
40
|
+
"Request acknowledged",
|
|
41
|
+
"Operations initiated.",
|
|
42
|
+
"Command confirmed.",
|
|
43
|
+
"Directive understood.",
|
|
44
|
+
],
|
|
45
|
+
"input.required": [
|
|
46
|
+
"Authorization required.",
|
|
47
|
+
"Input needed.",
|
|
48
|
+
"Clearance requested.",
|
|
49
|
+
"Decision awaited.",
|
|
50
|
+
"Confirmation required.",
|
|
51
|
+
],
|
|
52
|
+
"resource.limit": [
|
|
53
|
+
"Memory capacity critical.",
|
|
54
|
+
"Resources nearly exhausted.",
|
|
55
|
+
"Buffer limit approached.",
|
|
56
|
+
"Context capacity strained.",
|
|
57
|
+
"Power reserves depleted.",
|
|
58
|
+
],
|
|
59
|
+
"session.end": [
|
|
60
|
+
"Session terminated.",
|
|
61
|
+
"Connection closed.",
|
|
62
|
+
"Signing off.",
|
|
63
|
+
"Session ended.",
|
|
64
|
+
"Disconnected.",
|
|
65
|
+
],
|
|
66
|
+
"task.error": [
|
|
67
|
+
"Operation failed.",
|
|
68
|
+
"Error detected.",
|
|
69
|
+
"Task aborted.",
|
|
70
|
+
"Execution error.",
|
|
71
|
+
"Failure reported.",
|
|
72
|
+
],
|
|
73
|
+
notification: [
|
|
74
|
+
"Alert received.",
|
|
75
|
+
"Status change detected.",
|
|
76
|
+
"Notification logged.",
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Fields allowed in per-directory and global-user config overrides
|
|
81
|
+
const PROJECT_OVERRIDE_FIELDS = new Set([
|
|
82
|
+
"enabled",
|
|
83
|
+
"active_pack",
|
|
84
|
+
"volume",
|
|
85
|
+
"categories",
|
|
86
|
+
"collect_llm_data",
|
|
87
|
+
"max_cache_entries",
|
|
88
|
+
"prefix",
|
|
89
|
+
"tts_backend",
|
|
90
|
+
"qwen_tts_url",
|
|
91
|
+
"overlay",
|
|
92
|
+
"overlay_dismiss",
|
|
93
|
+
"overlay_style",
|
|
94
|
+
"logging",
|
|
95
|
+
"error_log",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Walk up from cwd toward home directory looking for project config.
|
|
100
|
+
* Checks each directory for .voxlert.json then .voxlert/config.json.
|
|
101
|
+
* Returns the parsed config object from the nearest match, or null.
|
|
102
|
+
*/
|
|
103
|
+
function findProjectConfig(cwd) {
|
|
104
|
+
if (!cwd) return null;
|
|
105
|
+
|
|
106
|
+
const home = homedir();
|
|
107
|
+
let dir = cwd;
|
|
108
|
+
|
|
109
|
+
while (true) {
|
|
110
|
+
// Don't check $HOME itself — that's the global user tier
|
|
111
|
+
if (dir === home) break;
|
|
112
|
+
|
|
113
|
+
// Check .voxlert.json first (wins over .voxlert/config.json)
|
|
114
|
+
const dotFile = join(dir, ".voxlert.json");
|
|
115
|
+
try {
|
|
116
|
+
if (existsSync(dotFile)) {
|
|
117
|
+
return JSON.parse(readFileSync(dotFile, "utf-8"));
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Malformed JSON — skip and keep walking
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check .voxlert/config.json
|
|
124
|
+
const dirFile = join(dir, ".voxlert", "config.json");
|
|
125
|
+
try {
|
|
126
|
+
if (existsSync(dirFile)) {
|
|
127
|
+
return JSON.parse(readFileSync(dirFile, "utf-8"));
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Malformed JSON — skip and keep walking
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parent = dirname(dir);
|
|
134
|
+
if (parent === dir) break; // filesystem root
|
|
135
|
+
dir = parent;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Load the global user config from ~/.voxlert/config.json.
|
|
143
|
+
* Returns empty object if missing or malformed.
|
|
144
|
+
*/
|
|
145
|
+
function loadGlobalUserConfig() {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(readFileSync(GLOBAL_USER_CONFIG_PATH, "utf-8"));
|
|
148
|
+
} catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Strip disallowed fields from an override config object.
|
|
155
|
+
*/
|
|
156
|
+
function filterOverrideFields(config) {
|
|
157
|
+
const filtered = {};
|
|
158
|
+
for (const key of Object.keys(config)) {
|
|
159
|
+
if (PROJECT_OVERRIDE_FIELDS.has(key)) {
|
|
160
|
+
filtered[key] = config[key];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return filtered;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Merge configs with shallow merge for most fields, deep merge for categories.
|
|
168
|
+
*/
|
|
169
|
+
function mergeConfigs(base, ...overrides) {
|
|
170
|
+
const result = { ...base };
|
|
171
|
+
for (const override of overrides) {
|
|
172
|
+
if (!override || typeof override !== "object") continue;
|
|
173
|
+
for (const [key, value] of Object.entries(override)) {
|
|
174
|
+
if (key === "categories" && typeof value === "object" && value !== null) {
|
|
175
|
+
result.categories = { ...(result.categories || {}), ...value };
|
|
176
|
+
} else {
|
|
177
|
+
result[key] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load config with 3-tier resolution:
|
|
186
|
+
* 1. Install-dir config.json (base)
|
|
187
|
+
* 2. ~/.voxlert/config.json (global user prefs, whitelist-filtered)
|
|
188
|
+
* 3. Nearest .voxlert.json / .voxlert/config.json (project override, whitelist-filtered)
|
|
189
|
+
*
|
|
190
|
+
* When called without cwd, behaves like the original (install config + global user only).
|
|
191
|
+
*/
|
|
192
|
+
export function loadConfig(cwd) {
|
|
193
|
+
// Tier 1: install-dir base config
|
|
194
|
+
let base;
|
|
195
|
+
try {
|
|
196
|
+
base = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
197
|
+
} catch {
|
|
198
|
+
base = { enabled: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Tier 2: global user config
|
|
202
|
+
const globalUser = filterOverrideFields(loadGlobalUserConfig());
|
|
203
|
+
|
|
204
|
+
// Tier 3: project config (nearest-only)
|
|
205
|
+
const project = cwd ? filterOverrideFields(findProjectConfig(cwd) || {}) : {};
|
|
206
|
+
|
|
207
|
+
return mergeConfigs(base, globalUser, project);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function saveConfig(config) {
|
|
211
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
212
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Ensure config.json exists — creates from config.default.json template if missing.
|
|
217
|
+
* Returns the loaded config.
|
|
218
|
+
*/
|
|
219
|
+
export function ensureConfig() {
|
|
220
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
221
|
+
const templatePath = join(SCRIPT_DIR, "config.default.json");
|
|
222
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
223
|
+
if (existsSync(templatePath)) {
|
|
224
|
+
copyFileSync(templatePath, CONFIG_PATH);
|
|
225
|
+
} else {
|
|
226
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ enabled: true }, null, 2) + "\n");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return loadConfig();
|
|
230
|
+
}
|
package/src/cost.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { USAGE_FILE } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
// Fallback prices per 1M tokens [prompt, completion] in USD
|
|
5
|
+
// Used only for old records missing usage.cost and when OpenRouter API is unreachable
|
|
6
|
+
const FALLBACK_PRICES = {
|
|
7
|
+
"google/gemini-2.0-flash-001": [0.10, 0.40],
|
|
8
|
+
"openai/gpt-4o-mini": [0.15, 0.60],
|
|
9
|
+
"anthropic/claude-3.5-haiku": [0.80, 4.00],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let priceCache = null;
|
|
13
|
+
let priceCacheTime = 0;
|
|
14
|
+
const CACHE_TTL = 3600_000; // 1 hour
|
|
15
|
+
|
|
16
|
+
async function fetchPricing() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
if (priceCache && (now - priceCacheTime) < CACHE_TTL) {
|
|
19
|
+
return priceCache;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch("https://openrouter.ai/api/v1/models");
|
|
23
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
24
|
+
const json = await res.json();
|
|
25
|
+
const table = {};
|
|
26
|
+
for (const model of json.data) {
|
|
27
|
+
const p = model.pricing;
|
|
28
|
+
if (p && p.prompt && p.completion) {
|
|
29
|
+
table[model.id] = [
|
|
30
|
+
parseFloat(p.prompt) * 1_000_000,
|
|
31
|
+
parseFloat(p.completion) * 1_000_000,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
priceCache = table;
|
|
36
|
+
priceCacheTime = now;
|
|
37
|
+
return table;
|
|
38
|
+
} catch {
|
|
39
|
+
return priceCache || FALLBACK_PRICES;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadUsage() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(USAGE_FILE, "utf-8").trim();
|
|
46
|
+
if (!raw) return [];
|
|
47
|
+
return raw.split("\n").map((line) => {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(line);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}).filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resetUsage() {
|
|
60
|
+
writeFileSync(USAGE_FILE, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function formatCost() {
|
|
64
|
+
const entries = loadUsage();
|
|
65
|
+
if (!entries.length) {
|
|
66
|
+
return "No usage recorded yet.";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if any entries need price-table estimation
|
|
70
|
+
const needsEstimation = entries.some((e) => e.cost == null);
|
|
71
|
+
const prices = needsEstimation ? await fetchPricing() : null;
|
|
72
|
+
|
|
73
|
+
const byModel = {};
|
|
74
|
+
let totalPrompt = 0;
|
|
75
|
+
let totalCompletion = 0;
|
|
76
|
+
let totalTokens = 0;
|
|
77
|
+
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const m = e.model || "unknown";
|
|
80
|
+
if (!byModel[m]) byModel[m] = { prompt: 0, completion: 0, total: 0, calls: 0, cost: 0, estimated: 0 };
|
|
81
|
+
byModel[m].prompt += e.prompt_tokens || 0;
|
|
82
|
+
byModel[m].completion += e.completion_tokens || 0;
|
|
83
|
+
byModel[m].total += e.total_tokens || 0;
|
|
84
|
+
byModel[m].calls++;
|
|
85
|
+
if (e.cost != null) {
|
|
86
|
+
byModel[m].cost += e.cost;
|
|
87
|
+
} else {
|
|
88
|
+
// Estimate from price table for old records
|
|
89
|
+
const mp = prices[m];
|
|
90
|
+
if (mp) {
|
|
91
|
+
byModel[m].cost += ((e.prompt_tokens || 0) * mp[0] + (e.completion_tokens || 0) * mp[1]) / 1_000_000;
|
|
92
|
+
byModel[m].estimated++;
|
|
93
|
+
} else {
|
|
94
|
+
byModel[m].estimated++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
totalPrompt += e.prompt_tokens || 0;
|
|
98
|
+
totalCompletion += e.completion_tokens || 0;
|
|
99
|
+
totalTokens += e.total_tokens || 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lines = ["Usage Summary", "=".repeat(40)];
|
|
103
|
+
|
|
104
|
+
let grandCost = 0;
|
|
105
|
+
let totalEstimated = 0;
|
|
106
|
+
|
|
107
|
+
for (const [model, stats] of Object.entries(byModel)) {
|
|
108
|
+
lines.push(`\nModel: ${model}`);
|
|
109
|
+
lines.push(` Calls: ${stats.calls}`);
|
|
110
|
+
lines.push(` Prompt tokens: ${stats.prompt.toLocaleString()}`);
|
|
111
|
+
lines.push(` Completion tokens: ${stats.completion.toLocaleString()}`);
|
|
112
|
+
lines.push(` Total tokens: ${stats.total.toLocaleString()}`);
|
|
113
|
+
lines.push(` Cost: $${stats.cost.toFixed(6)}${stats.estimated ? ` (${stats.estimated} call${stats.estimated > 1 ? "s" : ""} estimated)` : ""}`);
|
|
114
|
+
grandCost += stats.cost;
|
|
115
|
+
totalEstimated += stats.estimated;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push(`\n${"=".repeat(40)}`);
|
|
119
|
+
lines.push(`Total calls: ${entries.length}`);
|
|
120
|
+
lines.push(`Total tokens: ${totalTokens.toLocaleString()} (${totalPrompt.toLocaleString()} prompt + ${totalCompletion.toLocaleString()} completion)`);
|
|
121
|
+
lines.push(`Total cost: $${grandCost.toFixed(6)}${totalEstimated ? ` (${totalEstimated} call${totalEstimated > 1 ? "s" : ""} estimated from price table)` : ""}`);
|
|
122
|
+
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
const CURSOR_DIR = join(homedir(), ".cursor");
|
|
6
|
+
const HOOKS_FILE = join(CURSOR_DIR, "hooks.json");
|
|
7
|
+
|
|
8
|
+
const CURSOR_HOOK_EVENTS = [
|
|
9
|
+
"sessionStart",
|
|
10
|
+
"sessionEnd",
|
|
11
|
+
"stop",
|
|
12
|
+
"postToolUseFailure",
|
|
13
|
+
"preCompact",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const HOOK_ENTRY = (command) => ({
|
|
17
|
+
command,
|
|
18
|
+
timeout: 10,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function isVoxlertHook(entry) {
|
|
22
|
+
const cmd = (entry && entry.command) || "";
|
|
23
|
+
return typeof cmd === "string" && (cmd.includes("voxlert") || cmd.includes("cursor-hook"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadHooks() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(HOOKS_FILE, "utf-8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return { version: 1, hooks: {} };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hasCursorHooks() {
|
|
35
|
+
const config = loadHooks();
|
|
36
|
+
if (!config.hooks || typeof config.hooks !== "object") return false;
|
|
37
|
+
return Object.values(config.hooks).some(
|
|
38
|
+
(entries) => Array.isArray(entries) && entries.some((entry) => isVoxlertHook(entry)),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register Voxlert in ~/.cursor/hooks.json.
|
|
44
|
+
* Merges with existing hooks; does not remove other hooks.
|
|
45
|
+
* @param {string} command — e.g. "voxlert cursor-hook"
|
|
46
|
+
* @returns {number} number of hook events registered
|
|
47
|
+
*/
|
|
48
|
+
export function registerCursorHooks(command) {
|
|
49
|
+
mkdirSync(CURSOR_DIR, { recursive: true });
|
|
50
|
+
const config = loadHooks();
|
|
51
|
+
if (config.version === undefined) config.version = 1;
|
|
52
|
+
if (!config.hooks || typeof config.hooks !== "object") config.hooks = {};
|
|
53
|
+
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (const event of CURSOR_HOOK_EVENTS) {
|
|
56
|
+
const existing = config.hooks[event];
|
|
57
|
+
const arr = Array.isArray(existing) ? existing : [];
|
|
58
|
+
const withoutUs = arr.filter((entry) => !isVoxlertHook(entry));
|
|
59
|
+
const entry = HOOK_ENTRY(command);
|
|
60
|
+
config.hooks[event] = [...withoutUs, entry];
|
|
61
|
+
count++;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
writeFileSync(HOOKS_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
65
|
+
return count;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove Voxlert hook entries from ~/.cursor/hooks.json.
|
|
70
|
+
* Leaves other hooks and the file intact.
|
|
71
|
+
* @returns {number} number of hook entries removed
|
|
72
|
+
*/
|
|
73
|
+
export function unregisterCursorHooks() {
|
|
74
|
+
try {
|
|
75
|
+
const config = loadHooks();
|
|
76
|
+
if (!config.hooks || typeof config.hooks !== "object") return 0;
|
|
77
|
+
|
|
78
|
+
let removed = 0;
|
|
79
|
+
for (const event of Object.keys(config.hooks)) {
|
|
80
|
+
const arr = config.hooks[event];
|
|
81
|
+
if (!Array.isArray(arr)) continue;
|
|
82
|
+
const before = arr.length;
|
|
83
|
+
config.hooks[event] = arr.filter((entry) => !isVoxlertHook(entry));
|
|
84
|
+
removed += before - config.hooks[event].length;
|
|
85
|
+
if (config.hooks[event].length === 0) delete config.hooks[event];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
writeFileSync(HOOKS_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
89
|
+
return removed;
|
|
90
|
+
} catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/formats.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared format definitions for LLM phrase generation.
|
|
3
|
+
*
|
|
4
|
+
* Format = structural rules (word count, grammar, what to include/omit)
|
|
5
|
+
* Style = character personality (tone, vocabulary, examples)
|
|
6
|
+
*
|
|
7
|
+
* buildSystemPrompt() composes them into a single system prompt.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_STYLE =
|
|
11
|
+
"You are a status report assistant.";
|
|
12
|
+
|
|
13
|
+
const FORMATS = {
|
|
14
|
+
"status-report": [
|
|
15
|
+
"Respond with ONLY 2-8 words as a brief status report.",
|
|
16
|
+
"The phrase MUST end with a past participle OR adjective (but not both).",
|
|
17
|
+
"Before the final word, state WHAT was done. If you can clearly infer WHY it exists — the purpose or goal — include it (e.g. 'item for purpose adjective'). If the purpose is not obvious, omit it and just describe the action.",
|
|
18
|
+
"Do NOT fabricate or guess a purpose. Only include 'for …' when the intent is clearly evident from context.",
|
|
19
|
+
"No punctuation. No quotes. No explanation.",
|
|
20
|
+
].join(" "),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Format examples array into prompt text.
|
|
25
|
+
* Each example: { content, format?, response }
|
|
26
|
+
*
|
|
27
|
+
* @param {Array|null} examples
|
|
28
|
+
* @param {string} formatId - only include examples matching this format
|
|
29
|
+
* @returns {string} Formatted examples block, or empty string
|
|
30
|
+
*/
|
|
31
|
+
function formatExamples(examples, formatId) {
|
|
32
|
+
if (!Array.isArray(examples) || examples.length === 0) return "";
|
|
33
|
+
const matching = examples.filter(
|
|
34
|
+
(ex) => ex.content && ex.response && (!ex.format || ex.format === formatId),
|
|
35
|
+
);
|
|
36
|
+
if (matching.length === 0) return "";
|
|
37
|
+
const lines = matching.map(
|
|
38
|
+
(ex) => `"${ex.content}" → ${ex.response}`,
|
|
39
|
+
);
|
|
40
|
+
return "\n\nExamples:\n" + lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compose a full system prompt from a style string, format id, and examples.
|
|
45
|
+
*
|
|
46
|
+
* @param {string|null} style - Character personality text (null → default neutral)
|
|
47
|
+
* @param {string} formatId - Format key (default: "status-report")
|
|
48
|
+
* @param {Array|null} examples - Few-shot examples from pack.json
|
|
49
|
+
* @returns {string} Complete system prompt
|
|
50
|
+
*/
|
|
51
|
+
export function buildSystemPrompt(style, formatId = "status-report", examples = null) {
|
|
52
|
+
const format = FORMATS[formatId] || FORMATS["status-report"];
|
|
53
|
+
const s = style || DEFAULT_STYLE;
|
|
54
|
+
return format + "\n\n" + s + formatExamples(examples, formatId);
|
|
55
|
+
}
|