nova-audio-agent 0.2.2 → 0.2.3

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 CHANGED
@@ -37,16 +37,11 @@ npm install --global nova-audio-agent
37
37
 
38
38
  ## Configure
39
39
 
40
- Open the desktop settings, which downloads the app on first run:
40
+ Run `novaaudio`. The first launch downloads the app and opens a setup window
41
+ that asks for one key and tests it before you start:
41
42
 
42
- ```bash
43
- novaaudio config
44
- ```
45
-
46
- Then add the keys for the services you want:
47
-
48
- - **[DashScope](https://platform.qianwenai.com)** — the default Qwen realtime voice service. Required.
49
- - **[Tavily](https://docs.tavily.com)** — web search. Optional.
43
+ - **[DashScope](https://platform.qianwenai.com)** — the default Qwen realtime voice service, plus memory, the camera and web search. Required.
44
+ - **[Tavily](https://docs.tavily.com)** — web search through Tavily instead of Bailian. Optional.
50
45
  - **Codex** — a logged-in Codex executable, for coding tasks. Optional. See the [setup guide](https://deepnovacore.github.io/NovaAudioAgent/en/docs/getting-started).
51
46
 
52
47
  Allow microphone access when prompted.
@@ -72,7 +67,8 @@ Hover over the desktop orb to reach its controls.
72
67
  | --- | --- |
73
68
  | `novaaudio` or `novaaudio start` | Download the desktop app if needed, then launch it |
74
69
  | `novaaudio config` | Download the app if needed, then open its settings |
75
- | `novaaudio doctor` | Inspect platform support, local installation, and configuration status |
70
+ | `novaaudio doctor` | Inspect platform support, local installation, and which keys the voice pipeline needs |
71
+ | `novaaudio doctor --online` | Also test keys set in the environment against their provider |
76
72
  | `novaaudio --version` | Print the desktop release version used by the CLI |
77
73
  | `novaaudio --help` | Show command help |
78
74
 
@@ -84,8 +80,9 @@ The CLI downloads the matching desktop release from
84
80
  before launching it. It reuses the desktop client's encrypted settings store and
85
81
  never reads or prints secret values.
86
82
 
87
- Run `novaaudio doctor` to see which release is installed and whether your
88
- configuration is complete.
83
+ Run `novaaudio doctor` to see which release is installed and which voice keys
84
+ are still missing. `novaaudio config` opens the full settings for the other
85
+ features.
89
86
 
90
87
  For headless Ubuntu 22.04+, install `nova-audio-agent-server` and use `novaaudio-server start`; `novaaudio-server pair wss://your-host.ts.net` shows a terminal pairing QR after configuring the service.
91
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nova-audio-agent",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Install and launch the Nova Audio Agent desktop client from the command line.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,17 +3,17 @@
3
3
  "repository": "deepnovacore/NovaAudioAgent",
4
4
  "targets": {
5
5
  "darwin-arm64": {
6
- "artifact": "nova-audio-agent-0.2.2-macos-arm64-app.zip",
6
+ "artifact": "nova-audio-agent-0.2.3-macos-arm64-app.zip",
7
7
  "executable": "Nova Audio Agent Desktop.app/Contents/MacOS/Nova Audio Agent Desktop",
8
8
  "archive": "zip"
9
9
  },
10
10
  "win32-x64": {
11
- "artifact": "nova-audio-agent-0.2.2-windows-x64-portable.zip",
11
+ "artifact": "nova-audio-agent-0.2.3-windows-x64-portable.zip",
12
12
  "executable": "Nova Audio Agent Desktop.exe",
13
13
  "archive": "zip"
14
14
  },
15
15
  "linux-x64": {
16
- "artifact": "nova-audio-agent-0.2.2-linux-x64.AppImage",
16
+ "artifact": "nova-audio-agent-0.2.3-linux-x64.AppImage",
17
17
  "executable": "nova-audio-agent.AppImage",
18
18
  "archive": "file"
19
19
  }
@@ -80,6 +80,15 @@ export function interpolateCapabilityValue(value, environment) {
80
80
  invalid('interpolated_value_too_large');
81
81
  return resolved;
82
82
  }
83
+ function missingEnvironment(values, environment) {
84
+ for (const value of values) {
85
+ for (const [, name = ''] of value.matchAll(/\$\{([^}]+)\}/gu)) {
86
+ if (ENV_NAME.test(name) && !environment[name]?.trim())
87
+ return name;
88
+ }
89
+ }
90
+ return undefined;
91
+ }
83
92
  function interpolateMap(value, environment) {
84
93
  return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, interpolateCapabilityValue(val, environment)]));
85
94
  }
@@ -110,39 +119,60 @@ export function parseCapabilityRegistry(input, environment = {}) {
110
119
  const camera = moduleConfig(modules.camera, 'modules.camera', []);
111
120
  const coding = moduleConfig(modules.coding, 'modules.coding', []);
112
121
  const knowledge = moduleConfig(modules.knowledge, 'modules.knowledge', ['exposeToCodex']);
113
- const enabled = bool(search.enabled, true, 'modules.search.enabled');
122
+ const requestedEnabled = bool(search.enabled, true, 'modules.search.enabled');
114
123
  const configuredProvider = omittedDefault(search.provider, 'tavily');
115
124
  if (configuredProvider !== 'tavily' && configuredProvider !== 'mcp')
116
125
  invalid('modules.search.provider');
117
- const provider = environmentOverride(environment, 'NOVA_AUDIO_AGENT_SEARCH_PROVIDER') ?? configuredProvider;
118
- if (provider !== 'tavily' && provider !== 'mcp')
119
- invalid('NOVA_AUDIO_AGENT_SEARCH_PROVIDER');
126
+ const requestedProvider = environmentOverride(environment, 'SEARCH_PROVIDER') ?? configuredProvider;
127
+ if (requestedProvider !== 'tavily' && requestedProvider !== 'mcp')
128
+ invalid('SEARCH_PROVIDER');
120
129
  const overrides = [];
121
- if (environment.NOVA_AUDIO_AGENT_SEARCH_PROVIDER?.trim())
122
- overrides.push('NOVA_AUDIO_AGENT_SEARCH_PROVIDER');
130
+ if (environment.SEARCH_PROVIDER?.trim())
131
+ overrides.push('SEARCH_PROVIDER');
123
132
  let cameraEnabled = bool(camera.enabled, true, 'modules.camera.enabled');
124
- const cameraOverride = environment.NOVA_AUDIO_AGENT_CAMERA_MODULE_ENABLED?.trim().toLowerCase();
133
+ const cameraOverride = environment.CAMERA_MODULE_ENABLED?.trim().toLowerCase();
125
134
  if (cameraOverride) {
126
135
  if (!['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(cameraOverride))
127
- invalid('NOVA_AUDIO_AGENT_CAMERA_MODULE_ENABLED');
136
+ invalid('CAMERA_MODULE_ENABLED');
128
137
  cameraEnabled = ['true', '1', 'yes', 'on'].includes(cameraOverride);
129
- overrides.push('NOVA_AUDIO_AGENT_CAMERA_MODULE_ENABLED');
138
+ overrides.push('CAMERA_MODULE_ENABLED');
139
+ }
140
+ let codingEnabled = bool(coding.enabled, true, 'modules.coding.enabled');
141
+ const codingOverride = environment.CODING_MODULE_ENABLED?.trim().toLowerCase();
142
+ if (codingOverride) {
143
+ if (!['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(codingOverride))
144
+ invalid('CODING_MODULE_ENABLED');
145
+ codingEnabled = ['true', '1', 'yes', 'on'].includes(codingOverride);
146
+ overrides.push('CODING_MODULE_ENABLED');
130
147
  }
131
148
  const tavily = object(omittedDefault(search.tavily, {}), 'modules.search.tavily', ['apiKeyEnv']);
132
149
  const apiKeyEnv = string(omittedDefault(tavily.apiKeyEnv, 'TAVILY_API_KEY'), 'modules.search.tavily.apiKeyEnv', 128);
133
150
  if (!ENV_NAME.test(apiKeyEnv))
134
151
  invalid('modules.search.tavily.apiKeyEnv');
152
+ // Search is optional: a missing Tavily key reuses the DashScope key through the Bailian preset, or turns search off.
153
+ const tavilyUnavailable = requestedEnabled && requestedProvider === 'tavily' && !environment[apiKeyEnv]?.trim();
154
+ const fallback = tavilyUnavailable && search.mcp === undefined && !environmentOverride(environment, 'SEARCH_MCP_URL')
155
+ && Boolean(environment.DASHSCOPE_API_KEY?.trim()) ? 'bailian_mcp' : undefined;
156
+ let enabled = requestedEnabled && (!tavilyUnavailable || fallback !== undefined);
157
+ const provider = fallback === undefined ? requestedProvider : 'mcp';
158
+ let reason = tavilyUnavailable ? `missing_environment:${apiKeyEnv}` : undefined;
135
159
  let mcp;
136
160
  if (search.mcp !== undefined || (enabled && provider === 'mcp')) {
137
161
  const config = object(omittedDefault(search.mcp, {}), 'modules.search.mcp', ['url', 'tool', 'headers', 'timeoutMs', 'maxResultBytes']);
138
- const urlOverride = environmentOverride(environment, 'NOVA_AUDIO_AGENT_SEARCH_MCP_URL');
139
- const toolOverride = environmentOverride(environment, 'NOVA_AUDIO_AGENT_SEARCH_MCP_TOOL');
162
+ const urlOverride = environmentOverride(environment, 'SEARCH_MCP_URL');
163
+ const toolOverride = environmentOverride(environment, 'SEARCH_MCP_TOOL');
140
164
  const preset = !urlOverride && config.url === undefined;
141
165
  const configuredUrl = string(omittedDefault(config.url, BAILIAN_SEARCH_MCP_URL), 'modules.search.mcp.url');
142
166
  const rawUrl = string(urlOverride ?? configuredUrl, 'modules.search.mcp.url');
143
167
  const configuredTool = string(omittedDefault(config.tool, preset ? BAILIAN_SEARCH_MCP_TOOL : 'web_search'), 'modules.search.mcp.tool', 256);
144
168
  const tool = string(toolOverride ?? configuredTool, 'modules.search.mcp.tool', 256);
145
169
  const rawHeaders = stringMap(omittedDefault(config.headers, preset ? { authorization: 'Bearer ${DASHSCOPE_API_KEY}' } : {}), 'modules.search.mcp.headers');
170
+ // A search endpoint whose key is not set turns search off, like a missing Tavily key.
171
+ const missing = enabled && provider === 'mcp' ? missingEnvironment([rawUrl, ...Object.values(rawHeaders)], environment) : undefined;
172
+ if (missing !== undefined) {
173
+ enabled = false;
174
+ reason = `missing_environment:${missing}`;
175
+ }
146
176
  const headers = enabled && provider === 'mcp' ? interpolateMap(rawHeaders, environment) : rawHeaders;
147
177
  const url = enabled && provider === 'mcp' ? interpolateCapabilityValue(rawUrl, environment) : rawUrl;
148
178
  if (enabled && provider === 'mcp')
@@ -151,9 +181,9 @@ export function parseCapabilityRegistry(input, environment = {}) {
151
181
  timeoutMs: integer(config.timeoutMs, 8000, 60000, 'modules.search.mcp.timeoutMs'),
152
182
  maxResultBytes: integer(config.maxResultBytes, 262144, 1048576, 'modules.search.mcp.maxResultBytes') };
153
183
  if (urlOverride)
154
- overrides.push('NOVA_AUDIO_AGENT_SEARCH_MCP_URL');
184
+ overrides.push('SEARCH_MCP_URL');
155
185
  if (toolOverride)
156
- overrides.push('NOVA_AUDIO_AGENT_SEARCH_MCP_TOOL');
186
+ overrides.push('SEARCH_MCP_TOOL');
157
187
  }
158
188
  const servers = object(omittedDefault(document.mcpServers, {}), 'mcpServers');
159
189
  if (Object.keys(servers).length > 8)
@@ -176,8 +206,9 @@ export function parseCapabilityRegistry(input, environment = {}) {
176
206
  }
177
207
  }
178
208
  return { version: 1, modules: {
179
- search: { enabled, provider, tavily: { apiKeyEnv, ...(environment[apiKeyEnv] === undefined ? {} : { apiKey: environment[apiKeyEnv] }) }, ...(mcp === undefined ? {} : { mcp }) },
180
- camera: { enabled: cameraEnabled }, coding: { enabled: bool(coding.enabled, true, 'modules.coding.enabled') },
209
+ search: { enabled, provider, tavily: { apiKeyEnv, ...(environment[apiKeyEnv] === undefined ? {} : { apiKey: environment[apiKeyEnv] }) }, ...(mcp === undefined ? {} : { mcp }),
210
+ ...(fallback === undefined ? {} : { fallback }), ...(reason === undefined ? {} : { reason }) },
211
+ camera: { enabled: cameraEnabled }, coding: { enabled: codingEnabled },
181
212
  knowledge: { enabled: bool(knowledge.enabled, false, 'modules.knowledge.enabled'), exposeToCodex: bool(knowledge.exposeToCodex, false, 'modules.knowledge.exposeToCodex') },
182
213
  }, mcpServers, serverStatuses, overrides,
183
214
  frontbrainToolBudget: integer(document.frontbrainToolBudget, DEFAULT_FRONTBRAIN_TOOL_BUDGET, 256, 'frontbrainToolBudget') };
@@ -225,7 +256,7 @@ function parseServer(value, environment) {
225
256
  }
226
257
  export function loadCapabilityRegistry(options = {}) {
227
258
  const environment = options.environment ?? process.env;
228
- const explicitPath = (options.path === '' ? undefined : options.path) ?? environmentOverride(environment, 'NOVA_AUDIO_AGENT_CAPABILITIES_CONFIG');
259
+ const explicitPath = (options.path === '' ? undefined : options.path) ?? environmentOverride(environment, 'CAPABILITIES_CONFIG');
229
260
  const path = explicitPath ?? DEFAULT_CAPABILITIES_PATH;
230
261
  const explicit = explicitPath !== undefined;
231
262
  const resolved = path.startsWith('~/') ? join(options.home ?? homedir(), path.slice(2)) : path;
@@ -246,19 +277,17 @@ export function loadCapabilityRegistry(options = {}) {
246
277
  }
247
278
  export function capabilityStatus(registry, toolCount = null) {
248
279
  return { modules: {
249
- search: { enabled: registry.modules.search.enabled, provider: registry.modules.search.provider },
280
+ search: { enabled: registry.modules.search.enabled, provider: registry.modules.search.provider,
281
+ ...(registry.modules.search.fallback === undefined ? {} : { fallback: registry.modules.search.fallback }),
282
+ ...(registry.modules.search.reason === undefined ? {} : { reason: registry.modules.search.reason }) },
250
283
  camera: registry.modules.camera, coding: registry.modules.coding, knowledge: registry.modules.knowledge,
251
284
  }, servers: registry.serverStatuses, overrides: registry.overrides, toolCount, toolBudget: registry.frontbrainToolBudget };
252
285
  }
253
286
  export function inspectCapabilities(options = {}) {
254
287
  try {
255
288
  const registry = loadCapabilityRegistry(options);
256
- const environment = options.environment ?? process.env;
257
- const missingTavily = registry.modules.search.enabled && registry.modules.search.provider === 'tavily'
258
- && !environment[registry.modules.search.tavily.apiKeyEnv]?.trim();
259
- return { ok: !missingTavily && registry.serverStatuses.every(server => server.status !== 'failed'),
260
- ...capabilityStatus(registry),
261
- ...(missingTavily ? { reason: `missing_environment:${registry.modules.search.tavily.apiKeyEnv}` } : {}) };
289
+ // A switched or disabled search is a reported degradation, not a failed configuration.
290
+ return { ok: registry.serverStatuses.every(server => server.status !== 'failed'), ...capabilityStatus(registry) };
262
291
  }
263
292
  catch (error) {
264
293
  return { ok: false, reason: error instanceof CapabilityConfigurationError ? error.reason : 'invalid_configuration' };
package/src/command.mjs CHANGED
@@ -6,7 +6,12 @@ export const HELP_TEXT = `Usage: novaaudio [start|config|doctor|--version|--help
6
6
  novaaudio Install if needed and launch Nova Audio Agent
7
7
  novaaudio start Install if needed and launch Nova Audio Agent
8
8
  novaaudio config Open the desktop settings window
9
- novaaudio doctor Inspect local installation and configuration status`
9
+ novaaudio doctor Inspect local installation and configuration status
10
+ novaaudio doctor --online
11
+ Also test voice keys set in the environment against their provider`
12
+
13
+ const VOICE_KEY_SOURCES = Object.freeze({settings: 'saved in settings', environment: 'set in environment', missing: 'missing'})
14
+ const VOICE_KEY_PROBES = Object.freeze({ok: 'valid', rejected: 'rejected by provider', network: 'could not reach provider', deferred: 'checked on first connection'})
10
15
 
11
16
  export async function main(argv, {
12
17
  stdout = process.stdout,
@@ -15,7 +20,8 @@ export async function main(argv, {
15
20
  doctor = inspectDoctor,
16
21
  } = {}) {
17
22
  const command = argv[0] ?? 'start'
18
- if (argv.length > 1) {
23
+ const online = command === 'doctor' && argv.length === 2 && argv[1] === '--online'
24
+ if (argv.length > 1 && !online) {
19
25
  stdout.write(`${HELP_TEXT}\n`)
20
26
  return 2
21
27
  }
@@ -28,7 +34,7 @@ export async function main(argv, {
28
34
  return 0
29
35
  }
30
36
  if (command === 'doctor') {
31
- const report = await doctor()
37
+ const report = await doctor({online})
32
38
  stdout.write(`Nova Audio Agent ${PRODUCT_VERSION}\n`)
33
39
  stdout.write(`Platform: ${report.platform} (${report.supported ? 'supported' : 'unsupported'})\n`)
34
40
  if (report.supported) {
@@ -37,6 +43,16 @@ export async function main(argv, {
37
43
  stdout.write(`Configured keys: ${report.configuredSecretKeys.length === 0 ? 'none' : report.configuredSecretKeys.join(', ')}\n`)
38
44
  stdout.write(`Codex: ${report.codexPresent ? 'found' : 'missing'}\n`)
39
45
  }
46
+ const missingVoiceKeys = report.voice?.keys.filter(key => key.source === null) ?? []
47
+ const rejectedVoiceKeys = report.voice?.keys.filter(key => key.probe === 'rejected') ?? []
48
+ if (report.voice) {
49
+ stdout.write(`Voice pipeline: ${report.voice.pipeline}\n`)
50
+ for (const key of report.voice.keys) stdout.write(` ${key.name}: ${VOICE_KEY_SOURCES[key.source ?? 'missing']}${key.probe ? ` (${VOICE_KEY_PROBES[key.probe] ?? key.probe})` : ''}\n`)
51
+ if (online && report.voice.keys.some(key => key.source === 'settings')) stdout.write(' Saved keys are encrypted; test them in the desktop setup window.\n')
52
+ if (missingVoiceKeys.length > 0) stdout.write(report.voice.pipeline === 'integrated'
53
+ ? 'First run: start `novaaudio` and paste a DashScope API Key into the setup window that opens.\n'
54
+ : 'First run: start `novaaudio` and fill in the missing keys in the setup window that opens.\n')
55
+ }
40
56
  if (report.capabilities !== undefined) {
41
57
  const status = report.capabilities
42
58
  stdout.write(`Capabilities: ${status.ok ? 'valid' : 'needs attention'}${status.reason ? ` (${status.reason})` : ''}\n`)
@@ -47,7 +63,7 @@ export async function main(argv, {
47
63
  for (const name of status.overrides) stdout.write(` Override: ${name}\n`)
48
64
  }
49
65
  }
50
- return report.supported && report.capabilities?.ok !== false ? 0 : 1
66
+ return report.supported && report.capabilities?.ok !== false && missingVoiceKeys.length === 0 && rejectedVoiceKeys.length === 0 ? 0 : 1
51
67
  }
52
68
  if (command !== 'start' && command !== 'config') {
53
69
  stdout.write(`${HELP_TEXT}\n`)
@@ -0,0 +1,29 @@
1
+ // Generated from clients/desktop/src/main/key-probe.mjs; run node runtime/scripts/check-capabilities.mjs --write.
2
+ // Live check for the one or two keys a first run needs. Each probe is a cheap
3
+ // authenticated model listing; the key never appears in the result.
4
+ const PROBES = Object.freeze({
5
+ dashscopeApiKey: 'https://dashscope.aliyuncs.com/compatible-mode/v1/models',
6
+ deepseekApiKey: 'https://api.deepseek.com/models',
7
+ arkApiKey: 'https://ark.cn-beijing.volces.com/api/v3/models',
8
+ })
9
+ // The Volcengine speech key has no cheap authenticated read; it is checked by the first connection.
10
+ const DEFERRED = new Set(['doubaoBigmodelApiKey'])
11
+ const PROBE_TIMEOUT_MS = 8_000
12
+
13
+ export async function probeApiKey(key, value, {fetch = globalThis.fetch, timeoutMs = PROBE_TIMEOUT_MS} = {}) {
14
+ if (typeof value !== 'string' || value.trim() === '' || /[\u0000-\u001f\u007f]/u.test(value)) return Object.freeze({status: 'missing'})
15
+ if (DEFERRED.has(key)) return Object.freeze({status: 'deferred'})
16
+ if (!Object.hasOwn(PROBES, key)) return Object.freeze({status: 'unsupported'})
17
+ try {
18
+ const response = await fetch(PROBES[key], {
19
+ headers: {authorization: `Bearer ${value.trim()}`},
20
+ signal: AbortSignal.timeout(timeoutMs),
21
+ })
22
+ await response.body?.cancel().catch(() => {})
23
+ if (response.ok) return Object.freeze({status: 'ok'})
24
+ if (response.status === 401 || response.status === 403) return Object.freeze({status: 'rejected', httpStatus: response.status})
25
+ return Object.freeze({status: 'network', httpStatus: response.status})
26
+ } catch {
27
+ return Object.freeze({status: 'network'})
28
+ }
29
+ }
package/src/runtime.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import {inspectCapabilities} from './capability-registry.mjs'
2
+ import {probeApiKey} from './key-probe.mjs'
2
3
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
4
  import { spawn, spawnSync } from 'node:child_process'
4
5
  import {
@@ -415,11 +416,60 @@ function findCodex(platform = process.platform) {
415
416
  return result.status === 0 && String(result.stdout).trim() !== ''
416
417
  }
417
418
 
419
+ // Mirrors the desktop settings keys each voice pipeline cannot start without.
420
+ const VOICE_KEYS = Object.freeze({
421
+ dashscopeApiKey: 'DASHSCOPE_API_KEY',
422
+ deepseekApiKey: 'DEEPSEEK_API_KEY',
423
+ arkApiKey: 'ARK_API_KEY',
424
+ doubaoBigmodelApiKey: 'DOUBAO_BIGMODEL_API_KEY',
425
+ })
426
+ const CASCADED_LLM_KEYS = Object.freeze({qwen: 'dashscopeApiKey', deepseek: 'deepseekApiKey', ark: 'arkApiKey'})
427
+
428
+ function voiceRequirement(document) {
429
+ const pipeline = document?.pipelineMode === 'cascaded' ? 'cascaded' : 'integrated'
430
+ const llm = Object.hasOwn(CASCADED_LLM_KEYS, document?.cascadedLlmProvider) ? document.cascadedLlmProvider : 'deepseek'
431
+ return {pipeline, keys: pipeline === 'integrated' ? ['dashscopeApiKey'] : [CASCADED_LLM_KEYS[llm], 'doubaoBigmodelApiKey']}
432
+ }
433
+
434
+ const DASHSCOPE_COMPATIBLE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
435
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/u
436
+
437
+ // Matches settings-store's validSecretEntry: anything else is dropped by the desktop and does not count as saved.
438
+ function savedSecret(document, key) {
439
+ const entry = document?.secrets?.[key]
440
+ return entry !== null && typeof entry === 'object' && ['safeStorage', 'none'].includes(entry.enc)
441
+ && typeof entry.data === 'string' && entry.data !== '' && entry.data.length % 4 === 0 && BASE64.test(entry.data)
442
+ }
443
+
444
+ // Saved keys win over the environment in the desktop, and stay encrypted, so only environment keys can be probed here.
445
+ async function inspectVoiceKeys(document, environment, {online, fetchImpl}) {
446
+ const {pipeline, keys} = voiceRequirement(document)
447
+ const sourceOf = (key, name) => savedSecret(document, key) ? 'settings' : environment[name]?.trim() ? 'environment' : null
448
+ const entries = []
449
+ for (const key of keys) {
450
+ let name = VOICE_KEYS[key]
451
+ let source = sourceOf(key, name)
452
+ // Integrated voice also accepts the generic model key while the model gateway stays on DashScope.
453
+ const savedBaseUrl = typeof document?.modelBaseUrl === 'string' ? document.modelBaseUrl.trim() : ''
454
+ const modelBaseUrl = savedBaseUrl || environment.MODEL_BASE_URL?.trim() || DASHSCOPE_COMPATIBLE_BASE_URL
455
+ if (source === null && key === 'dashscopeApiKey' && pipeline === 'integrated'
456
+ && (modelBaseUrl === '' || modelBaseUrl === DASHSCOPE_COMPATIBLE_BASE_URL) && sourceOf('modelApiKey', 'MODEL_API_KEY') !== null) {
457
+ name = 'MODEL_API_KEY'
458
+ source = sourceOf('modelApiKey', name)
459
+ }
460
+ const probe = online && source === 'environment' ? (await probeApiKey(key, environment[name], {fetch: fetchImpl})).status : undefined
461
+ entries.push(Object.freeze({name, source, ...(probe === undefined ? {} : {probe})}))
462
+ }
463
+ return Object.freeze({pipeline, keys: Object.freeze(entries)})
464
+ }
465
+
418
466
  export async function inspectDoctor({
419
467
  platform = process.platform,
420
468
  arch = process.arch,
421
469
  home,
422
470
  environment = process.env,
471
+ online = false,
472
+ fetchImpl = globalThis.fetch,
423
473
  } = {}) {
424
474
  let target
425
475
  try {
@@ -432,8 +482,9 @@ export async function inspectDoctor({
432
482
  const settings = desktopSettingsPath({platform, home, environment})
433
483
  let secretKeys = []
434
484
  let capabilitiesConfigPath
485
+ let document
435
486
  try {
436
- const document = JSON.parse(await readFile(settings, 'utf8'))
487
+ document = JSON.parse(await readFile(settings, 'utf8'))
437
488
  // Match settings-store v4 migration/string validation, then backendLaunchSpec's nonempty saved-path override.
438
489
  const acceptsV4Fields = document?.version === undefined || (typeof document.version === 'number' && document.version >= 4)
439
490
  const candidate = document?.capabilitiesConfigPath
@@ -451,10 +502,11 @@ export async function inspectDoctor({
451
502
  desktopReady: await cachedInstallation({root, executable, target, platform}) !== null,
452
503
  settingsPresent: await access(settings).then(() => true, () => false),
453
504
  configuredSecretKeys: Object.freeze(secretKeys),
505
+ voice: await inspectVoiceKeys(document, environment, {online, fetchImpl}),
454
506
  codexPresent: findCodex(platform),
455
507
  capabilities: inspectCapabilities({
456
508
  environment: capabilitiesConfigPath === undefined ? environment : {
457
- ...environment, NOVA_AUDIO_AGENT_CAPABILITIES_CONFIG: capabilitiesConfigPath,
509
+ ...environment, CAPABILITIES_CONFIG: capabilitiesConfigPath,
458
510
  },
459
511
  ...(home === undefined ? {} : {home}),
460
512
  }),
package/src/target.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join } from 'node:path'
3
3
 
4
- export const PRODUCT_VERSION = '0.2.2'
4
+ export const PRODUCT_VERSION = '0.2.3'
5
5
  export const RELEASE_REPOSITORY = 'deepnovacore/NovaAudioAgent'
6
6
 
7
7
  const DEFINITIONS = Object.freeze({