@settinghead/voxlert 0.3.10 → 0.3.11
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/README.md +3 -6
- package/package.json +1 -8
- package/pi-package/extensions/voxlert.ts +86 -8
- package/src/commands/pack-helpers.js +9 -1
- package/src/prefix.js +266 -0
- package/src/upgrade-check.js +11 -27
- package/src/voxlert.js +28 -36
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
<a href="https://github.com/settinghead/voxlert/actions/workflows/cli-integration.yml">
|
|
9
9
|
<img src="https://github.com/settinghead/voxlert/actions/workflows/cli-integration.yml/badge.svg" alt="CLI Integration" />
|
|
10
10
|
</a>
|
|
11
|
+
<a href="https://fazier.com/launches/voxlert">
|
|
12
|
+
<img src="https://fazier.com/api/v1//public/badges/launch_badges.svg?badge_type=launched&theme=light" alt="Launched on Fazier" />
|
|
13
|
+
</a>
|
|
11
14
|
</p>
|
|
12
15
|
|
|
13
16
|
# Voxlert
|
|
@@ -113,12 +116,6 @@ Run tests locally with:
|
|
|
113
116
|
npm test
|
|
114
117
|
```
|
|
115
118
|
|
|
116
|
-
For release-impacting changes, add a changeset before opening a PR:
|
|
117
|
-
|
|
118
|
-
```bash
|
|
119
|
-
npm run changeset
|
|
120
|
-
```
|
|
121
|
-
|
|
122
119
|
## Supported Voices
|
|
123
120
|
|
|
124
121
|
The `sc1-adjutant` preview below uses the animated in-game portrait GIF from `assets/sc1-adjutant.gif`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@settinghead/voxlert",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"description": "LLM-generated voice notifications for Claude Code, Cursor, OpenAI Codex, pi, and OpenClaw, spoken by game characters like the StarCraft Adjutant, Kerrigan, C&C EVA, SHODAN, and more.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,9 +28,6 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"start": "node src/voxlert.js",
|
|
30
30
|
"postinstall": "node src/postinstall.js",
|
|
31
|
-
"changeset": "changeset",
|
|
32
|
-
"version": "changeset version",
|
|
33
|
-
"release": "changeset publish",
|
|
34
31
|
"test": "node --test ./test/*.test.js"
|
|
35
32
|
},
|
|
36
33
|
"publishConfig": {
|
|
@@ -43,9 +40,5 @@
|
|
|
43
40
|
"@inquirer/input": "^4.1.0",
|
|
44
41
|
"@inquirer/select": "^5.1.0",
|
|
45
42
|
"node-notifier": "^10.0.1"
|
|
46
|
-
},
|
|
47
|
-
"devDependencies": {
|
|
48
|
-
"@changesets/changelog-github": "^0.6.0",
|
|
49
|
-
"@changesets/cli": "^2.30.0"
|
|
50
43
|
}
|
|
51
44
|
}
|
|
@@ -95,15 +95,80 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
95
|
let available = isVoxlertAvailable();
|
|
96
96
|
|
|
97
97
|
// ------------------------------------------------------------------
|
|
98
|
-
//
|
|
98
|
+
// Guided setup: install CLI + run setup --yes
|
|
99
|
+
// ------------------------------------------------------------------
|
|
100
|
+
async function runGuidedSetup(ctx: any): Promise<boolean> {
|
|
101
|
+
// Step 1: Install the CLI
|
|
102
|
+
ctx.ui.notify("Installing @settinghead/voxlert...", "info");
|
|
103
|
+
const install = await pi.exec("npm", ["install", "-g", "@settinghead/voxlert"], {
|
|
104
|
+
timeout: 120_000,
|
|
105
|
+
});
|
|
106
|
+
if (install.code !== 0) {
|
|
107
|
+
ctx.ui.notify(
|
|
108
|
+
`Install failed (exit ${install.code}):\n${install.stderr.slice(0, 300)}`,
|
|
109
|
+
"error",
|
|
110
|
+
);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step 2: Run non-interactive setup (downloads voice packs, detects TTS)
|
|
115
|
+
ctx.ui.notify("Running voxlert setup (downloading voice packs, detecting TTS)...", "info");
|
|
116
|
+
const setup = await pi.exec("voxlert", ["setup", "--yes"], { timeout: 120_000 });
|
|
117
|
+
if (setup.code !== 0) {
|
|
118
|
+
ctx.ui.notify(
|
|
119
|
+
`Setup failed (exit ${setup.code}):\n${setup.stderr.slice(0, 300)}`,
|
|
120
|
+
"error",
|
|
121
|
+
);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Verify it worked
|
|
126
|
+
available = isVoxlertAvailable();
|
|
127
|
+
if (available) {
|
|
128
|
+
ctx.ui.notify(
|
|
129
|
+
"Voxlert installed and configured!\n\n" +
|
|
130
|
+
"Voice notifications will now play when the agent finishes a task.\n" +
|
|
131
|
+
"Run /voxlert test to hear it, or 'voxlert setup' in a terminal for full interactive config.",
|
|
132
|
+
"info",
|
|
133
|
+
);
|
|
134
|
+
ctx.ui.setStatus("voxlert", "🔊 Voxlert");
|
|
135
|
+
return true;
|
|
136
|
+
} else {
|
|
137
|
+
ctx.ui.notify("Install succeeded but voxlert binary not found in PATH.", "error");
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ------------------------------------------------------------------
|
|
143
|
+
// Session start: verify Voxlert is installed, offer setup if not
|
|
99
144
|
// ------------------------------------------------------------------
|
|
100
145
|
pi.on("session_start", async (_event, ctx) => {
|
|
101
146
|
available = isVoxlertAvailable();
|
|
102
147
|
if (!available) {
|
|
103
|
-
ctx.
|
|
104
|
-
|
|
105
|
-
|
|
148
|
+
if (!ctx.hasUI) {
|
|
149
|
+
// Non-interactive mode (print mode, JSON mode) — just warn
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const install = await ctx.ui.confirm(
|
|
154
|
+
"Voxlert Setup",
|
|
155
|
+
"Voxlert CLI not found. Install it now?\n\n" +
|
|
156
|
+
"This will:\n" +
|
|
157
|
+
" • npm install -g @settinghead/voxlert\n" +
|
|
158
|
+
" • Download default voice packs (SHODAN, Adjutant, etc.)\n" +
|
|
159
|
+
" • Auto-detect your TTS backend\n\n" +
|
|
160
|
+
"You can run full interactive setup later with: voxlert setup",
|
|
106
161
|
);
|
|
162
|
+
|
|
163
|
+
if (install) {
|
|
164
|
+
await runGuidedSetup(ctx);
|
|
165
|
+
} else {
|
|
166
|
+
ctx.ui.notify(
|
|
167
|
+
"Skipped. Run /voxlert setup anytime, or manually:\n" +
|
|
168
|
+
" npm install -g @settinghead/voxlert && voxlert setup",
|
|
169
|
+
"info",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
107
172
|
} else {
|
|
108
173
|
ctx.ui.setStatus("voxlert", "🔊 Voxlert");
|
|
109
174
|
}
|
|
@@ -168,9 +233,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
233
|
handler: async (args, ctx) => {
|
|
169
234
|
const sub = (args || "").trim().split(/\s+/)[0];
|
|
170
235
|
|
|
236
|
+
if (sub === "setup") {
|
|
237
|
+
if (available) {
|
|
238
|
+
const redo = await ctx.ui.confirm(
|
|
239
|
+
"Voxlert Setup",
|
|
240
|
+
"Voxlert is already installed. Re-run setup with defaults?",
|
|
241
|
+
);
|
|
242
|
+
if (!redo) return;
|
|
243
|
+
}
|
|
244
|
+
await runGuidedSetup(ctx);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
171
248
|
if (sub === "test") {
|
|
172
249
|
if (!available) {
|
|
173
|
-
ctx.ui.notify("Voxlert CLI not installed.", "error");
|
|
250
|
+
ctx.ui.notify("Voxlert CLI not installed. Run /voxlert setup first.", "error");
|
|
174
251
|
return;
|
|
175
252
|
}
|
|
176
253
|
fireVoxlert("Stop", ctx.cwd);
|
|
@@ -182,7 +259,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
182
259
|
ctx.ui.notify(
|
|
183
260
|
available
|
|
184
261
|
? "Voxlert is active. Voice notifications will play on agent_end, tool errors, compaction, and session end."
|
|
185
|
-
: "Voxlert CLI not found. Run
|
|
262
|
+
: "Voxlert CLI not found. Run /voxlert setup to install.",
|
|
186
263
|
available ? "info" : "warning",
|
|
187
264
|
);
|
|
188
265
|
return;
|
|
@@ -190,10 +267,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
190
267
|
|
|
191
268
|
// Default: show help
|
|
192
269
|
ctx.ui.notify(
|
|
193
|
-
"Usage: /voxlert [test|status]\n" +
|
|
270
|
+
"Usage: /voxlert [setup|test|status]\n" +
|
|
271
|
+
" setup — install Voxlert CLI and configure with defaults\n" +
|
|
194
272
|
" test — fire a test voice notification\n" +
|
|
195
273
|
" status — check if Voxlert CLI is available\n" +
|
|
196
|
-
"\
|
|
274
|
+
"\nFor full interactive config, run in terminal: voxlert setup",
|
|
197
275
|
"info",
|
|
198
276
|
);
|
|
199
277
|
},
|
|
@@ -5,6 +5,7 @@ import { showOverlay } from "../overlay.js";
|
|
|
5
5
|
import { listPacks, loadPack } from "../packs.js";
|
|
6
6
|
import { generatePhrase } from "../llm.js";
|
|
7
7
|
import { speakPhrase } from "../audio.js";
|
|
8
|
+
import { resolvePrefix, DEFAULT_PREFIX } from "../prefix.js";
|
|
8
9
|
|
|
9
10
|
export async function testPipeline(text, pack) {
|
|
10
11
|
if (!text) {
|
|
@@ -33,12 +34,19 @@ export async function testPipeline(text, pack) {
|
|
|
33
34
|
phrase = text;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// Resolve prefix from config, same as the hook path
|
|
38
|
+
const prefixTemplate = config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX;
|
|
39
|
+
const resolvedPrefix = resolvePrefix(prefixTemplate, process.cwd());
|
|
40
|
+
if (resolvedPrefix) {
|
|
41
|
+
phrase = `${resolvedPrefix}; ${phrase}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
console.log("Sending to TTS...");
|
|
37
45
|
showOverlay(phrase, {
|
|
38
46
|
category: "notification",
|
|
39
47
|
packName: activePack.name,
|
|
40
48
|
packId: activePack.id || (config.active_pack || "sc1-kerrigan-infested"),
|
|
41
|
-
prefix:
|
|
49
|
+
prefix: resolvedPrefix,
|
|
42
50
|
config,
|
|
43
51
|
overlayColors: activePack.overlay_colors,
|
|
44
52
|
});
|
package/src/prefix.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix resolver — composable template variables for announcement prefixes.
|
|
3
|
+
*
|
|
4
|
+
* Template syntax:
|
|
5
|
+
* ${dirname} — basename of cwd
|
|
6
|
+
* ${project} — project name from manifest files (package.json, pyproject.toml, etc.)
|
|
7
|
+
* ${project|dirname} — pipe means "try left, fall back to right"
|
|
8
|
+
*
|
|
9
|
+
* Literal text is preserved as-is: "Project ${project|dirname}" → "Project my-app"
|
|
10
|
+
* Unknown variables and all-null chains resolve to empty string.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { basename, join } from "path";
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
15
|
+
|
|
16
|
+
/** Default prefix template — project name with dirname fallback. */
|
|
17
|
+
export const DEFAULT_PREFIX = "${project|dirname}";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Resolvers — each is (cwd: string) => string | null
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const RESOLVERS = {
|
|
24
|
+
dirname: (cwd) => (cwd ? basename(cwd) : null),
|
|
25
|
+
project: readProjectName,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Project manifest readers, tried in priority order.
|
|
30
|
+
// Each entry: [filename, extractorFn(contents) => string|null]
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** @type {Array<[string, (content: string) => string|null]>} */
|
|
34
|
+
const MANIFEST_READERS = [
|
|
35
|
+
["package.json", extractPackageName],
|
|
36
|
+
["pyproject.toml", extractPyprojectName],
|
|
37
|
+
["Cargo.toml", extractTomlSectionField("package", "name")],
|
|
38
|
+
["setup.cfg", extractIniSectionField("metadata", "name")],
|
|
39
|
+
["go.mod", extractGoModule],
|
|
40
|
+
["composer.json", extractComposerName],
|
|
41
|
+
["pubspec.yaml", extractYamlScalar("name")],
|
|
42
|
+
// gemspec handled separately — requires glob
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Public API
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a prefix template string against the given working directory.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} template - e.g. "${project|dirname}" or "my-app"
|
|
53
|
+
* @param {string} cwd - Absolute path to the project directory
|
|
54
|
+
* @returns {string} Resolved prefix (may be empty)
|
|
55
|
+
*/
|
|
56
|
+
export function resolvePrefix(template, cwd) {
|
|
57
|
+
if (!template) return "";
|
|
58
|
+
return template.replace(/\$\{([^}]+)\}/g, (_match, expr) => {
|
|
59
|
+
const candidates = expr.split("|");
|
|
60
|
+
for (const name of candidates) {
|
|
61
|
+
const resolver = RESOLVERS[name.trim()];
|
|
62
|
+
if (!resolver) continue;
|
|
63
|
+
const value = resolver(cwd);
|
|
64
|
+
if (value) return value;
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// readProjectName — walks manifests in priority order
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the project name from the first recognized manifest file in cwd.
|
|
76
|
+
* Returns null if no manifest is found or none yields a name.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} cwd
|
|
79
|
+
* @returns {string|null}
|
|
80
|
+
*/
|
|
81
|
+
function readProjectName(cwd) {
|
|
82
|
+
if (!cwd) return null;
|
|
83
|
+
|
|
84
|
+
// Fixed-name manifests
|
|
85
|
+
for (const [filename, extractor] of MANIFEST_READERS) {
|
|
86
|
+
const filepath = join(cwd, filename);
|
|
87
|
+
const content = readFileSafe(filepath);
|
|
88
|
+
if (content === null) continue;
|
|
89
|
+
const name = extractor(content);
|
|
90
|
+
if (name) return name;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// *.gemspec — need a directory listing
|
|
94
|
+
try {
|
|
95
|
+
const entries = readdirSync(cwd);
|
|
96
|
+
const gemspec = entries.find((e) => e.endsWith(".gemspec"));
|
|
97
|
+
if (gemspec) {
|
|
98
|
+
const content = readFileSafe(join(cwd, gemspec));
|
|
99
|
+
if (content) {
|
|
100
|
+
const name = extractGemspecName(content);
|
|
101
|
+
if (name) return name;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// directory unreadable — skip
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Extractors
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** JSON field extractor factory. */
|
|
116
|
+
function extractJsonField(field) {
|
|
117
|
+
return (content) => {
|
|
118
|
+
try {
|
|
119
|
+
const obj = JSON.parse(content);
|
|
120
|
+
const val = obj?.[field];
|
|
121
|
+
return typeof val === "string" && val.trim() ? val.trim() : null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** package.json: strip npm scope (@scope/name → name). */
|
|
129
|
+
function extractPackageName(content) {
|
|
130
|
+
const name = extractJsonField("name")(content);
|
|
131
|
+
if (!name) return null;
|
|
132
|
+
// Strip @scope/ prefix for TTS-friendly output
|
|
133
|
+
const slashIdx = name.indexOf("/");
|
|
134
|
+
return slashIdx >= 0 && name.startsWith("@") ? name.slice(slashIdx + 1) : name;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** composer.json: name is "vendor/package" — return the package segment. */
|
|
138
|
+
function extractComposerName(content) {
|
|
139
|
+
try {
|
|
140
|
+
const obj = JSON.parse(content);
|
|
141
|
+
const val = obj?.name;
|
|
142
|
+
if (typeof val !== "string" || !val.trim()) return null;
|
|
143
|
+
const parts = val.split("/");
|
|
144
|
+
return parts[parts.length - 1].trim() || null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* pyproject.toml: check [project].name then [tool.poetry].name.
|
|
152
|
+
* Lightweight line-based extraction — no full TOML parser.
|
|
153
|
+
*/
|
|
154
|
+
function extractPyprojectName(content) {
|
|
155
|
+
return (
|
|
156
|
+
extractTomlSectionField("project", "name")(content) ||
|
|
157
|
+
extractTomlNestedField("tool.poetry", "name")(content)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Factory: extract `key = "value"` under a [section] header in TOML.
|
|
163
|
+
* Handles both `key = "value"` and `key = 'value'`.
|
|
164
|
+
*/
|
|
165
|
+
function extractTomlSectionField(section, key) {
|
|
166
|
+
// Match [section] exactly (no dots, no sub-tables)
|
|
167
|
+
const headerRe = new RegExp(`^\\[${escapeRegex(section)}\\]\\s*$`);
|
|
168
|
+
const fieldRe = new RegExp(`^${escapeRegex(key)}\\s*=\\s*["'](.+?)["']`);
|
|
169
|
+
return (content) => extractFromSections(content, headerRe, fieldRe);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Factory: extract `key = "value"` under a [dotted.section] header in TOML.
|
|
174
|
+
* e.g. [tool.poetry] → key = "value"
|
|
175
|
+
*/
|
|
176
|
+
function extractTomlNestedField(dottedSection, key) {
|
|
177
|
+
const headerRe = new RegExp(`^\\[${escapeRegex(dottedSection)}\\]\\s*$`);
|
|
178
|
+
const fieldRe = new RegExp(`^${escapeRegex(key)}\\s*=\\s*["'](.+?)["']`);
|
|
179
|
+
return (content) => extractFromSections(content, headerRe, fieldRe);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Shared: scan lines for a section header, then find a field before the next header. */
|
|
183
|
+
function extractFromSections(content, headerRe, fieldRe) {
|
|
184
|
+
const lines = content.split("\n");
|
|
185
|
+
let inSection = false;
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const trimmed = line.trim();
|
|
188
|
+
if (trimmed.startsWith("[")) {
|
|
189
|
+
inSection = headerRe.test(trimmed);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (inSection) {
|
|
193
|
+
const m = fieldRe.exec(trimmed);
|
|
194
|
+
if (m) return m[1].trim() || null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** setup.cfg: INI-style [metadata] section, name = value (no quotes). */
|
|
201
|
+
function extractIniSectionField(section, key) {
|
|
202
|
+
const headerRe = new RegExp(`^\\[${escapeRegex(section)}\\]\\s*$`, "i");
|
|
203
|
+
const fieldRe = new RegExp(`^${escapeRegex(key)}\\s*=\\s*(.+)`, "i");
|
|
204
|
+
return (content) => {
|
|
205
|
+
const lines = content.split("\n");
|
|
206
|
+
let inSection = false;
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (trimmed.startsWith("[")) {
|
|
210
|
+
inSection = headerRe.test(trimmed);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (inSection) {
|
|
214
|
+
const m = fieldRe.exec(trimmed);
|
|
215
|
+
if (m) return m[1].trim() || null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** go.mod: `module github.com/user/repo` → "repo" */
|
|
223
|
+
function extractGoModule(content) {
|
|
224
|
+
const m = /^module\s+(\S+)/m.exec(content);
|
|
225
|
+
if (!m) return null;
|
|
226
|
+
const parts = m[1].split("/");
|
|
227
|
+
return parts[parts.length - 1].trim() || null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** *.gemspec: first `spec.name = "value"` or `.name = "value"` */
|
|
231
|
+
function extractGemspecName(content) {
|
|
232
|
+
const m = /\.name\s*=\s*["'](.+?)["']/.exec(content);
|
|
233
|
+
return m ? m[1].trim() || null : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** YAML top-level scalar: `name: value` */
|
|
237
|
+
function extractYamlScalar(key) {
|
|
238
|
+
const re = new RegExp(`^${escapeRegex(key)}:\\s*(.+)`, "m");
|
|
239
|
+
return (content) => {
|
|
240
|
+
const m = re.exec(content);
|
|
241
|
+
if (!m) return null;
|
|
242
|
+
// Strip surrounding quotes if present
|
|
243
|
+
let val = m[1].trim();
|
|
244
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
245
|
+
val = val.slice(1, -1);
|
|
246
|
+
}
|
|
247
|
+
return val || null;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Helpers
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function readFileSafe(filepath) {
|
|
256
|
+
try {
|
|
257
|
+
if (!existsSync(filepath)) return null;
|
|
258
|
+
return readFileSync(filepath, "utf-8");
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function escapeRegex(s) {
|
|
265
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
266
|
+
}
|
package/src/upgrade-check.js
CHANGED
|
@@ -91,13 +91,9 @@ const ansi = {
|
|
|
91
91
|
grey: "\x1b[90m",
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
-
function stripAnsi(str) {
|
|
95
|
-
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
94
|
/**
|
|
99
|
-
* Print upgrade notification to stdout, styled
|
|
100
|
-
*
|
|
95
|
+
* Print upgrade notification to stdout, styled with horizontal separator lines:
|
|
96
|
+
* yellow lines above and below, bold header, version info, install command, changelog link.
|
|
101
97
|
* Only uses colors when stdout is TTY.
|
|
102
98
|
*/
|
|
103
99
|
export function printUpgradeNotification(info, options = {}) {
|
|
@@ -110,28 +106,16 @@ export function printUpgradeNotification(info, options = {}) {
|
|
|
110
106
|
releaseNotesUrl ||
|
|
111
107
|
`https://github.com/settinghead/voxlert/releases/latest`;
|
|
112
108
|
|
|
113
|
-
const
|
|
114
|
-
const line2 = `Run ${installCmd} to update.`;
|
|
115
|
-
const line3 = "See full release notes:";
|
|
116
|
-
const line4 = `${c.cyan}${url}${c.reset}`;
|
|
117
|
-
|
|
118
|
-
const padding = 2;
|
|
119
|
-
const maxLen = Math.max(
|
|
120
|
-
stripAnsi(line1).length,
|
|
121
|
-
line2.length,
|
|
122
|
-
line3.length,
|
|
123
|
-
url.length
|
|
124
|
-
);
|
|
125
|
-
const width = maxLen + padding * 2;
|
|
126
|
-
const border = "─".repeat(width);
|
|
127
|
-
const pad = (s) => " ".repeat(Math.max(0, width - 2 - stripAnsi(s).length));
|
|
109
|
+
const rule = `${c.yellow}${"─".repeat(60)}${c.reset}`;
|
|
128
110
|
|
|
129
111
|
console.log("");
|
|
130
|
-
console.log(
|
|
131
|
-
console.log(
|
|
132
|
-
console.log(
|
|
133
|
-
console.log(
|
|
134
|
-
console.log(
|
|
135
|
-
console.log(
|
|
112
|
+
console.log(rule);
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(` ${c.bold}${c.yellow}Update Available${c.reset}`);
|
|
115
|
+
console.log(` New version ${c.bold}${info.latest}${c.reset} is available. Run: ${c.cyan}${installCmd}${c.reset}`);
|
|
116
|
+
console.log(` Changelog:`);
|
|
117
|
+
console.log(` ${c.cyan}${url}${c.reset}`);
|
|
118
|
+
console.log("");
|
|
119
|
+
console.log(rule);
|
|
136
120
|
console.log("");
|
|
137
121
|
}
|
package/src/voxlert.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* speaks them through a local Chatterbox TTS server.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { resolvePrefix, DEFAULT_PREFIX } from "./prefix.js";
|
|
10
10
|
import { appendFileSync, mkdirSync } from "fs";
|
|
11
11
|
import { loadConfig, EVENT_MAP, CONTEXTUAL_EVENTS, FALLBACK_PHRASES } from "./config.js";
|
|
12
12
|
import { extractContext, generatePhrase } from "./llm.js";
|
|
@@ -58,7 +58,7 @@ export async function processHookEvent(eventData) {
|
|
|
58
58
|
llm_backend: config.llm_backend || "",
|
|
59
59
|
tts_backend: config.tts_backend || "",
|
|
60
60
|
overlay: config.overlay === true,
|
|
61
|
-
prefix: config.prefix !== undefined ? config.prefix :
|
|
61
|
+
prefix: config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX,
|
|
62
62
|
task_complete_enabled: config.categories?.["task.complete"] !== false,
|
|
63
63
|
task_error_enabled: config.categories?.["task.error"] !== false,
|
|
64
64
|
});
|
|
@@ -91,38 +91,33 @@ export async function processHookEvent(eventData) {
|
|
|
91
91
|
debugLog("processHookEvent processing", { source, eventName, category });
|
|
92
92
|
// Load active voice pack
|
|
93
93
|
const pack = loadPack(config);
|
|
94
|
-
const projectName = cwd ? basename(cwd) : "";
|
|
95
|
-
|
|
96
94
|
// Allow callers to bypass LLM generation entirely with a pre-built phrase
|
|
97
95
|
if (eventData.phrase_override && typeof eventData.phrase_override === "string") {
|
|
98
96
|
const overridePhrase = eventData.phrase_override.trim();
|
|
99
97
|
if (overridePhrase) {
|
|
100
98
|
debugLog("processHookEvent using phrase_override", { source, phrase: overridePhrase.slice(0, 120) });
|
|
101
99
|
|
|
102
|
-
//
|
|
103
|
-
const prefixTemplate = config.prefix !== undefined ? config.prefix :
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
debugLog("processHookEvent done (phrase_override)", { source });
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
100
|
+
// Resolve prefix
|
|
101
|
+
const prefixTemplate = config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX;
|
|
102
|
+
const resolvedPrefix = resolvePrefix(prefixTemplate, cwd);
|
|
103
|
+
if (resolvedPrefix) {
|
|
104
|
+
const finalPhrase = `${resolvedPrefix}; ${overridePhrase}`;
|
|
105
|
+
const packId = config.active_pack || "sc1-kerrigan-infested";
|
|
106
|
+
appendLog(
|
|
107
|
+
`[${new Date().toISOString()}] source=${source} event=${eventName} category=${category} phrase=${finalPhrase.replace(/\s+/g, " ").slice(0, 120)}`,
|
|
108
|
+
config,
|
|
109
|
+
);
|
|
110
|
+
showOverlay(finalPhrase, {
|
|
111
|
+
category,
|
|
112
|
+
packName: pack.name,
|
|
113
|
+
packId: pack.id || packId,
|
|
114
|
+
prefix: resolvedPrefix,
|
|
115
|
+
config,
|
|
116
|
+
overlayColors: pack.overlay_colors,
|
|
117
|
+
});
|
|
118
|
+
await speakPhrase(finalPhrase, config, pack);
|
|
119
|
+
debugLog("processHookEvent done (phrase_override)", { source });
|
|
120
|
+
return;
|
|
126
121
|
}
|
|
127
122
|
|
|
128
123
|
const packId = config.active_pack || "sc1-kerrigan-infested";
|
|
@@ -196,14 +191,11 @@ export async function processHookEvent(eventData) {
|
|
|
196
191
|
phrase = phrases[Math.floor(Math.random() * phrases.length)];
|
|
197
192
|
}
|
|
198
193
|
|
|
199
|
-
//
|
|
200
|
-
const prefixTemplate = config.prefix !== undefined ? config.prefix :
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
if (resolvedPrefix) {
|
|
205
|
-
phrase = `${resolvedPrefix}; ${phrase}`;
|
|
206
|
-
}
|
|
194
|
+
// Resolve prefix (supports ${project|dirname} template variables)
|
|
195
|
+
const prefixTemplate = config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX;
|
|
196
|
+
const resolvedPrefix = resolvePrefix(prefixTemplate, cwd);
|
|
197
|
+
if (resolvedPrefix) {
|
|
198
|
+
phrase = `${resolvedPrefix}; ${phrase}`;
|
|
207
199
|
}
|
|
208
200
|
|
|
209
201
|
const packId = config.active_pack || "sc1-kerrigan-infested";
|