@agentprojectcontext/apx 1.74.0 → 1.74.2
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/package.json +3 -2
- package/src/core/desktop/autostart.js +31 -14
- package/src/core/mcp/runner.js +6 -1
- package/src/core/stores/routines.js +52 -0
- package/src/core/util/index.js +1 -0
- package/src/core/util/path-env.js +73 -0
- package/src/core/voice/transcription.js +28 -3
- package/src/host/daemon/whisper-server.js +27 -0
- package/src/interfaces/web/dist/assets/index-CQTIGYCu.js +819 -0
- package/src/interfaces/web/dist/assets/index-CQTIGYCu.js.map +1 -0
- package/src/interfaces/web/dist/index.html +1 -1
- package/src/interfaces/web/package-lock.json +229 -203
- package/src/interfaces/web/dist/assets/index-BxYXJEtf.js +0 -819
- package/src/interfaces/web/dist/assets/index-BxYXJEtf.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentprojectcontext/apx",
|
|
3
|
-
"version": "1.74.
|
|
3
|
+
"version": "1.74.2",
|
|
4
4
|
"description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"react": "^19.2.0",
|
|
68
68
|
"remeda": "^2.21.0",
|
|
69
69
|
"safer-buffer": "^2.1.2",
|
|
70
|
-
"solid-js": "^1.9.13"
|
|
70
|
+
"solid-js": "^1.9.13",
|
|
71
|
+
"ws": "^8.21.3"
|
|
71
72
|
},
|
|
72
73
|
"optionalDependencies": {
|
|
73
74
|
"better-sqlite3": "^11.3.0",
|
|
@@ -18,6 +18,7 @@ import os from "node:os";
|
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { augmentedPath } from "#core/util/path-env.js";
|
|
21
22
|
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
24
|
const __dirname = path.dirname(__filename);
|
|
@@ -55,6 +56,12 @@ function escapeXml(s) {
|
|
|
55
56
|
export function buildPlist(runner, logFile) {
|
|
56
57
|
const args = [...runner, "desktop", "start"];
|
|
57
58
|
const argsXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
59
|
+
// Absolute ProgramArguments (see getApxRunner) only save the *first* hop.
|
|
60
|
+
// Everything the daemon spawns below itself — ffmpeg for whisper, npx/node
|
|
61
|
+
// for stdio MCPs — still resolves through PATH, and launchd's is
|
|
62
|
+
// /usr/bin:/bin:/usr/sbin:/sbin. Baking the installing shell's augmented
|
|
63
|
+
// PATH in makes the whole tree behave the same at login as in a terminal.
|
|
64
|
+
const pathEnv = augmentedPath();
|
|
58
65
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
66
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
60
67
|
<plist version="1.0">
|
|
@@ -64,6 +71,10 @@ export function buildPlist(runner, logFile) {
|
|
|
64
71
|
<array>
|
|
65
72
|
${argsXml}
|
|
66
73
|
</array>
|
|
74
|
+
<key>EnvironmentVariables</key>
|
|
75
|
+
<dict>
|
|
76
|
+
<key>PATH</key><string>${escapeXml(pathEnv)}</string>
|
|
77
|
+
</dict>
|
|
67
78
|
<key>RunAtLoad</key><true/>
|
|
68
79
|
<key>KeepAlive</key><false/>
|
|
69
80
|
<key>ProcessType</key><string>Interactive</string>
|
|
@@ -76,12 +87,18 @@ ${argsXml}
|
|
|
76
87
|
|
|
77
88
|
// ── public API ───────────────────────────────────────────────────────────
|
|
78
89
|
|
|
90
|
+
// The three functions below take `platform` instead of reading process.platform
|
|
91
|
+
// inline so the unsupported-platform branch is reachable from a test on any
|
|
92
|
+
// machine — it's the only branch with no filesystem/registry side effect, and
|
|
93
|
+
// hard-coding process.platform meant it could only ever be exercised on a
|
|
94
|
+
// freebsd/sunos box. Callers always omit it.
|
|
95
|
+
|
|
79
96
|
/** Boolean: is the autostart entry currently registered on this platform? */
|
|
80
|
-
export function autostartIsOn() {
|
|
97
|
+
export function autostartIsOn(platform = process.platform) {
|
|
81
98
|
try {
|
|
82
|
-
if (
|
|
83
|
-
if (
|
|
84
|
-
if (
|
|
99
|
+
if (platform === "darwin") return fs.existsSync(MAC_PLIST_PATH);
|
|
100
|
+
if (platform === "linux") return fs.existsSync(LINUX_DESKTOP_PATH);
|
|
101
|
+
if (platform === "win32") {
|
|
85
102
|
const out = execFileSync("reg", ["query", WIN_RUN_KEY, "/v", WIN_RUN_NAME], {
|
|
86
103
|
stdio: ["ignore", "pipe", "ignore"],
|
|
87
104
|
}).toString();
|
|
@@ -95,12 +112,12 @@ export function autostartIsOn() {
|
|
|
95
112
|
* Enable autostart. Idempotent — running twice is safe.
|
|
96
113
|
* @returns {{ ok: boolean, message?: string, error?: string, runs?: string, path?: string }}
|
|
97
114
|
*/
|
|
98
|
-
export function autostartInstall() {
|
|
115
|
+
export function autostartInstall(platform = process.platform) {
|
|
99
116
|
const runner = getApxRunner();
|
|
100
117
|
const sh = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
|
|
101
118
|
const cmdline = [...runner, "desktop", "start"].map(sh).join(" ");
|
|
102
119
|
|
|
103
|
-
if (
|
|
120
|
+
if (platform === "darwin") {
|
|
104
121
|
try {
|
|
105
122
|
fs.mkdirSync(path.dirname(MAC_PLIST_PATH), { recursive: true });
|
|
106
123
|
fs.mkdirSync(path.dirname(AUTOSTART_LOG_PATH), { recursive: true });
|
|
@@ -110,7 +127,7 @@ export function autostartInstall() {
|
|
|
110
127
|
return { ok: true, runs: cmdline, path: MAC_PLIST_PATH };
|
|
111
128
|
} catch (e) { return { ok: false, error: e.message }; }
|
|
112
129
|
}
|
|
113
|
-
if (
|
|
130
|
+
if (platform === "win32") {
|
|
114
131
|
try {
|
|
115
132
|
execFileSync("reg", [
|
|
116
133
|
"add", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/t", "REG_SZ", "/d", cmdline, "/f",
|
|
@@ -118,7 +135,7 @@ export function autostartInstall() {
|
|
|
118
135
|
return { ok: true, runs: cmdline, path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}` };
|
|
119
136
|
} catch (e) { return { ok: false, error: e.message }; }
|
|
120
137
|
}
|
|
121
|
-
if (
|
|
138
|
+
if (platform === "linux") {
|
|
122
139
|
try {
|
|
123
140
|
fs.mkdirSync(path.dirname(LINUX_DESKTOP_PATH), { recursive: true });
|
|
124
141
|
fs.writeFileSync(LINUX_DESKTOP_PATH,
|
|
@@ -127,15 +144,15 @@ export function autostartInstall() {
|
|
|
127
144
|
return { ok: true, runs: cmdline, path: LINUX_DESKTOP_PATH };
|
|
128
145
|
} catch (e) { return { ok: false, error: e.message }; }
|
|
129
146
|
}
|
|
130
|
-
return { ok: false, error: `autostart not supported on platform: ${
|
|
147
|
+
return { ok: false, error: `autostart not supported on platform: ${platform}` };
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
/**
|
|
134
151
|
* Disable autostart. Idempotent — no-op if not installed.
|
|
135
152
|
* @returns {{ ok: boolean, removed?: boolean, path?: string, error?: string }}
|
|
136
153
|
*/
|
|
137
|
-
export function autostartUninstall() {
|
|
138
|
-
if (
|
|
154
|
+
export function autostartUninstall(platform = process.platform) {
|
|
155
|
+
if (platform === "darwin") {
|
|
139
156
|
if (!fs.existsSync(MAC_PLIST_PATH)) return { ok: true, removed: false };
|
|
140
157
|
try {
|
|
141
158
|
try { execFileSync("launchctl", ["unload", "-w", MAC_PLIST_PATH], { stdio: "ignore" }); } catch {}
|
|
@@ -143,7 +160,7 @@ export function autostartUninstall() {
|
|
|
143
160
|
return { ok: true, removed: true, path: MAC_PLIST_PATH };
|
|
144
161
|
} catch (e) { return { ok: false, error: e.message }; }
|
|
145
162
|
}
|
|
146
|
-
if (
|
|
163
|
+
if (platform === "win32") {
|
|
147
164
|
try {
|
|
148
165
|
execFileSync("reg", ["delete", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/f"], { stdio: "ignore" });
|
|
149
166
|
return { ok: true, removed: true, path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}` };
|
|
@@ -151,12 +168,12 @@ export function autostartUninstall() {
|
|
|
151
168
|
return { ok: true, removed: false };
|
|
152
169
|
}
|
|
153
170
|
}
|
|
154
|
-
if (
|
|
171
|
+
if (platform === "linux") {
|
|
155
172
|
if (!fs.existsSync(LINUX_DESKTOP_PATH)) return { ok: true, removed: false };
|
|
156
173
|
try {
|
|
157
174
|
fs.unlinkSync(LINUX_DESKTOP_PATH);
|
|
158
175
|
return { ok: true, removed: true, path: LINUX_DESKTOP_PATH };
|
|
159
176
|
} catch (e) { return { ok: false, error: e.message }; }
|
|
160
177
|
}
|
|
161
|
-
return { ok: false, error: `autostart not supported on platform: ${
|
|
178
|
+
return { ok: false, error: `autostart not supported on platform: ${platform}` };
|
|
162
179
|
}
|
package/src/core/mcp/runner.js
CHANGED
|
@@ -9,6 +9,7 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
import { loadAll } from "./sources.js";
|
|
10
10
|
import { interpolate, MissingVarError } from "#core/vars/interpolate.js";
|
|
11
11
|
import { loadAllVars } from "#core/vars/sources.js";
|
|
12
|
+
import { envWithPath } from "#core/util/path-env.js";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
14
15
|
const LOG_CAP = 64; // entries per MCP we keep in memory
|
|
@@ -59,8 +60,12 @@ class McpProcess {
|
|
|
59
60
|
start() {
|
|
60
61
|
if (this.proc) return;
|
|
61
62
|
this._log("info", `spawn ${this.command} ${(this.args || []).join(" ")}`);
|
|
63
|
+
// envWithPath, not a bare process.env spread: most stdio MCPs are launched
|
|
64
|
+
// via `npx`/`node`, which live in the nvm/pnpm bin dir. Booted from launchd
|
|
65
|
+
// the daemon only has /usr/bin:/bin:/usr/sbin:/sbin and every one of them
|
|
66
|
+
// fails with "spawn npx ENOENT".
|
|
62
67
|
this.proc = spawn(this.command, this.args, {
|
|
63
|
-
env:
|
|
68
|
+
env: envWithPath(this.env),
|
|
64
69
|
stdio: ["pipe", "pipe", "pipe"],
|
|
65
70
|
});
|
|
66
71
|
this.startedAt = nowIso();
|
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { CronExpressionParser } from "cron-parser";
|
|
6
6
|
import { nowIso, isoToMs } from "../util/time.js";
|
|
7
|
+
import { shortId } from "../util/ids.js";
|
|
7
8
|
|
|
8
9
|
function routinesPath(storagePath) {
|
|
9
10
|
// storagePath is always ~/.apx/projects/{apxId}/ — flat, no .apc subdir needed.
|
|
@@ -82,13 +83,58 @@ export function computeNextRun(routine, baseMs = Date.now()) {
|
|
|
82
83
|
return null;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// --------------------- ids + migration --------------------------------------
|
|
87
|
+
|
|
88
|
+
// Routines are addressed by `name` everywhere (getRoutine/deleteRoutine/
|
|
89
|
+
// setEnabled/updateRunState), but per-routine memory is keyed by `id`
|
|
90
|
+
// (stores/routine-memory.js). Records written before ids existed have none, so
|
|
91
|
+
// every one of them resolved to the shared `routines/_unknown/memory.md`.
|
|
92
|
+
//
|
|
93
|
+
// This migration is deliberately NOT inside readFile(): that helper is on the
|
|
94
|
+
// scheduler's 5s polling path, and writing from it would mean write
|
|
95
|
+
// amplification plus a read-modify-write race against a concurrent CLI edit.
|
|
96
|
+
// Instead the public read entry points call this, and it early-returns without
|
|
97
|
+
// touching disk once every record has an id — so the write happens once per
|
|
98
|
+
// project, ever.
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Backfill `id` on any routine record that predates the field.
|
|
102
|
+
* Returns the number of records migrated (0 when there was nothing to do).
|
|
103
|
+
*/
|
|
104
|
+
export function ensureRoutineIds(storagePath) {
|
|
105
|
+
const routines = readFile(storagePath);
|
|
106
|
+
const missing = routines.filter((r) => r && !r.id);
|
|
107
|
+
if (missing.length === 0) return 0;
|
|
108
|
+
|
|
109
|
+
for (const r of missing) r.id = shortId("r");
|
|
110
|
+
writeFile(storagePath, routines);
|
|
111
|
+
|
|
112
|
+
// Anything already written under routines/_unknown/ belonged to an
|
|
113
|
+
// indeterminate set of routines — we can't know which, so we leave it where
|
|
114
|
+
// it is rather than guess, and say so out loud once.
|
|
115
|
+
const orphan = path.join(storagePath, "routines", "_unknown");
|
|
116
|
+
if (fs.existsSync(orphan)) {
|
|
117
|
+
// eslint-disable-next-line no-console
|
|
118
|
+
console.warn(
|
|
119
|
+
`[apx] routines: assigned ids to ${missing.length} routine(s). Pre-existing shared\n` +
|
|
120
|
+
` memory at ${orphan} was left untouched — it cannot be attributed to a single\n` +
|
|
121
|
+
` routine. Copy anything worth keeping into the per-routine memory files.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return missing.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
85
127
|
// --------------------- CRUD -------------------------------------------------
|
|
86
128
|
|
|
87
129
|
export function listRoutines(projectPath) {
|
|
130
|
+
ensureRoutineIds(projectPath);
|
|
88
131
|
return readFile(projectPath);
|
|
89
132
|
}
|
|
90
133
|
|
|
91
134
|
export function getRoutine(projectPath, name) {
|
|
135
|
+
// Callers hand the record straight to the runner, which needs `id` for
|
|
136
|
+
// per-routine memory.
|
|
137
|
+
ensureRoutineIds(projectPath);
|
|
92
138
|
return readFile(projectPath).find((r) => r.name === name) || null;
|
|
93
139
|
}
|
|
94
140
|
|
|
@@ -100,6 +146,10 @@ export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled
|
|
|
100
146
|
const prev = idx >= 0 ? routines[idx] : null;
|
|
101
147
|
const next = computeNextRun({ schedule, last_run_at: null });
|
|
102
148
|
const entry = {
|
|
149
|
+
// `entry` is rebuilt from scratch on every upsert, so the id MUST be
|
|
150
|
+
// carried over explicitly (same as created_at below). Dropping it here
|
|
151
|
+
// would re-id the routine on every edit and orphan its memory directory.
|
|
152
|
+
id: prev?.id || shortId("r"),
|
|
103
153
|
name,
|
|
104
154
|
kind,
|
|
105
155
|
schedule,
|
|
@@ -167,6 +217,8 @@ export function updateRunState(projectPath, name, { last_run_at, last_status, la
|
|
|
167
217
|
}
|
|
168
218
|
|
|
169
219
|
export function getDueRoutines(projectPath, nowStr) {
|
|
220
|
+
// The runner keys per-routine memory off `id`, so due records must carry one.
|
|
221
|
+
ensureRoutineIds(projectPath);
|
|
170
222
|
return readFile(projectPath).filter((r) => {
|
|
171
223
|
if (!r.enabled) return false;
|
|
172
224
|
// CRITICAL: If the schedule cannot be parsed, NEVER run it.
|
package/src/core/util/index.js
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// PATH repair for processes APX spawns.
|
|
2
|
+
//
|
|
3
|
+
// When the daemon is booted from a GUI context — the launchd agent
|
|
4
|
+
// (dev.apx.desktop), a .app bundle, an IDE — it inherits launchd's minimal
|
|
5
|
+
// PATH: /usr/bin:/bin:/usr/sbin:/sbin. No Homebrew, no nvm, no pnpm. Every
|
|
6
|
+
// child we spawn then dies with ENOENT on binaries that work fine in a
|
|
7
|
+
// terminal:
|
|
8
|
+
//
|
|
9
|
+
// - whisper-server.py → "No such file or directory: 'ffmpeg'" (mlx/faster
|
|
10
|
+
// whisper shell out to ffmpeg to decode .oga/.webm)
|
|
11
|
+
// - stdio MCP servers → "spawn npx ENOENT" / "spawn node ENOENT"
|
|
12
|
+
//
|
|
13
|
+
// getApxRunner() in core/desktop/autostart.js already dodges this for the
|
|
14
|
+
// node binary itself by using an absolute process.execPath. This module is
|
|
15
|
+
// the same idea for everything spawned *below* that process.
|
|
16
|
+
//
|
|
17
|
+
// Extra dirs are APPENDED, never prepended: an inherited PATH that already
|
|
18
|
+
// resolves a binary keeps winning, we only fill the gaps.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Directories worth appending on this machine, in priority order. Only ones
|
|
25
|
+
* that actually exist are returned, so PATH stays free of dead entries.
|
|
26
|
+
*/
|
|
27
|
+
export function extraBinDirs() {
|
|
28
|
+
const home = os.homedir();
|
|
29
|
+
// dirname(process.execPath) is the bin/ of the node currently running us —
|
|
30
|
+
// under nvm/fnm/volta that's also where npm and npx live, which is exactly
|
|
31
|
+
// what stdio MCP servers need.
|
|
32
|
+
const candidates = process.platform === "win32"
|
|
33
|
+
? [path.dirname(process.execPath)]
|
|
34
|
+
: [
|
|
35
|
+
path.dirname(process.execPath),
|
|
36
|
+
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
|
37
|
+
"/opt/homebrew/sbin",
|
|
38
|
+
"/usr/local/bin", // Homebrew on Intel, most manual installs
|
|
39
|
+
"/usr/local/sbin",
|
|
40
|
+
path.join(home, ".local", "bin"),
|
|
41
|
+
path.join(home, "Library", "pnpm"),
|
|
42
|
+
path.join(home, ".bun", "bin"),
|
|
43
|
+
path.join(home, ".cargo", "bin"),
|
|
44
|
+
];
|
|
45
|
+
return candidates.filter((dir) => {
|
|
46
|
+
try { return fs.existsSync(dir); } catch { return false; }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `basePath` with every missing entry from extraBinDirs() appended.
|
|
52
|
+
* @param {string} [basePath] defaults to the current process PATH
|
|
53
|
+
*/
|
|
54
|
+
export function augmentedPath(basePath = process.env.PATH || "") {
|
|
55
|
+
const sep = path.delimiter;
|
|
56
|
+
const seen = new Set(basePath.split(sep).filter(Boolean));
|
|
57
|
+
const additions = extraBinDirs().filter((dir) => !seen.has(dir));
|
|
58
|
+
if (!additions.length) return basePath;
|
|
59
|
+
return [...basePath.split(sep).filter(Boolean), ...additions].join(sep);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An env object for child_process.spawn: process.env + `extra`, with PATH
|
|
64
|
+
* repaired. A PATH supplied in `extra` is respected as the base and augmented
|
|
65
|
+
* too, so callers can still pin their own dirs first.
|
|
66
|
+
*/
|
|
67
|
+
export function envWithPath(extra = {}) {
|
|
68
|
+
const merged = { ...process.env, ...extra };
|
|
69
|
+
// Windows env keys are case-insensitive and may arrive as "Path".
|
|
70
|
+
const key = Object.keys(merged).find((k) => k.toUpperCase() === "PATH") || "PATH";
|
|
71
|
+
merged[key] = augmentedPath(merged[key] || "");
|
|
72
|
+
return merged;
|
|
73
|
+
}
|
|
@@ -174,12 +174,37 @@ export function listSttProviders(rawConfig = {}) {
|
|
|
174
174
|
return { configured_provider: provider, engines };
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// Host-injected "make sure the whisper subprocess is alive" hook. Core must
|
|
178
|
+
// not import from host/daemon, so host/daemon/whisper-server.js registers
|
|
179
|
+
// ensureWhisperServer() here at module load instead. Stays null in contexts
|
|
180
|
+
// that have no subprocess to manage (tests, CLI talking to a remote daemon),
|
|
181
|
+
// where transcribeViaLocalServer just fetches as before.
|
|
182
|
+
let _ensureLocalServer = null;
|
|
183
|
+
|
|
184
|
+
/** @param {((opts:object)=>Promise<void>)|null} fn */
|
|
185
|
+
export function setLocalServerEnsure(fn) {
|
|
186
|
+
_ensureLocalServer = typeof fn === "function" ? fn : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
177
189
|
/**
|
|
178
|
-
* Call the local whisper-server.py over HTTP.
|
|
179
|
-
*
|
|
180
|
-
* down
|
|
190
|
+
* Call the local whisper-server.py over HTTP.
|
|
191
|
+
*
|
|
192
|
+
* The server shuts itself down after `idle_minutes` (10 by default), so on any
|
|
193
|
+
* request that isn't back-to-back with the last one the port is simply dead.
|
|
194
|
+
* We therefore ask the host to (re)spawn it first — ensureWhisperServer() is a
|
|
195
|
+
* cheap health check when the process is already up. Without this, the first
|
|
196
|
+
* voice message after an idle gap always failed with a bare "fetch failed".
|
|
181
197
|
*/
|
|
182
198
|
export async function transcribeViaLocalServer(filePath, opts) {
|
|
199
|
+
if (_ensureLocalServer) {
|
|
200
|
+
try {
|
|
201
|
+
await _ensureLocalServer(opts);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Not fatal: the server may still be reachable (e.g. an orphan listener
|
|
204
|
+
// this process doesn't own). Let the fetch below decide.
|
|
205
|
+
logWarn("whisper", `ensure local server failed, trying anyway: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
183
208
|
const language = (opts.language || DEFAULT_LOCAL.language) === "auto"
|
|
184
209
|
? null
|
|
185
210
|
: (opts.language || null);
|
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
WHISPER_LOCAL_PORT,
|
|
16
16
|
DEFAULT_LOCAL,
|
|
17
17
|
getConfig,
|
|
18
|
+
setLocalServerEnsure,
|
|
18
19
|
} from "#core/voice/transcription.js";
|
|
20
|
+
import { envWithPath } from "#core/util/path-env.js";
|
|
19
21
|
import { pythonForWhisper } from "./stt-venv.js";
|
|
20
22
|
|
|
21
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -82,7 +84,21 @@ async function _killOrphanWhisper() {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Serializes concurrent ensures. Two voice messages arriving together (or a
|
|
88
|
+
// desktop utterance racing a Telegram note) would otherwise each see a dead
|
|
89
|
+
// port and spawn their own python, and the loser then trips
|
|
90
|
+
// "address already in use".
|
|
91
|
+
let _ensureInFlight = null;
|
|
92
|
+
|
|
85
93
|
export async function ensureWhisperServer(opts) {
|
|
94
|
+
if (_ensureInFlight) return _ensureInFlight;
|
|
95
|
+
_ensureInFlight = _ensureWhisperServer(opts).finally(() => {
|
|
96
|
+
_ensureInFlight = null;
|
|
97
|
+
});
|
|
98
|
+
return _ensureInFlight;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function _ensureWhisperServer(opts) {
|
|
86
102
|
const model = opts.model || DEFAULT_LOCAL.model;
|
|
87
103
|
const backend = opts.backend || "faster";
|
|
88
104
|
|
|
@@ -129,9 +145,15 @@ async function _spawnWhisper(opts, model, backend, retried) {
|
|
|
129
145
|
|
|
130
146
|
// Prefer APX's dedicated venv interpreter (isolated mlx/faster-whisper);
|
|
131
147
|
// fall back to system python3 for the legacy user-site install.
|
|
148
|
+
//
|
|
149
|
+
// envWithPath: both whisper backends shell out to `ffmpeg` to decode
|
|
150
|
+
// .oga/.webm. Booted from launchd the daemon's PATH is /usr/bin:/bin:
|
|
151
|
+
// /usr/sbin:/sbin, so a Homebrew ffmpeg is invisible and every transcription
|
|
152
|
+
// dies with "[Errno 2] No such file or directory: 'ffmpeg'".
|
|
132
153
|
const proc = spawn(pythonForWhisper(), args, {
|
|
133
154
|
stdio: ["ignore", "pipe", "inherit"],
|
|
134
155
|
detached: false,
|
|
156
|
+
env: envWithPath(),
|
|
135
157
|
});
|
|
136
158
|
|
|
137
159
|
_serverProcess = proc;
|
|
@@ -236,3 +258,8 @@ export const WHISPER_PATHS = {
|
|
|
236
258
|
whisper_server: WHISPER_SERVER,
|
|
237
259
|
port: WHISPER_LOCAL_PORT,
|
|
238
260
|
};
|
|
261
|
+
|
|
262
|
+
// Hand core the ability to revive the subprocess on demand. Importing this
|
|
263
|
+
// module (the daemon does, to preload at boot) is what wires it up — core
|
|
264
|
+
// itself never reaches into host/.
|
|
265
|
+
setLocalServerEnsure(ensureWhisperServer);
|