nova-audio-agent 0.1.1 → 0.2.0

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
@@ -1,24 +1,93 @@
1
- # Nova Audio Agent CLI
1
+ # Nova Audio Agent
2
2
 
3
- Install the command globally:
3
+ **An always-on voice agent with restrained proactivity and voice-controlled workspaces.**
4
+
5
+ [GitHub](https://github.com/deepnovacore/NovaAudioAgent) ·
6
+ [简体中文](https://github.com/deepnovacore/NovaAudioAgent/blob/main/README.zh-CN.md) ·
7
+ [Watch the demo (Chinese)](https://youtu.be/t1c-2O-QsxE) ·
8
+ [Desktop downloads](https://github.com/deepnovacore/NovaAudioAgent/releases)
9
+
10
+ Nova (小诺) keeps a conversation going while longer tasks run in the background.
11
+ It is designed to report useful milestones, clarify requests, and let you steer
12
+ work through voice without narrating every small step.
13
+
14
+ This npm package provides the `novaaudio` command to install and launch the
15
+ desktop application. You do not need to clone or build the repository to use it.
16
+
17
+ ## Get started
18
+
19
+ Requirements: **Node.js 22.13.0 or newer**, npm, and **macOS on Apple Silicon**
20
+ or **Windows x64**. The first launch needs access to GitHub to download the app.
4
21
 
5
22
  ```bash
6
- npm install -g nova-audio-agent
23
+ npm install --global nova-audio-agent
24
+ novaaudio config
7
25
  ```
8
26
 
9
- Then launch Nova or open its settings:
27
+ In the desktop settings, configure your [DashScope](https://platform.qianwenai.com)
28
+ API key for the default Qwen realtime voice service and your
29
+ [Tavily](https://docs.tavily.com) API key for web search. For coding tasks, set up
30
+ a logged-in Codex executable; see the
31
+ [setup guide](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/en/getting-started.md).
32
+ Allow microphone access when prompted, then launch Nova:
10
33
 
11
34
  ```bash
12
35
  novaaudio
13
- novaaudio config
14
- novaaudio doctor
15
36
  ```
16
37
 
17
- The CLI downloads the matching `v0.1.1` desktop release into
18
- `~/.nova-audio-agent/cli/releases/`, verifies its published SHA-256 digest, and
19
- keeps application settings in the desktop client's existing encrypted store.
20
- It never reads or prints secret values.
38
+ Hover over the desktop orb to access its controls. Open settings again with
39
+ `novaaudio config`, or inspect your local setup with `novaaudio doctor`.
40
+
41
+ ## Why Nova?
42
+
43
+ - **Talk while work continues.** Long-running tasks execute in the background
44
+ while Nova stays available for conversation.
45
+ - **Progress worth hearing.** Restrained proactivity aims to surface meaningful
46
+ updates without reading out every coding event.
47
+ - **Manage workspaces by voice.** Create or switch workspaces and sessions
48
+ through proposals that you confirm.
49
+ - **Steer ongoing work.** The Codex integration uses its native app-server
50
+ transport to support real-time steering.
51
+
52
+ The repository also documents ongoing development. Features and acceptance
53
+ status on development branches may differ from the shipped desktop release;
54
+ check the [release notes](https://github.com/deepnovacore/NovaAudioAgent/releases)
55
+ for your installed version.
56
+
57
+ ## Commands
58
+
59
+ | Command | What it does |
60
+ | --- | --- |
61
+ | `novaaudio` or `novaaudio start` | Download the desktop app if needed, then launch it |
62
+ | `novaaudio config` | Download the app if needed, then open its settings |
63
+ | `novaaudio doctor` | Inspect platform support, local installation, and configuration status |
64
+ | `novaaudio --version` | Print the desktop release version used by the CLI |
65
+ | `novaaudio --help` | Show command help |
66
+
67
+ ## Installation details
68
+
69
+ The CLI downloads the matching `v0.2.0` desktop release from
70
+ [GitHub Releases](https://github.com/deepnovacore/NovaAudioAgent/releases/tag/v0.2.0)
71
+ into `~/.nova-audio-agent/cli/releases/` and verifies its published SHA-256 digest
72
+ before launching it. It reuses the desktop client's encrypted settings store;
73
+ the CLI does not read or print secret values.
74
+
75
+ Documentation-only npm updates may have a newer package version while retaining
76
+ the same desktop release. `novaaudio --version` continues to report `0.2.0`.
77
+
78
+ This release supports macOS arm64 and Windows x64. Linux and Intel Mac desktop
79
+ downloads are not included. The desktop application is currently unsigned, so
80
+ macOS Gatekeeper or Windows SmartScreen may display a security warning.
81
+
82
+ ## Learn more
83
+
84
+ - [Getting started and integrations](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/en/getting-started.md)
85
+ - [Runtime architecture](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/en/architecture.md)
86
+ - [Design: when should a proactive voice agent speak?](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/en/blog/2026-08-proactive-voice-agent-design-space.md)
87
+ - [Report an issue](https://github.com/deepnovacore/NovaAudioAgent/issues)
88
+ - [Build from source and contribute](https://github.com/deepnovacore/NovaAudioAgent/blob/main/CONTRIBUTING.md)
89
+
90
+ ## License
21
91
 
22
- The initial release supports macOS arm64 and Windows x64.
23
- The desktop application is currently unsigned, so the operating system may
24
- show a security warning.
92
+ Copyright 2026 DeepNovaCore.
93
+ [Apache License 2.0](https://github.com/deepnovacore/NovaAudioAgent/blob/main/LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nova-audio-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Install and launch the Nova Audio Agent desktop client from the command line.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,13 +3,13 @@
3
3
  "repository": "deepnovacore/NovaAudioAgent",
4
4
  "targets": {
5
5
  "darwin-arm64": {
6
- "artifact": "nova-audio-agent-0.1.1-macos-arm64-app.zip",
7
- "executable": "Nova Audio Agent Ambient Orb.app/Contents/MacOS/Nova Audio Agent Ambient Orb",
6
+ "artifact": "nova-audio-agent-0.2.0-macos-arm64-app.zip",
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.1.1-windows-x64-portable.zip",
12
- "executable": "Nova Audio Agent Ambient Orb.exe",
11
+ "artifact": "nova-audio-agent-0.2.0-windows-x64-portable.zip",
12
+ "executable": "Nova Audio Agent Desktop.exe",
13
13
  "archive": "zip"
14
14
  }
15
15
  }
@@ -0,0 +1,266 @@
1
+ // Generated from runtime/src/config/capability-registry.ts; run node runtime/scripts/check-capabilities.mjs --write.
2
+ /** One dependency-free registry parser, also emitted into the standalone CLI by check-capabilities.mjs. */
3
+ import { readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ export const DEFAULT_CAPABILITIES_PATH = '~/.nova-audio-agent/capabilities.json';
7
+ export const BAILIAN_SEARCH_MCP_URL = 'https://dashscope.aliyuncs.com/api/v1/mcps/EnhancedSearch/mcp';
8
+ export const BAILIAN_SEARCH_MCP_TOOL = 'search_pro';
9
+ export const DEFAULT_FRONTBRAIN_TOOL_BUDGET = 24;
10
+ export const MCP_NON_AUTH_HEADERS = ['accept', 'content-type', 'user-agent'];
11
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u;
12
+ const SERVER_NAME = /^[a-z][a-z0-9_]{0,31}$/u;
13
+ const MAX_CONFIG_BYTES = 256 * 1024;
14
+ export class CapabilityConfigurationError extends Error {
15
+ reason;
16
+ code = 'invalid_capabilities_configuration';
17
+ constructor(reason) {
18
+ super(`invalid capabilities configuration: ${reason}`);
19
+ this.reason = reason;
20
+ this.name = 'CapabilityConfigurationError';
21
+ }
22
+ }
23
+ function environmentOverride(environment, name) {
24
+ const value = environment[name]?.trim();
25
+ return value === '' ? undefined : value;
26
+ }
27
+ function omittedDefault(value, fallback) { return value === undefined ? fallback : value; }
28
+ function invalid(field) { throw new CapabilityConfigurationError(field); }
29
+ function object(value, field, keys) {
30
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
31
+ invalid(field);
32
+ const record = value;
33
+ if (keys !== undefined && Object.keys(record).some(key => !keys.includes(key)))
34
+ invalid(field);
35
+ return record;
36
+ }
37
+ function bool(value, fallback, field) {
38
+ if (value === undefined)
39
+ return fallback;
40
+ if (typeof value !== 'boolean')
41
+ invalid(field);
42
+ return value;
43
+ }
44
+ function string(value, field, max = 8192) {
45
+ if (typeof value !== 'string' || value.trim() === '' || value.length > max || /[\u0000\r\n]/u.test(value))
46
+ invalid(field);
47
+ return value;
48
+ }
49
+ function integer(value, fallback, max, field) {
50
+ if (value === undefined)
51
+ return fallback;
52
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max)
53
+ invalid(field);
54
+ return value;
55
+ }
56
+ function stringMap(value, field) {
57
+ const entries = Object.entries(object(omittedDefault(value, {}), field));
58
+ if (entries.length > 64)
59
+ invalid(field);
60
+ return Object.fromEntries(entries.map(([key, val]) => {
61
+ if (!/^[A-Za-z_][A-Za-z0-9_-]{0,127}$/u.test(key))
62
+ invalid(field);
63
+ if (typeof val !== 'string' || val.length > 8192 || /[\u0000\r\n]/u.test(val))
64
+ invalid(field);
65
+ return [key, val];
66
+ }));
67
+ }
68
+ export function interpolateCapabilityValue(value, environment) {
69
+ const resolved = value.replace(/\$\{([^}]+)\}/gu, (_match, name) => {
70
+ if (!ENV_NAME.test(name))
71
+ invalid('invalid_environment_reference');
72
+ const replacement = environment[name];
73
+ if (replacement === undefined || replacement.trim() === '')
74
+ invalid(`missing_environment:${name}`);
75
+ if (/[\u0000\r\n]/u.test(replacement))
76
+ invalid(`invalid_environment:${name}`);
77
+ return replacement;
78
+ });
79
+ if (resolved.length > 8192)
80
+ invalid('interpolated_value_too_large');
81
+ return resolved;
82
+ }
83
+ function interpolateMap(value, environment) {
84
+ return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, interpolateCapabilityValue(val, environment)]));
85
+ }
86
+ export function validateMcpEndpoint(value, headers = {}) {
87
+ let url;
88
+ try {
89
+ url = new URL(value);
90
+ }
91
+ catch {
92
+ invalid('invalid_mcp_endpoint');
93
+ }
94
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
95
+ // Unknown custom headers may themselves be credentials. Only known non-auth metadata is safe on HTTP.
96
+ const hasAuth = Object.keys(headers).some(key => !MCP_NON_AUTH_HEADERS.some(name => name === key.toLowerCase()));
97
+ if (url.username || url.password || url.hash
98
+ || (url.protocol !== 'https:' && (url.protocol !== 'http:' || !loopback || hasAuth)))
99
+ invalid('insecure_mcp_endpoint');
100
+ }
101
+ function moduleConfig(value, field, keys) {
102
+ return object(omittedDefault(value, {}), field, ['enabled', ...keys]);
103
+ }
104
+ export function parseCapabilityRegistry(input, environment = {}) {
105
+ const document = object(input, 'document', ['version', 'modules', 'mcpServers', 'frontbrainToolBudget']);
106
+ if (document.version !== 1)
107
+ invalid('version');
108
+ const modules = object(omittedDefault(document.modules, {}), 'modules', ['search', 'camera', 'coding', 'knowledge']);
109
+ const search = moduleConfig(modules.search, 'modules.search', ['provider', 'mcp', 'tavily']);
110
+ const camera = moduleConfig(modules.camera, 'modules.camera', []);
111
+ const coding = moduleConfig(modules.coding, 'modules.coding', []);
112
+ const knowledge = moduleConfig(modules.knowledge, 'modules.knowledge', ['exposeToCodex']);
113
+ const enabled = bool(search.enabled, true, 'modules.search.enabled');
114
+ const configuredProvider = omittedDefault(search.provider, 'tavily');
115
+ if (configuredProvider !== 'tavily' && configuredProvider !== 'mcp')
116
+ 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');
120
+ const overrides = [];
121
+ if (environment.NOVA_AUDIO_AGENT_SEARCH_PROVIDER?.trim())
122
+ overrides.push('NOVA_AUDIO_AGENT_SEARCH_PROVIDER');
123
+ let cameraEnabled = bool(camera.enabled, true, 'modules.camera.enabled');
124
+ const cameraOverride = environment.NOVA_AUDIO_AGENT_CAMERA_MODULE_ENABLED?.trim().toLowerCase();
125
+ if (cameraOverride) {
126
+ if (!['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(cameraOverride))
127
+ invalid('NOVA_AUDIO_AGENT_CAMERA_MODULE_ENABLED');
128
+ cameraEnabled = ['true', '1', 'yes', 'on'].includes(cameraOverride);
129
+ overrides.push('NOVA_AUDIO_AGENT_CAMERA_MODULE_ENABLED');
130
+ }
131
+ const tavily = object(omittedDefault(search.tavily, {}), 'modules.search.tavily', ['apiKeyEnv']);
132
+ const apiKeyEnv = string(omittedDefault(tavily.apiKeyEnv, 'TAVILY_API_KEY'), 'modules.search.tavily.apiKeyEnv', 128);
133
+ if (!ENV_NAME.test(apiKeyEnv))
134
+ invalid('modules.search.tavily.apiKeyEnv');
135
+ let mcp;
136
+ if (search.mcp !== undefined || (enabled && provider === 'mcp')) {
137
+ 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');
140
+ const preset = !urlOverride && config.url === undefined;
141
+ const configuredUrl = string(omittedDefault(config.url, BAILIAN_SEARCH_MCP_URL), 'modules.search.mcp.url');
142
+ const rawUrl = string(urlOverride ?? configuredUrl, 'modules.search.mcp.url');
143
+ const configuredTool = string(omittedDefault(config.tool, preset ? BAILIAN_SEARCH_MCP_TOOL : 'web_search'), 'modules.search.mcp.tool', 256);
144
+ const tool = string(toolOverride ?? configuredTool, 'modules.search.mcp.tool', 256);
145
+ const rawHeaders = stringMap(omittedDefault(config.headers, preset ? { authorization: 'Bearer ${DASHSCOPE_API_KEY}' } : {}), 'modules.search.mcp.headers');
146
+ const headers = enabled && provider === 'mcp' ? interpolateMap(rawHeaders, environment) : rawHeaders;
147
+ const url = enabled && provider === 'mcp' ? interpolateCapabilityValue(rawUrl, environment) : rawUrl;
148
+ if (enabled && provider === 'mcp')
149
+ validateMcpEndpoint(url, headers);
150
+ mcp = { url, tool, headers,
151
+ timeoutMs: integer(config.timeoutMs, 8000, 60000, 'modules.search.mcp.timeoutMs'),
152
+ maxResultBytes: integer(config.maxResultBytes, 262144, 1048576, 'modules.search.mcp.maxResultBytes') };
153
+ if (urlOverride)
154
+ overrides.push('NOVA_AUDIO_AGENT_SEARCH_MCP_URL');
155
+ if (toolOverride)
156
+ overrides.push('NOVA_AUDIO_AGENT_SEARCH_MCP_TOOL');
157
+ }
158
+ const servers = object(omittedDefault(document.mcpServers, {}), 'mcpServers');
159
+ if (Object.keys(servers).length > 8)
160
+ invalid('mcpServers:max_8');
161
+ const mcpServers = Object.create(null);
162
+ const serverStatuses = [];
163
+ for (const [index, [name, value]] of Object.entries(servers).entries()) {
164
+ const safeName = SERVER_NAME.test(name) ? name : `invalid_server_${index + 1}`;
165
+ try {
166
+ if (!SERVER_NAME.test(name))
167
+ invalid('invalid_server_name');
168
+ if (name === 'nova_camera' || name === 'nova_knowledge')
169
+ invalid('reserved_server_name');
170
+ const server = parseServer(value, environment);
171
+ mcpServers[name] = server;
172
+ serverStatuses.push({ name, status: server.enabled ? 'configured' : 'disabled' });
173
+ }
174
+ catch (error) {
175
+ serverStatuses.push({ name: safeName, status: 'failed', reason: error instanceof CapabilityConfigurationError ? error.reason : 'invalid_server' });
176
+ }
177
+ }
178
+ 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') },
181
+ knowledge: { enabled: bool(knowledge.enabled, false, 'modules.knowledge.enabled'), exposeToCodex: bool(knowledge.exposeToCodex, false, 'modules.knowledge.exposeToCodex') },
182
+ }, mcpServers, serverStatuses, overrides,
183
+ frontbrainToolBudget: integer(document.frontbrainToolBudget, DEFAULT_FRONTBRAIN_TOOL_BUDGET, 256, 'frontbrainToolBudget') };
184
+ }
185
+ function parseServer(value, environment) {
186
+ const config = object(value, 'server', ['enabled', 'transport', 'url', 'headers', 'command', 'args', 'env', 'tools', 'exposeTo']);
187
+ const enabled = bool(config.enabled, true, 'server.enabled');
188
+ const transport = config.transport;
189
+ if (transport !== 'streamable-http' && transport !== 'stdio')
190
+ invalid('server.transport');
191
+ const exposure = object(omittedDefault(config.exposeTo, {}), 'server.exposeTo', ['frontbrain', 'codex']);
192
+ const exposeTo = { frontbrain: bool(exposure.frontbrain, false, 'server.exposeTo.frontbrain'), codex: bool(exposure.codex, true, 'server.exposeTo.codex') };
193
+ const rawTools = object(omittedDefault(config.tools, {}), 'server.tools');
194
+ if (Object.keys(rawTools).length > 32)
195
+ invalid('server.tools:max_32');
196
+ const tools = Object.fromEntries(Object.entries(rawTools).map(([name, value]) => {
197
+ string(name, 'server.tool_name', 256);
198
+ const tool = object(value, 'server.tool', ['enabled', 'timeoutMs', 'maxResultBytes', 'maxCallsPerTurn']);
199
+ return [name, { enabled: bool(tool.enabled, false, 'server.tool.enabled'),
200
+ timeoutMs: integer(tool.timeoutMs, 8000, 60000, 'server.tool.timeoutMs'),
201
+ maxResultBytes: integer(tool.maxResultBytes, 32768, 1048576, 'server.tool.maxResultBytes'),
202
+ maxCallsPerTurn: integer(tool.maxCallsPerTurn, 2, 32, 'server.tool.maxCallsPerTurn') }];
203
+ }));
204
+ if (transport === 'streamable-http') {
205
+ if (config.command !== undefined || config.args !== undefined || config.env !== undefined)
206
+ invalid('server.transport_fields');
207
+ const rawHeaders = stringMap(config.headers, 'server.headers');
208
+ const rawUrl = string(config.url, 'server.url');
209
+ const headers = enabled ? interpolateMap(rawHeaders, environment) : rawHeaders;
210
+ const url = enabled ? interpolateCapabilityValue(rawUrl, environment) : rawUrl;
211
+ if (enabled)
212
+ validateMcpEndpoint(url, headers);
213
+ return { enabled, transport, url, urlInterpolated: /\$\{[A-Za-z_][A-Za-z0-9_]*\}/u.test(rawUrl), headers, tools, exposeTo };
214
+ }
215
+ if (config.url !== undefined || config.headers !== undefined)
216
+ invalid('server.transport_fields');
217
+ const command = string(config.command, 'server.command');
218
+ const args = omittedDefault(config.args, []);
219
+ if (!Array.isArray(args) || args.length > 64 || args.some(arg => typeof arg !== 'string' || arg.length > 8192 || arg.includes('\0')))
220
+ invalid('server.args');
221
+ const env = stringMap(config.env, 'server.env');
222
+ if (Object.keys(env).some(key => !ENV_NAME.test(key)))
223
+ invalid('server.env');
224
+ return { enabled, transport, command, args: args, env: enabled ? interpolateMap(env, environment) : env, tools, exposeTo };
225
+ }
226
+ export function loadCapabilityRegistry(options = {}) {
227
+ const environment = options.environment ?? process.env;
228
+ const explicitPath = (options.path === '' ? undefined : options.path) ?? environmentOverride(environment, 'NOVA_AUDIO_AGENT_CAPABILITIES_CONFIG');
229
+ const path = explicitPath ?? DEFAULT_CAPABILITIES_PATH;
230
+ const explicit = explicitPath !== undefined;
231
+ const resolved = path.startsWith('~/') ? join(options.home ?? homedir(), path.slice(2)) : path;
232
+ let input;
233
+ try {
234
+ const bytes = readFileSync(resolved);
235
+ if (bytes.byteLength > MAX_CONFIG_BYTES)
236
+ invalid('file_too_large');
237
+ input = JSON.parse(bytes.toString('utf8'));
238
+ }
239
+ catch (error) {
240
+ if (!explicit && error.code === 'ENOENT')
241
+ input = { version: 1 };
242
+ else
243
+ throw error instanceof CapabilityConfigurationError ? error : new CapabilityConfigurationError('file_unreadable_or_invalid_json');
244
+ }
245
+ return parseCapabilityRegistry(input, environment);
246
+ }
247
+ export function capabilityStatus(registry, toolCount = null) {
248
+ return { modules: {
249
+ search: { enabled: registry.modules.search.enabled, provider: registry.modules.search.provider },
250
+ camera: registry.modules.camera, coding: registry.modules.coding, knowledge: registry.modules.knowledge,
251
+ }, servers: registry.serverStatuses, overrides: registry.overrides, toolCount, toolBudget: registry.frontbrainToolBudget };
252
+ }
253
+ export function inspectCapabilities(options = {}) {
254
+ try {
255
+ 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}` } : {}) };
262
+ }
263
+ catch (error) {
264
+ return { ok: false, reason: error instanceof CapabilityConfigurationError ? error.reason : 'invalid_configuration' };
265
+ }
266
+ }
package/src/command.mjs CHANGED
@@ -37,7 +37,17 @@ export async function main(argv, {
37
37
  stdout.write(`Configured keys: ${report.configuredSecretKeys.length === 0 ? 'none' : report.configuredSecretKeys.join(', ')}\n`)
38
38
  stdout.write(`Codex: ${report.codexPresent ? 'found' : 'missing'}\n`)
39
39
  }
40
- return report.supported ? 0 : 1
40
+ if (report.capabilities !== undefined) {
41
+ const status = report.capabilities
42
+ stdout.write(`Capabilities: ${status.ok ? 'valid' : 'needs attention'}${status.reason ? ` (${status.reason})` : ''}\n`)
43
+ if (status.modules) {
44
+ for (const [name, module] of Object.entries(status.modules)) stdout.write(` ${name}: ${module.enabled ? 'enabled' : 'disabled'}${module.provider ? ` (${module.provider})` : ''}\n`)
45
+ stdout.write(` FrontBrain budget: ${status.toolBudget}; exact count requires runtime composition\n`)
46
+ for (const server of status.servers) stdout.write(` MCP ${server.name}: ${server.status}${server.reason ? ` (${server.reason})` : ''}\n`)
47
+ for (const name of status.overrides) stdout.write(` Override: ${name}\n`)
48
+ }
49
+ }
50
+ return report.supported && report.capabilities?.ok !== false ? 0 : 1
41
51
  }
42
52
  if (command !== 'start' && command !== 'config') {
43
53
  stdout.write(`${HELP_TEXT}\n`)
package/src/runtime.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {inspectCapabilities} from './capability-registry.mjs'
1
2
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
2
3
  import { spawn, spawnSync } from 'node:child_process'
3
4
  import {
@@ -430,8 +431,16 @@ export async function inspectDoctor({
430
431
  const executable = resolve(root, target.executable)
431
432
  const settings = desktopSettingsPath({platform, home, environment})
432
433
  let secretKeys = []
434
+ let capabilitiesConfigPath
433
435
  try {
434
436
  const document = JSON.parse(await readFile(settings, 'utf8'))
437
+ // Match settings-store v4 migration/string validation, then backendLaunchSpec's nonempty saved-path override.
438
+ const acceptsV4Fields = document?.version === undefined || (typeof document.version === 'number' && document.version >= 4)
439
+ const candidate = document?.capabilitiesConfigPath
440
+ if (acceptsV4Fields && typeof candidate === 'string' && !/[\u0000-\u001f\u007f]/u.test(candidate)) {
441
+ const normalized = candidate.trim()
442
+ if (normalized && [...normalized].length <= 32768) capabilitiesConfigPath = normalized
443
+ }
435
444
  if (document?.secrets && typeof document.secrets === 'object' && !Array.isArray(document.secrets)) {
436
445
  secretKeys = Object.keys(document.secrets).sort()
437
446
  }
@@ -443,5 +452,11 @@ export async function inspectDoctor({
443
452
  settingsPresent: await access(settings).then(() => true, () => false),
444
453
  configuredSecretKeys: Object.freeze(secretKeys),
445
454
  codexPresent: findCodex(platform),
455
+ capabilities: inspectCapabilities({
456
+ environment: capabilitiesConfigPath === undefined ? environment : {
457
+ ...environment, NOVA_AUDIO_AGENT_CAPABILITIES_CONFIG: capabilitiesConfigPath,
458
+ },
459
+ ...(home === undefined ? {} : {home}),
460
+ }),
446
461
  })
447
462
  }
package/src/target.mjs CHANGED
@@ -1,18 +1,18 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join } from 'node:path'
3
3
 
4
- export const PRODUCT_VERSION = '0.1.1'
4
+ export const PRODUCT_VERSION = '0.2.0'
5
5
  export const RELEASE_REPOSITORY = 'deepnovacore/NovaAudioAgent'
6
6
 
7
7
  const DEFINITIONS = Object.freeze({
8
8
  'darwin-arm64': Object.freeze({
9
9
  artifact: `nova-audio-agent-${PRODUCT_VERSION}-macos-arm64-app.zip`,
10
- executable: 'Nova Audio Agent Ambient Orb.app/Contents/MacOS/Nova Audio Agent Ambient Orb',
10
+ executable: 'Nova Audio Agent Desktop.app/Contents/MacOS/Nova Audio Agent Desktop',
11
11
  archive: 'zip',
12
12
  }),
13
13
  'win32-x64': Object.freeze({
14
14
  artifact: `nova-audio-agent-${PRODUCT_VERSION}-windows-x64-portable.zip`,
15
- executable: 'Nova Audio Agent Ambient Orb.exe',
15
+ executable: 'Nova Audio Agent Desktop.exe',
16
16
  archive: 'zip',
17
17
  }),
18
18
  })
@@ -43,6 +43,7 @@ export function desktopSettingsPath({
43
43
  home = homedir(),
44
44
  environment = process.env,
45
45
  } = {}) {
46
+ // Stable storage identity shared with the renamed desktop (including encrypted settings).
46
47
  const product = 'Nova Audio Agent Ambient Orb'
47
48
  if (platform === 'darwin') {
48
49
  return join(home, 'Library', 'Application Support', product, 'ambient-orb-settings.json')