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 +82 -13
- package/package.json +1 -1
- package/release-assets.json +4 -4
- package/src/capability-registry.mjs +266 -0
- package/src/command.mjs +11 -1
- package/src/runtime.mjs +15 -0
- package/src/target.mjs +4 -3
package/README.md
CHANGED
|
@@ -1,24 +1,93 @@
|
|
|
1
|
-
# Nova Audio Agent
|
|
1
|
+
# Nova Audio Agent
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
23
|
+
npm install --global nova-audio-agent
|
|
24
|
+
novaaudio config
|
|
7
25
|
```
|
|
8
26
|
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
package/release-assets.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"repository": "deepnovacore/NovaAudioAgent",
|
|
4
4
|
"targets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"artifact": "nova-audio-agent-0.
|
|
7
|
-
"executable": "Nova Audio Agent
|
|
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.
|
|
12
|
-
"executable": "Nova Audio Agent
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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')
|