@openvulcan/dsh-vulcancode 0.4.3 → 0.4.4

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.
Files changed (33) hide show
  1. package/README.md +30 -5
  2. package/cordis.patch.yml +2 -0
  3. package/node_modules/@openvulcan/dsh-decision-guard/lib/client.js +52 -14
  4. package/node_modules/@openvulcan/dsh-decision-guard/package.json +2 -2
  5. package/node_modules/@openvulcan/dsh-vulcan-account/package.json +2 -2
  6. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/client.js +30 -30
  7. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/committed-fiber.d.ts +18 -0
  8. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/committed-fiber.js +62 -0
  9. package/node_modules/@openvulcan/dsh-vulcan-answers/package.json +1 -1
  10. package/node_modules/@openvulcan/dsh-vulcan-luaskills/package.json +1 -1
  11. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/markdown-memory-guide.md +1 -0
  12. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-curator/SKILL.md +1 -1
  13. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.d.ts +7 -0
  14. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.js +11 -0
  15. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.d.ts +1 -1
  16. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.js +1 -1
  17. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.js +22 -3
  18. package/node_modules/@openvulcan/dsh-vulcan-memory/package.json +1 -1
  19. package/node_modules/@openvulcan/dsh-vulcan-reasoning/package.json +1 -1
  20. package/node_modules/@openvulcan/dsh-vulcan-style/lib/client.js +4 -0
  21. package/node_modules/@openvulcan/dsh-vulcan-style/package.json +1 -1
  22. package/node_modules/@openvulcan/dsh-vulcan-voice-input/LICENSE +21 -0
  23. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/client.js +1567 -0
  24. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/cloud.d.ts +39 -0
  25. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/cloud.js +187 -0
  26. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/contract.d.ts +58 -0
  27. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/contract.js +1 -0
  28. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/index.d.ts +49 -0
  29. package/node_modules/@openvulcan/dsh-vulcan-voice-input/lib/index.js +204 -0
  30. package/node_modules/@openvulcan/dsh-vulcan-voice-input/package.json +46 -0
  31. package/node_modules/@openvulcan/openvulcan-account/package.json +1 -1
  32. package/node_modules/@openvulcan/vulcan-decision/package.json +2 -2
  33. package/package.json +13 -11
