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 +49 -45
- package/package.json +2 -2
- 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
|
@@ -2,57 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
**An always-on voice agent with restrained proactivity and voice-controlled workspaces.**
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/nova-audio-agent)
|
|
6
|
+
[](https://github.com/deepnovacore/NovaAudioAgent/blob/main/LICENSE)
|
|
7
|
+
[](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
|
|
12
|
-
|
|
15
|
+
It reports useful milestones, clarifies requests, and lets you steer work through
|
|
16
|
+
voice without narrating every small step.
|
|
17
|
+
|
|
18
|
+

|
|
13
19
|
|
|
14
|
-
This
|
|
15
|
-
desktop application. You do not need to clone or build the repository
|
|
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
|
-
##
|
|
23
|
+
## Install
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
or **Windows x64**.
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
[
|
|
30
|
-
|
|
31
|
-
[setup guide](https://github.
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
- **
|
|
46
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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.
|
|
85
|
-
- [
|
|
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
|
|
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.
|
|
30
|
+
"homepage": "https://deepnovacore.github.io/NovaAudioAgent/",
|
|
31
31
|
"bugs": {
|
|
32
32
|
"url": "https://github.com/deepnovacore/NovaAudioAgent/issues"
|
|
33
33
|
},
|
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')
|