nova-audio-agent 0.1.2 → 0.2.1

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
@@ -2,57 +2,65 @@
2
2
 
3
3
  **An always-on voice agent with restrained proactivity and voice-controlled workspaces.**
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/nova-audio-agent.svg)](https://www.npmjs.com/package/nova-audio-agent)
6
+ [![License](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://github.com/deepnovacore/NovaAudioAgent/blob/main/LICENSE)
7
+ [![Node.js](https://img.shields.io/badge/Node.js-22%2B-339933.svg)](https://nodejs.org)
8
+
9
+ [Website](https://deepnovacore.github.io/NovaAudioAgent/en) ·
5
10
  [GitHub](https://github.com/deepnovacore/NovaAudioAgent) ·
6
11
  [简体中文](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)
12
+ [Watch the demo (Chinese)](https://youtu.be/t1c-2O-QsxE)
9
13
 
10
14
  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.
15
+ It reports useful milestones, clarifies requests, and lets you steer work through
16
+ voice without narrating every small step.
17
+
18
+ ![Nova in conversation](https://raw.githubusercontent.com/deepnovacore/NovaAudioAgent/v0.2.0/assets/features/conversation.en.png)
13
19
 
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.
20
+ This package provides the `novaaudio` command, which installs and launches the
21
+ desktop application. You do not need to clone or build the repository.
16
22
 
17
- ## Get started
23
+ ## Install
18
24
 
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.
25
+ Requires **Node.js 22.13.0 or newer**, npm, and either **macOS on Apple Silicon**
26
+ or **Windows x64**. Intel Macs and Linux are not covered by the desktop build.
27
+ The application is not yet signed, so macOS Gatekeeper and Windows SmartScreen
28
+ will warn on first launch.
21
29
 
22
30
  ```bash
23
31
  npm install --global nova-audio-agent
32
+ ```
33
+
34
+ ## Configure
35
+
36
+ Open the desktop settings, which downloads the app on first run:
37
+
38
+ ```bash
24
39
  novaaudio config
25
40
  ```
26
41
 
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/getting-started.md).
32
- Allow microphone access when prompted, then launch Nova:
42
+ Then add the keys for the services you want:
43
+
44
+ - **[DashScope](https://platform.qianwenai.com)** — the default Qwen realtime voice service. Required.
45
+ - **[Tavily](https://docs.tavily.com)** — web search. Optional.
46
+ - **Codex** — a logged-in Codex executable, for coding tasks. Optional. See the [setup guide](https://deepnovacore.github.io/NovaAudioAgent/en/docs/getting-started).
47
+
48
+ Allow microphone access when prompted.
49
+
50
+ ## Run
33
51
 
34
52
  ```bash
35
53
  novaaudio
36
54
  ```
37
55
 
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`.
56
+ Hover over the desktop orb to reach its controls.
40
57
 
41
58
  ## Why Nova?
42
59
 
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.
60
+ - **Talk while work continues.** Long-running tasks execute in the background while Nova stays available for conversation.
61
+ - **Progress worth hearing.** Restrained proactivity surfaces meaningful updates instead of reading out every coding event.
62
+ - **Manage workspaces by voice.** Create or switch workspaces and sessions through proposals you confirm.
63
+ - **Steer ongoing work.** The Codex integration uses its native app-server transport, so you can redirect a task while it runs.
56
64
 
57
65
  ## Commands
58
66
 
@@ -64,26 +72,22 @@ for your installed version.
64
72
  | `novaaudio --version` | Print the desktop release version used by the CLI |
65
73
  | `novaaudio --help` | Show command help |
66
74
 
67
- ## Installation details
68
-
69
- The CLI downloads the matching `v0.1.1` desktop release from
70
- [GitHub Releases](https://github.com/deepnovacore/NovaAudioAgent/releases/tag/v0.1.1)
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.
75
+ ## How it works
74
76
 
75
- Documentation-only npm updates may have a newer package version while retaining
76
- the same desktop release. `novaaudio --version` continues to report `0.1.1`.
77
+ The CLI downloads the matching desktop release from
78
+ [GitHub Releases](https://github.com/deepnovacore/NovaAudioAgent/releases) into
79
+ `~/.nova-audio-agent/cli/releases/` and verifies its published SHA-256 digest
80
+ before launching it. It reuses the desktop client's encrypted settings store and
81
+ never reads or prints secret values.
77
82
 
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.
83
+ Run `novaaudio doctor` to see which release is installed and whether your
84
+ configuration is complete.
81
85
 
82
86
  ## Learn more
83
87
 
84
- - [Getting started and integrations](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/getting-started.md)
85
- - [Runtime architecture](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/architecture.md)
86
- - [Design: when should a proactive voice agent speak?](https://github.com/deepnovacore/NovaAudioAgent/blob/main/docs/blog/2026-08-proactive-voice-agent-design-space.md)
88
+ - [Getting started and integrations](https://deepnovacore.github.io/NovaAudioAgent/en/docs/getting-started)
89
+ - [How Nova works](https://deepnovacore.github.io/NovaAudioAgent/en/docs/architecture)
90
+ - [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
91
  - [Report an issue](https://github.com/deepnovacore/NovaAudioAgent/issues)
88
92
  - [Build from source and contribute](https://github.com/deepnovacore/NovaAudioAgent/blob/main/CONTRIBUTING.md)
89
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nova-audio-agent",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Install and launch the Nova Audio Agent desktop client from the command line.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/deepnovacore/NovaAudioAgent.git"
29
29
  },
30
- "homepage": "https://github.com/deepnovacore/NovaAudioAgent#readme",
30
+ "homepage": "https://deepnovacore.github.io/NovaAudioAgent/",
31
31
  "bugs": {
32
32
  "url": "https://github.com/deepnovacore/NovaAudioAgent/issues"
33
33
  },
@@ -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')