@@ -0,0 +1,39 @@
1
+ import { type Credential } from '@openvulcan/openvulcan-account';
2
+ import type { VoiceCatalog, VoiceErrorCode, VoiceSelection } from './contract.js';
3
+ export declare const MAX_SECONDS = 120;
4
+ export declare const MAX_AUDIO_BYTES: number;
5
+ /**
6
+ * A classified voice failure with an HTTP status and optional upstream wait time.
7
+ * 带 HTTP 状态和可选上游等待时间的语音输入错误。
8
+ */
9
+ export declare class VoiceFailure extends Error {
10
+ readonly code: VoiceErrorCode;
11
+ readonly status: number;
12
+ readonly retryAfter?: number | undefined;
13
+ constructor(code: VoiceErrorCode, status: number, retryAfter?: number | undefined);
14
+ }
15
+ /**
16
+ * The validated site catalogue, or null if its microphone channel is disabled.
17
+ * 经校验的站点模型目录。站点麦克风通道停用时为 null。
18
+ */
19
+ export type Publication = VoiceCatalog | null;
20
+ /**
21
+ * Read a body within the supplied byte limit. Oversized streams are cancelled before parsing.
22
+ * 在指定字节限制内读取正文。超限时在解析前取消流。
23
+ */
24
+ export declare function readBounded(message: Request | Response, limit: number, failure: VoiceFailure, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>;
25
+ /**
26
+ * Validate the precise PCM WAV produced by the browser encoder before sending it upstream.
27
+ * 在转发前校验浏览器编码器生成的精确 PCM WAV 格式。
28
+ */
29
+ export declare function validateWav(bytes: Uint8Array): void;
30
+ /**
31
+ * Get the free channel's current catalogue for one verified regional credential.
32
+ * 根据已校验的区域凭据读取免费通道当前的模型目录。
33
+ */
34
+ export declare function getPublication(transport: typeof fetch, credential: Credential, signal: AbortSignal): Promise<Publication>;
35
+ /**
36
+ * Send one bounded recording to the selected publication and return its recognized text.
37
+ * 向已选发布模型发送一段有界录音并返回识别文本。
38
+ */
39
+ export declare function transcribe(transport: typeof fetch, credential: Credential, signal: AbortSignal, selection: VoiceSelection, wav: Uint8Array<ArrayBuffer>): Promise<string>;
@@ -0,0 +1,187 @@
1
+ import { SITES } from '@openvulcan/openvulcan-account';
2
+ // The version-one microphone contract accepts 120 seconds of canonical 16 kHz mono PCM.
3
+ // 第一版麦克风协议接受最长 120 秒的标准 16 kHz 单声道 PCM。
4
+ export const MAX_SECONDS = 120;
5
+ // The audio and JSON limits apply before parsing or copying upstream data.
6
+ // 在解析或复制上游数据前执行音频和 JSON 体积上限。
7
+ export const MAX_AUDIO_BYTES = 44 + MAX_SECONDS * 16_000 * 2;
8
+ const MAX_JSON_BYTES = 1024 * 1024;
9
+ // Only these paths under the regional Auth origin serve the free microphone channel.
10
+ // 只有各站点 Auth 域名下的这些路径提供免费麦克风通道。
11
+ const BASE = '/api/v1/vulcancode/voice-input/';
12
+ /**
13
+ * A classified voice failure with an HTTP status and optional upstream wait time.
14
+ * 带 HTTP 状态和可选上游等待时间的语音输入错误。
15
+ */
16
+ export class VoiceFailure extends Error {
17
+ code;
18
+ status;
19
+ retryAfter;
20
+ constructor(code, status, retryAfter) {
21
+ super(code);
22
+ this.code = code;
23
+ this.status = status;
24
+ this.retryAfter = retryAfter;
25
+ }
26
+ }
27
+ /**
28
+ * Read a body within the supplied byte limit. Oversized streams are cancelled before parsing.
29
+ * 在指定字节限制内读取正文。超限时在解析前取消流。
30
+ */
31
+ export async function readBounded(message, limit, failure, signal) {
32
+ const declared = message.headers.get('content-length');
33
+ if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > limit)) {
34
+ void message.body?.cancel().catch(() => { });
35
+ throw failure;
36
+ }
37
+ if (message.body === null)
38
+ return new Uint8Array(0);
39
+ const reader = message.body.getReader();
40
+ const bytes = new Uint8Array(limit);
41
+ let length = 0;
42
+ // Cancel a slow upload when its caller, authorization or deadline ends.
43
+ // 调用方、设备授权或截止时间结束时取消缓慢上传。
44
+ const cancel = () => { void reader.cancel().catch(() => { }); };
45
+ signal?.addEventListener('abort', cancel, { once: true });
46
+ try {
47
+ if (signal?.aborted)
48
+ cancel();
49
+ for (;;) {
50
+ const chunk = await reader.read();
51
+ signal?.throwIfAborted();
52
+ if (chunk.done)
53
+ return bytes.subarray(0, length);
54
+ if (length + chunk.value.byteLength > limit) {
55
+ void reader.cancel().catch(() => { });
56
+ throw failure;
57
+ }
58
+ bytes.set(chunk.value, length);
59
+ length += chunk.value.byteLength;
60
+ }
61
+ }
62
+ finally {
63
+ signal?.removeEventListener('abort', cancel);
64
+ reader.releaseLock();
65
+ }
66
+ }
67
+ /**
68
+ * Validate the precise PCM WAV produced by the browser encoder before sending it upstream.
69
+ * 在转发前校验浏览器编码器生成的精确 PCM WAV 格式。
70
+ */
71
+ export function validateWav(bytes) {
72
+ if (bytes.length <= 44 || bytes.length > MAX_AUDIO_BYTES || bytes.length % 2 !== 0) {
73
+ throw new VoiceFailure('invalid_audio', 400);
74
+ }
75
+ const wav = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
76
+ if (wav.getUint32(0) !== 0x52494646 || wav.getUint32(4, true) !== bytes.length - 8 ||
77
+ wav.getUint32(8) !== 0x57415645 || wav.getUint32(12) !== 0x666d7420 || wav.getUint32(16, true) !== 16 ||
78
+ wav.getUint16(20, true) !== 1 || wav.getUint16(22, true) !== 1 || wav.getUint32(24, true) !== 16_000 ||
79
+ wav.getUint32(28, true) !== 32_000 || wav.getUint16(32, true) !== 2 || wav.getUint16(34, true) !== 16 ||
80
+ wav.getUint32(36) !== 0x64617461 || wav.getUint32(40, true) !== bytes.length - 44) {
81
+ throw new VoiceFailure('invalid_audio', 400);
82
+ }
83
+ }
84
+ /**
85
+ * Narrow a JSON object before reading protocol fields.
86
+ * 在读取协议字段前将 JSON 值缩窄为对象。
87
+ */
88
+ function object(value) {
89
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
90
+ }
91
+ /**
92
+ * Validate the version-one catalogue and the reference to its published default.
93
+ * 校验第一版模型目录及其已发布的默认模型引用。
94
+ */
95
+ function parsePublication(value) {
96
+ if (!object(value) || value.contract_version !== 1 || value.max_duration_seconds !== MAX_SECONDS ||
97
+ typeof value.available !== 'boolean' || typeof value.default_model_id !== 'string' ||
98
+ !Array.isArray(value.models))
99
+ throw new VoiceFailure('unavailable', 502);
100
+ if (!value.available && value.default_model_id === '' && value.models.length === 0)
101
+ return null;
102
+ if (!value.available || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.default_model_id)) {
103
+ throw new VoiceFailure('unavailable', 502);
104
+ }
105
+ // Validate every entry before exposing it as a selectable model or header value.
106
+ // 逐条验证目录,再将条目用于模型选择或请求头。
107
+ const models = [];
108
+ const ids = new Set();
109
+ for (const model of value.models) {
110
+ if (!object(model) || typeof model.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(model.id) ||
111
+ ids.has(model.id) || model.category !== 'speech_transcribe' || typeof model.display_name !== 'string' ||
112
+ model.display_name.trim() === '' || !Array.isArray(model.supported_languages) ||
113
+ !model.supported_languages.every((language) => typeof language === 'string' && /^[a-z]{2,3}(?:-[a-z]{2})?$/.test(language)) ||
114
+ new Set(model.supported_languages).size !== model.supported_languages.length) {
115
+ throw new VoiceFailure('unavailable', 502);
116
+ }
117
+ ids.add(model.id);
118
+ models.push({ id: model.id, label: model.display_name, languages: model.supported_languages });
119
+ }
120
+ if (!ids.has(value.default_model_id))
121
+ throw new VoiceFailure('unavailable', 502);
122
+ return { defaultModelId: value.default_model_id, models };
123
+ }
124
+ /**
125
+ * Send one native request to the account's Auth domain. Redirects would move the credential or audio.
126
+ * 向账号的 Auth 域名发送一次原生请求。重定向会转移凭据或录音,因此禁止重定向。
127
+ */
128
+ async function send(transport, credential, path, signal, upload) {
129
+ const origin = SITES[credential.site].auth;
130
+ if (credential.auth_origin !== origin)
131
+ throw new VoiceFailure('signed_out', 401);
132
+ const headers = new Headers({ authorization: `Auth ${credential.access_token}`, accept: 'application/json' });
133
+ if (upload !== undefined) {
134
+ headers.set('content-type', 'audio/wav');
135
+ headers.set('x-vulcan-voice-model', upload.selection.modelId);
136
+ if (upload.selection.language !== undefined)
137
+ headers.set('x-vulcan-asr-language', upload.selection.language);
138
+ }
139
+ const response = await transport(`${origin}${BASE}${path}`, {
140
+ method: upload === undefined ? 'GET' : 'POST',
141
+ headers,
142
+ redirect: 'error',
143
+ signal: AbortSignal.any([signal, AbortSignal.timeout(upload === undefined ? 5000 : 65000)]),
144
+ ...(upload === undefined ? {} : { body: upload.wav }),
145
+ });
146
+ if (response.status === 401) {
147
+ void response.body?.cancel().catch(() => { });
148
+ throw new VoiceFailure('signed_out', 401);
149
+ }
150
+ const bytes = await readBounded(response, MAX_JSON_BYTES, new VoiceFailure('unavailable', 502));
151
+ const value = JSON.parse(new TextDecoder().decode(bytes));
152
+ if (response.status === 200)
153
+ return value;
154
+ const code = object(value) && object(value.error) ? value.error.code : undefined;
155
+ if ((response.status === 404 && code === 'voice_model_unavailable') ||
156
+ (response.status === 400 && code === 'unsupported_voice_language'))
157
+ throw new VoiceFailure('selection_unavailable', 409);
158
+ const wait = Number(response.headers.get('retry-after'));
159
+ const retryAfter = Number.isInteger(wait) && wait >= 1 && wait <= 86_400 ? wait : undefined;
160
+ if (response.status === 429 && code === 'voice_rate_limited')
161
+ throw new VoiceFailure('rate_limited', 429, retryAfter);
162
+ if (response.status === 503 && code === 'voice_busy')
163
+ throw new VoiceFailure('busy', 503, retryAfter);
164
+ if (code === 'invalid_voice_audio' || code === 'voice_audio_too_large' || code === 'unsupported_voice_audio') {
165
+ throw new VoiceFailure('invalid_audio', 400);
166
+ }
167
+ throw new VoiceFailure('unavailable', 502);
168
+ }
169
+ /**
170
+ * Get the free channel's current catalogue for one verified regional credential.
171
+ * 根据已校验的区域凭据读取免费通道当前的模型目录。
172
+ */
173
+ export async function getPublication(transport, credential, signal) {
174
+ return parsePublication(await send(transport, credential, 'capabilities', signal));
175
+ }
176
+ /**
177
+ * Send one bounded recording to the selected publication and return its recognized text.
178
+ * 向已选发布模型发送一段有界录音并返回识别文本。
179
+ */
180
+ export async function transcribe(transport, credential, signal, selection, wav) {
181
+ const result = await send(transport, credential, 'transcriptions', signal, { selection, wav });
182
+ if (!object(result) || typeof result.text !== 'string' ||
183
+ typeof result.request_id !== 'string' || result.request_id.length === 0) {
184
+ throw new VoiceFailure('unavailable', 502);
185
+ }
186
+ return result.text;
187
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Public microphone state returned by the dsh Host. Ready state binds a recording to one authorization.
3
+ * dsh 宿主返回的麦克风公开状态。就绪状态将录音绑定至一次设备授权。
4
+ */
5
+ export type VoiceState = {
6
+ readonly status: 'signed_out' | 'unavailable';
7
+ } | {
8
+ readonly status: 'disabled';
9
+ readonly site: 'cn' | 'global';
10
+ } | {
11
+ readonly status: 'ready';
12
+ readonly site: 'cn' | 'global';
13
+ readonly authorizationId: string;
14
+ readonly catalog: VoiceCatalog;
15
+ readonly maxSeconds: number;
16
+ };
17
+ /**
18
+ * Localized failure keys shared by the Host response and composer.
19
+ * 宿主响应与输入框共用的本地化错误键。
20
+ */
21
+ export type VoiceErrorCode = 'signed_out' | 'unavailable' | 'selection_unavailable' | 'invalid_audio' | 'rate_limited' | 'busy';
22
+ /**
23
+ * The Host returns only text or a stable failure key and optional server wait time.
24
+ * 宿主仅返回转写文本,或稳定的错误键和可选的服务端等待时间。
25
+ */
26
+ export type VoiceReply = {
27
+ readonly ok: true;
28
+ readonly text: string;
29
+ } | {
30
+ readonly ok: false;
31
+ readonly code: VoiceErrorCode;
32
+ readonly retryAfter?: number;
33
+ };
34
+ /**
35
+ * One published transcription model and its supported language hints.
36
+ * 一个已发布的转写模型及它支持的优先语言。
37
+ */
38
+ export interface VoiceModel {
39
+ readonly id: string;
40
+ readonly label: string;
41
+ readonly languages: readonly string[];
42
+ }
43
+ /**
44
+ * The regional model catalog used by both voice controls.
45
+ * 两处语音控件共用的站点模型目录。
46
+ */
47
+ export interface VoiceCatalog {
48
+ readonly defaultModelId: string;
49
+ readonly models: readonly VoiceModel[];
50
+ }
51
+ /**
52
+ * The exact model and optional language captured when recording begins.
53
+ * 开始录音时捕获的模型及可选优先语言。
54
+ */
55
+ export interface VoiceSelection {
56
+ readonly modelId: string;
57
+ readonly language?: string;
58
+ }
@@ -0,0 +1,49 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { OpenVulcanAccountService } from '@openvulcan/dsh-vulcan-account';
3
+ import type { Credential } from '@openvulcan/openvulcan-account';
4
+ export declare const name = "vulcan-voice-input";
5
+ /**
6
+ * The identity captured before an async operation, with its cancellation generation.
7
+ * 异步操作开始前捕获的账号身份及其取消信号。
8
+ */
9
+ interface AccountBinding {
10
+ readonly account: Pick<OpenVulcanAccountService, 'status' | 'credential'>;
11
+ readonly site: Credential['site'];
12
+ readonly id: string;
13
+ readonly signal: AbortSignal;
14
+ }
15
+ /**
16
+ * Gate the free microphone channel on the active OpenVulcan device authorization.
17
+ * 仅允许当前有效的 OpenVulcan 设备授权使用免费麦克风通道。
18
+ */
19
+ export declare class VoiceInput {
20
+ #private;
21
+ private readonly getAccount;
22
+ private readonly transport;
23
+ /**
24
+ * Read the active account from its owning Cordis service and use the supplied native transport.
25
+ * 从所属 Cordis 服务读取当前账号,并使用指定的原生传输实现。
26
+ */
27
+ constructor(getAccount: () => AccountBinding['account'] | undefined, transport?: typeof fetch);
28
+ /**
29
+ * Discard readiness and cancel work associated with the previous device authorization.
30
+ * 清除可用状态并取消上一次设备授权所属的任务。
31
+ */
32
+ accountChanged(): void;
33
+ /**
34
+ * Report login, regional publication and the default model. Refresh is an explicit settings action.
35
+ * 返回登录状态、区域服务和默认模型。立即刷新由设置页显式触发。
36
+ */
37
+ status(request: Request): Promise<Response>;
38
+ /**
39
+ * Validate, submit and return one recording. Only one bounded upload is read at a time.
40
+ * 校验并提交一段录音,同一时刻只读取一份有界上传。
41
+ */
42
+ upload(request: Request): Promise<Response>;
43
+ }
44
+ /**
45
+ * Install the settings and audio routes within dsh's authenticated owner connection.
46
+ * 在 dsh 已认证的所有者连接内挂载设置与音频路由。
47
+ */
48
+ export declare function apply(ctx: Context): void;
49
+ export {};
@@ -0,0 +1,204 @@
1
+ import { getPublication, MAX_AUDIO_BYTES, MAX_SECONDS, readBounded, transcribe, validateWav, VoiceFailure } from './cloud.js';
2
+ // Cordis entry identity and owner-only routes used by the browser contribution.
3
+ // Cordis 条目标识与浏览器扩展使用的仅限所有者路由。
4
+ export const name = 'vulcan-voice-input';
5
+ const BASE = '/api/vulcan-voice-input/';
6
+ // Status reads can reuse a recent directory; actual transcription always refreshes it.
7
+ // 状态查询可复用近期目录,实际转写每次都重新读取目录。
8
+ const CATALOG_TTL_MS = 60_000;
9
+ /**
10
+ * Encode an owner-only response without allowing it to enter an HTTP cache.
11
+ * 编码仅限所有者的响应,并禁止 HTTP 缓存。
12
+ */
13
+ function json(body, status = 200) {
14
+ return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
15
+ }
16
+ /**
17
+ * Drain a rejected, length-bounded browser upload without retaining audio so dsh can flush the error.
18
+ * 丢弃长度有界的浏览器上传且不保留音频,让 dsh 能完整返回错误。
19
+ */
20
+ async function discardRejectedUpload(request) {
21
+ const length = request.headers.get('content-length');
22
+ if (request.body === null || request.bodyUsed || length === null || !/^\d+$/.test(length) ||
23
+ Number(length) > MAX_AUDIO_BYTES)
24
+ return;
25
+ // dsh closes an unread streaming request. A known-length HTTP body cannot exceed its framing length.
26
+ // dsh 会关闭未读完的流式请求。已知长度的 HTTP 正文不会超过其分帧长度。
27
+ const signal = AbortSignal.any([request.signal, AbortSignal.timeout(3000)]);
28
+ await request.body.pipeTo(new WritableStream(), { signal }).catch(() => { });
29
+ }
30
+ /**
31
+ * Gate the free microphone channel on the active OpenVulcan device authorization.
32
+ * 仅允许当前有效的 OpenVulcan 设备授权使用免费麦克风通道。
33
+ */
34
+ export class VoiceInput {
35
+ getAccount;
36
+ transport;
37
+ // An account change invalidates cached readiness and cancels upstream work.
38
+ // 账号变化会清除缓存的可用状态并取消上游任务。
39
+ #epoch = new AbortController();
40
+ #catalog;
41
+ #uploading = false;
42
+ /**
43
+ * Read the active account from its owning Cordis service and use the supplied native transport.
44
+ * 从所属 Cordis 服务读取当前账号,并使用指定的原生传输实现。
45
+ */
46
+ constructor(getAccount, transport = fetch) {
47
+ this.getAccount = getAccount;
48
+ this.transport = transport;
49
+ }
50
+ /**
51
+ * Discard readiness and cancel work associated with the previous device authorization.
52
+ * 清除可用状态并取消上一次设备授权所属的任务。
53
+ */
54
+ accountChanged() {
55
+ this.#catalog = undefined;
56
+ this.#epoch.abort();
57
+ this.#epoch = new AbortController();
58
+ }
59
+ /**
60
+ * Report login, regional publication and the default model. Refresh is an explicit settings action.
61
+ * 返回登录状态、区域服务和默认模型。立即刷新由设置页显式触发。
62
+ */
63
+ async status(request) {
64
+ try {
65
+ const binding = await this.#binding();
66
+ const cached = this.#catalog;
67
+ let publication;
68
+ if (new URL(request.url).searchParams.get('refresh') !== '1' &&
69
+ cached !== undefined && cached.id === binding.id && cached.site === binding.site && cached.expires > Date.now()) {
70
+ publication = cached.publication;
71
+ }
72
+ else {
73
+ const credential = await this.#credential(binding);
74
+ publication = await getPublication(this.transport, credential, AbortSignal.any([binding.signal, request.signal]));
75
+ await this.#confirm(binding);
76
+ this.#catalog = { id: binding.id, site: binding.site, expires: Date.now() + CATALOG_TTL_MS, publication };
77
+ }
78
+ return publication === null ? json({ status: 'disabled', site: binding.site }) : json({
79
+ status: 'ready', site: binding.site, authorizationId: binding.id, catalog: publication, maxSeconds: MAX_SECONDS,
80
+ });
81
+ }
82
+ catch (error) {
83
+ return json({ status: error instanceof VoiceFailure && error.code === 'signed_out' ? 'signed_out' : 'unavailable' });
84
+ }
85
+ }
86
+ /**
87
+ * Validate, submit and return one recording. Only one bounded upload is read at a time.
88
+ * 校验并提交一段录音,同一时刻只读取一份有界上传。
89
+ */
90
+ async upload(request) {
91
+ let ownsUpload = false;
92
+ try {
93
+ const binding = await this.#binding();
94
+ if (request.headers.get('x-vulcan-voice-authorization') !== binding.id)
95
+ throw new VoiceFailure('signed_out', 401);
96
+ if (request.headers.get('content-type') !== 'audio/wav' || request.headers.has('content-encoding')) {
97
+ throw new VoiceFailure('invalid_audio', 415);
98
+ }
99
+ if (this.#uploading)
100
+ throw new VoiceFailure('busy', 503);
101
+ this.#uploading = ownsUpload = true;
102
+ const signal = AbortSignal.any([binding.signal, request.signal, AbortSignal.timeout(80_000)]);
103
+ const wav = await readBounded(request, MAX_AUDIO_BYTES, new VoiceFailure('invalid_audio', 413), signal);
104
+ validateWav(wav);
105
+ const credential = await this.#credential(binding);
106
+ const publication = await getPublication(this.transport, credential, signal);
107
+ await this.#confirm(binding);
108
+ if (publication === null)
109
+ throw new VoiceFailure('unavailable', 503);
110
+ // Validate the recording's frozen selection against the account's current regional catalog.
111
+ // 对照该账号的当前站点目录,验证录音开始时冻结的选项。
112
+ const modelId = request.headers.get('x-vulcan-voice-model') ?? publication.defaultModelId;
113
+ const language = request.headers.get('x-vulcan-asr-language');
114
+ const model = publication.models.find(entry => entry.id === modelId);
115
+ if (model === undefined || (language !== null && !model.languages.includes(language))) {
116
+ throw new VoiceFailure('selection_unavailable', 409);
117
+ }
118
+ const text = await transcribe(this.transport, credential, signal, {
119
+ modelId, ...(language === null ? {} : { language }),
120
+ }, wav);
121
+ await this.#confirm(binding);
122
+ return json({ ok: true, text });
123
+ }
124
+ catch (error) {
125
+ await discardRejectedUpload(request);
126
+ const failure = error instanceof VoiceFailure ? error : new VoiceFailure('unavailable', 502);
127
+ return json({
128
+ ok: false, code: failure.code,
129
+ ...(failure.retryAfter === undefined ? {} : { retryAfter: failure.retryAfter }),
130
+ }, failure.status);
131
+ }
132
+ finally {
133
+ if (ownsUpload)
134
+ this.#uploading = false;
135
+ }
136
+ }
137
+ /**
138
+ * Capture the current account and its verified local authorization state.
139
+ * 捕获当前账号服务及已校验的本地设备授权状态。
140
+ */
141
+ async #binding() {
142
+ const signal = this.#epoch.signal;
143
+ const account = this.getAccount();
144
+ if (account === undefined)
145
+ throw new VoiceFailure('signed_out', 401);
146
+ const status = await account.status();
147
+ if (signal.aborted || this.getAccount() !== account || !status.signedIn || status.reauthRequired ||
148
+ status.site === undefined || status.authorizationId === undefined)
149
+ throw new VoiceFailure('signed_out', 401);
150
+ return { account, id: status.authorizationId, site: status.site, signal };
151
+ }
152
+ /**
153
+ * Recheck the account after awaits so work cannot move to a new authorization.
154
+ * 在异步等待后复核账号,防止任务跨设备授权继续执行。
155
+ */
156
+ async #confirm(binding) {
157
+ const current = await this.#binding();
158
+ if (binding.signal.aborted || binding.account !== current.account || binding.id !== current.id ||
159
+ binding.site !== current.site)
160
+ throw new VoiceFailure('signed_out', 401);
161
+ }
162
+ /**
163
+ * Refresh a native credential through the account service and verify its owner.
164
+ * 通过账号服务刷新原生凭据,并验证其归属。
165
+ */
166
+ async #credential(binding) {
167
+ const credential = await binding.account.credential().catch(() => { throw new VoiceFailure('signed_out', 401); });
168
+ await this.#confirm(binding);
169
+ if (credential.authorization_id !== binding.id || credential.site !== binding.site) {
170
+ throw new VoiceFailure('signed_out', 401);
171
+ }
172
+ return credential;
173
+ }
174
+ }
175
+ /**
176
+ * Install the settings and audio routes within dsh's authenticated owner connection.
177
+ * 在 dsh 已认证的所有者连接内挂载设置与音频路由。
178
+ */
179
+ export function apply(ctx) {
180
+ // Cordis wraps services on each lookup, so capture one proxy for each injection lifetime.
181
+ // Cordis 每次查找都会包装服务,因此在每次注入的生命周期内复用同一个代理。
182
+ let account;
183
+ const voice = new VoiceInput(() => account);
184
+ ctx.inject(['openvulcanAccount'], child => {
185
+ child.effect(() => {
186
+ const activeAccount = child.openvulcanAccount;
187
+ const stop = activeAccount.onChange(() => voice.accountChanged());
188
+ account = activeAccount;
189
+ return () => {
190
+ account = undefined;
191
+ stop();
192
+ voice.accountChanged();
193
+ };
194
+ }, 'vulcan-voice-input: account');
195
+ });
196
+ ctx.inject(['connection'], child => {
197
+ child.effect(() => child.connection.fetch.register({
198
+ path: `${BASE}state`, methods: ['GET'], requestBody: 'buffered', fetch: request => voice.status(request),
199
+ }), 'vulcan-voice-input: state');
200
+ child.effect(() => child.connection.fetch.register({
201
+ path: `${BASE}transcriptions`, methods: ['POST'], requestBody: 'streaming', fetch: request => voice.upload(request),
202
+ }), 'vulcan-voice-input: transcriptions');
203
+ });
204
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@openvulcan/dsh-vulcan-voice-input",
3
+ "version": "0.4.4",
4
+ "description": "Microphone dictation in DeepSeek Harness using OpenVulcan's free transcription service.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "types": "lib/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": "./lib/client.js",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "!lib/**/*.test.*"
20
+ ],
21
+ "engines": {
22
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
23
+ },
24
+ "dsh": {
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-locale",
29
+ "@deepseek-ai/dsh-client-ui-conversation",
30
+ "@deepseek-ai/dsh-client-ui-settings",
31
+ "@deepseek-ai/dsh-client-ui-settings-general",
32
+ "@deepseek-ai/dsh-client-ui-sidebar"
33
+ ]
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@openvulcan/openvulcan-account": "0.4.4"
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/cordis": "^4.0.5",
41
+ "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.2"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvulcan/openvulcan-account",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "OpenVulcan account: device sign-in, credential storage and refresh, shared by the Vulcan tools.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvulcan/vulcan-decision",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Vulcan Decision: judge AI coding tool calls before they run, with OpenVulcan cloud endpoints or an optional local model runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,6 +35,6 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@openvulcan/openvulcan-account": "0.4.3"
38
+ "@openvulcan/openvulcan-account": "0.4.4"
39
39
  }
40
40
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openvulcan/dsh-vulcancode",
3
- "version": "0.4.3",
4
- "description": "VulcanCode for DeepSeek Harness (dsh): OpenVulcan account, Vulcan Decision permission guard, interactive answers, LuaSkills, long-term memory, custom thinking levels and the VulcanCode look. Community plugins, not affiliated with DeepSeek.",
3
+ "version": "0.4.4",
4
+ "description": "VulcanCode for DeepSeek Harness (dsh): OpenVulcan account, Vulcan Decision permission guard, interactive answers, LuaSkills, long-term memory, custom thinking levels, voice input and the VulcanCode look. Community plugins, not affiliated with DeepSeek.",
5
5
  "license": "MIT",
6
6
  "icon": "./assets/openvulcan-icon.svg",
7
7
  "repository": {
@@ -27,18 +27,19 @@
27
27
  }
28
28
  },
29
29
  "dependencies": {
30
- "@openvulcan/dsh-decision-guard": "0.4.3",
31
- "@openvulcan/dsh-vulcan-account": "0.4.3",
32
- "@openvulcan/dsh-vulcan-answers": "0.4.3",
33
- "@openvulcan/dsh-vulcan-luaskills": "0.4.3",
34
- "@openvulcan/dsh-vulcan-memory": "0.4.3",
35
- "@openvulcan/dsh-vulcan-reasoning": "0.4.3",
36
- "@openvulcan/dsh-vulcan-style": "0.4.3",
30
+ "@openvulcan/dsh-decision-guard": "0.4.4",
31
+ "@openvulcan/dsh-vulcan-account": "0.4.4",
32
+ "@openvulcan/dsh-vulcan-answers": "0.4.4",
33
+ "@openvulcan/dsh-vulcan-luaskills": "0.4.4",
34
+ "@openvulcan/dsh-vulcan-memory": "0.4.4",
35
+ "@openvulcan/dsh-vulcan-reasoning": "0.4.4",
36
+ "@openvulcan/dsh-vulcan-style": "0.4.4",
37
+ "@openvulcan/dsh-vulcan-voice-input": "0.4.4",
37
38
  "fflate": "^0.8.3",
38
39
  "mdast-util-from-markdown": "^2.0.2",
39
40
  "zod": "4.4.3",
40
- "@openvulcan/openvulcan-account": "0.4.3",
41
- "@openvulcan/vulcan-decision": "0.4.3"
41
+ "@openvulcan/openvulcan-account": "0.4.4",
42
+ "@openvulcan/vulcan-decision": "0.4.4"
42
43
  },
43
44
  "publishConfig": {
44
45
  "access": "public"
@@ -51,6 +52,7 @@
51
52
  "@openvulcan/dsh-vulcan-memory",
52
53
  "@openvulcan/dsh-vulcan-reasoning",
53
54
  "@openvulcan/dsh-vulcan-style",
55
+ "@openvulcan/dsh-vulcan-voice-input",
54
56
  "@openvulcan/openvulcan-account",
55
57
  "@openvulcan/vulcan-decision"
56
58
  ]