draken 0.1.6 → 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.
Files changed (301) hide show
  1. package/ACCEPTANCE.md +31 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +129 -0
  4. package/MEMORY.md +98 -0
  5. package/PERSONA.md +67 -0
  6. package/README.md +156 -78
  7. package/SETUP.md +111 -0
  8. package/SOURCES.md +75 -0
  9. package/TRAY.md +54 -0
  10. package/VOICE.md +118 -0
  11. package/dist/auth/middleware.d.ts +3 -1
  12. package/dist/auth/middleware.d.ts.map +1 -1
  13. package/dist/auth/middleware.js +20 -15
  14. package/dist/auth/middleware.js.map +1 -1
  15. package/dist/cli.js +77 -113
  16. package/dist/cli.js.map +1 -1
  17. package/dist/db/agent-migration.d.ts +7 -0
  18. package/dist/db/agent-migration.d.ts.map +1 -0
  19. package/dist/db/agent-migration.js +36 -0
  20. package/dist/db/agent-migration.js.map +1 -0
  21. package/dist/db/evidence.d.ts +37 -0
  22. package/dist/db/evidence.d.ts.map +1 -0
  23. package/dist/db/evidence.js +110 -0
  24. package/dist/db/evidence.js.map +1 -0
  25. package/dist/db/pointer-migration.d.ts +7 -0
  26. package/dist/db/pointer-migration.d.ts.map +1 -0
  27. package/dist/db/pointer-migration.js +117 -0
  28. package/dist/db/pointer-migration.js.map +1 -0
  29. package/dist/db/retained-text.d.ts +8 -0
  30. package/dist/db/retained-text.d.ts.map +1 -0
  31. package/dist/db/retained-text.js +36 -0
  32. package/dist/db/retained-text.js.map +1 -0
  33. package/dist/db/schema.d.ts +1 -5
  34. package/dist/db/schema.d.ts.map +1 -1
  35. package/dist/db/schema.js +200 -55
  36. package/dist/db/schema.js.map +1 -1
  37. package/dist/db/snapshot-pointers.d.ts +5 -0
  38. package/dist/db/snapshot-pointers.d.ts.map +1 -0
  39. package/dist/db/snapshot-pointers.js +174 -0
  40. package/dist/db/snapshot-pointers.js.map +1 -0
  41. package/dist/memory/automatic.d.ts +38 -0
  42. package/dist/memory/automatic.d.ts.map +1 -0
  43. package/dist/memory/automatic.js +155 -0
  44. package/dist/memory/automatic.js.map +1 -0
  45. package/dist/memory/context.d.ts +12 -0
  46. package/dist/memory/context.d.ts.map +1 -0
  47. package/dist/memory/context.js +59 -0
  48. package/dist/memory/context.js.map +1 -0
  49. package/dist/memory/embedding-pool.d.ts +28 -0
  50. package/dist/memory/embedding-pool.d.ts.map +1 -0
  51. package/dist/memory/embedding-pool.js +135 -0
  52. package/dist/memory/embedding-pool.js.map +1 -0
  53. package/dist/memory/embedding-worker.d.ts +2 -0
  54. package/dist/memory/embedding-worker.d.ts.map +1 -0
  55. package/dist/memory/embedding-worker.js +32 -0
  56. package/dist/memory/embedding-worker.js.map +1 -0
  57. package/dist/memory/index.d.ts +26 -0
  58. package/dist/memory/index.d.ts.map +1 -0
  59. package/dist/memory/index.js +283 -0
  60. package/dist/memory/index.js.map +1 -0
  61. package/dist/memory/model.d.ts +15 -0
  62. package/dist/memory/model.d.ts.map +1 -0
  63. package/dist/memory/model.js +152 -0
  64. package/dist/memory/model.js.map +1 -0
  65. package/dist/memory/service.d.ts +8 -0
  66. package/dist/memory/service.d.ts.map +1 -0
  67. package/dist/memory/service.js +217 -0
  68. package/dist/memory/service.js.map +1 -0
  69. package/dist/memory/sources.d.ts +15 -0
  70. package/dist/memory/sources.d.ts.map +1 -0
  71. package/dist/memory/sources.js +87 -0
  72. package/dist/memory/sources.js.map +1 -0
  73. package/dist/memory/stamps.d.ts +13 -0
  74. package/dist/memory/stamps.d.ts.map +1 -0
  75. package/dist/memory/stamps.js +101 -0
  76. package/dist/memory/stamps.js.map +1 -0
  77. package/dist/memory/storage.d.ts +5 -0
  78. package/dist/memory/storage.d.ts.map +1 -0
  79. package/dist/memory/storage.js +107 -0
  80. package/dist/memory/storage.js.map +1 -0
  81. package/dist/memory/types.d.ts +68 -0
  82. package/dist/memory/types.d.ts.map +1 -0
  83. package/dist/memory/types.js +8 -0
  84. package/dist/memory/types.js.map +1 -0
  85. package/dist/memory/worker.d.ts +2 -0
  86. package/dist/memory/worker.d.ts.map +1 -0
  87. package/dist/memory/worker.js +82 -0
  88. package/dist/memory/worker.js.map +1 -0
  89. package/dist/npm-cli.d.ts +4 -0
  90. package/dist/npm-cli.d.ts.map +1 -0
  91. package/dist/npm-cli.js +36 -0
  92. package/dist/npm-cli.js.map +1 -0
  93. package/dist/pi/runtime.d.ts +34 -0
  94. package/dist/pi/runtime.d.ts.map +1 -0
  95. package/dist/pi/runtime.js +210 -0
  96. package/dist/pi/runtime.js.map +1 -0
  97. package/dist/pi/setup.d.ts +5 -0
  98. package/dist/pi/setup.d.ts.map +1 -0
  99. package/dist/pi/setup.js +147 -0
  100. package/dist/pi/setup.js.map +1 -0
  101. package/dist/routes/auth.d.ts.map +1 -1
  102. package/dist/routes/auth.js +3 -1
  103. package/dist/routes/auth.js.map +1 -1
  104. package/dist/routes/live-voice.d.ts +3 -0
  105. package/dist/routes/live-voice.d.ts.map +1 -0
  106. package/dist/routes/live-voice.js +49 -0
  107. package/dist/routes/live-voice.js.map +1 -0
  108. package/dist/routes/{browse.d.ts → memory.d.ts} +1 -1
  109. package/dist/routes/memory.d.ts.map +1 -0
  110. package/dist/routes/memory.js +18 -0
  111. package/dist/routes/memory.js.map +1 -0
  112. package/dist/routes/{tasks.d.ts → sync.d.ts} +1 -1
  113. package/dist/routes/sync.d.ts.map +1 -0
  114. package/dist/routes/sync.js +127 -0
  115. package/dist/routes/sync.js.map +1 -0
  116. package/dist/routes/{projects.d.ts → voice.d.ts} +1 -1
  117. package/dist/routes/voice.d.ts.map +1 -0
  118. package/dist/routes/voice.js +35 -0
  119. package/dist/routes/voice.js.map +1 -0
  120. package/dist/server-state.d.ts +30 -0
  121. package/dist/server-state.d.ts.map +1 -0
  122. package/dist/server-state.js +113 -0
  123. package/dist/server-state.js.map +1 -0
  124. package/dist/server-status.d.ts +7 -0
  125. package/dist/server-status.d.ts.map +1 -0
  126. package/dist/server-status.js +34 -0
  127. package/dist/server-status.js.map +1 -0
  128. package/dist/server.d.ts +5 -1
  129. package/dist/server.d.ts.map +1 -1
  130. package/dist/server.js +148 -46
  131. package/dist/server.js.map +1 -1
  132. package/dist/sources/adapters.d.ts +15 -0
  133. package/dist/sources/adapters.d.ts.map +1 -0
  134. package/dist/sources/adapters.js +159 -0
  135. package/dist/sources/adapters.js.map +1 -0
  136. package/dist/sources/registry.d.ts +17 -0
  137. package/dist/sources/registry.d.ts.map +1 -0
  138. package/dist/sources/registry.js +56 -0
  139. package/dist/sources/registry.js.map +1 -0
  140. package/dist/sync/collector.d.ts +3 -0
  141. package/dist/sync/collector.d.ts.map +1 -0
  142. package/dist/sync/collector.js +284 -0
  143. package/dist/sync/collector.js.map +1 -0
  144. package/dist/sync/cost.d.ts +7 -0
  145. package/dist/sync/cost.d.ts.map +1 -0
  146. package/dist/sync/cost.js +31 -0
  147. package/dist/sync/cost.js.map +1 -0
  148. package/dist/sync/jsonl.d.ts +34 -0
  149. package/dist/sync/jsonl.d.ts.map +1 -0
  150. package/dist/sync/jsonl.js +161 -0
  151. package/dist/sync/jsonl.js.map +1 -0
  152. package/dist/sync/manager.d.ts +12 -0
  153. package/dist/sync/manager.d.ts.map +1 -0
  154. package/dist/sync/manager.js +211 -0
  155. package/dist/sync/manager.js.map +1 -0
  156. package/dist/sync/normalize.d.ts +22 -0
  157. package/dist/sync/normalize.d.ts.map +1 -0
  158. package/dist/sync/normalize.js +107 -0
  159. package/dist/sync/normalize.js.map +1 -0
  160. package/dist/sync/pi-output.d.ts +33 -0
  161. package/dist/sync/pi-output.d.ts.map +1 -0
  162. package/dist/sync/pi-output.js +186 -0
  163. package/dist/sync/pi-output.js.map +1 -0
  164. package/dist/sync/pi.d.ts +15 -0
  165. package/dist/sync/pi.d.ts.map +1 -0
  166. package/dist/sync/pi.js +149 -0
  167. package/dist/sync/pi.js.map +1 -0
  168. package/dist/sync/recap.d.ts +25 -0
  169. package/dist/sync/recap.d.ts.map +1 -0
  170. package/dist/sync/recap.js +118 -0
  171. package/dist/sync/recap.js.map +1 -0
  172. package/dist/sync/references.d.ts +10 -0
  173. package/dist/sync/references.d.ts.map +1 -0
  174. package/dist/sync/references.js +50 -0
  175. package/dist/sync/references.js.map +1 -0
  176. package/dist/sync/source-files.d.ts +16 -0
  177. package/dist/sync/source-files.d.ts.map +1 -0
  178. package/dist/sync/source-files.js +56 -0
  179. package/dist/sync/source-files.js.map +1 -0
  180. package/dist/sync/store.d.ts +36 -0
  181. package/dist/sync/store.d.ts.map +1 -0
  182. package/dist/sync/store.js +359 -0
  183. package/dist/sync/store.js.map +1 -0
  184. package/dist/sync/summary-budget.d.ts +19 -0
  185. package/dist/sync/summary-budget.d.ts.map +1 -0
  186. package/dist/sync/summary-budget.js +39 -0
  187. package/dist/sync/summary-budget.js.map +1 -0
  188. package/dist/sync/tree.d.ts +69 -0
  189. package/dist/sync/tree.d.ts.map +1 -0
  190. package/dist/sync/tree.js +599 -0
  191. package/dist/sync/tree.js.map +1 -0
  192. package/dist/tray/controller.d.ts +51 -0
  193. package/dist/tray/controller.d.ts.map +1 -0
  194. package/dist/tray/controller.js +178 -0
  195. package/dist/tray/controller.js.map +1 -0
  196. package/dist/tray/launcher.d.ts +3 -0
  197. package/dist/tray/launcher.d.ts.map +1 -0
  198. package/dist/tray/launcher.js +47 -0
  199. package/dist/tray/launcher.js.map +1 -0
  200. package/dist/tray/native.d.ts +12 -0
  201. package/dist/tray/native.d.ts.map +1 -0
  202. package/dist/tray/native.js +93 -0
  203. package/dist/tray/native.js.map +1 -0
  204. package/dist/tray/processes.d.ts +5 -0
  205. package/dist/tray/processes.d.ts.map +1 -0
  206. package/dist/tray/processes.js +99 -0
  207. package/dist/tray/processes.js.map +1 -0
  208. package/dist/tray/worker.d.ts +4 -0
  209. package/dist/tray/worker.d.ts.map +1 -0
  210. package/dist/tray/worker.js +150 -0
  211. package/dist/tray/worker.js.map +1 -0
  212. package/dist/types.d.ts +212 -20
  213. package/dist/types.d.ts.map +1 -1
  214. package/dist/voice/continuation.d.ts +25 -0
  215. package/dist/voice/continuation.d.ts.map +1 -0
  216. package/dist/voice/continuation.js +94 -0
  217. package/dist/voice/continuation.js.map +1 -0
  218. package/dist/voice/legacy-history.d.ts +5 -0
  219. package/dist/voice/legacy-history.d.ts.map +1 -0
  220. package/dist/voice/legacy-history.js +34 -0
  221. package/dist/voice/legacy-history.js.map +1 -0
  222. package/dist/voice/live-context.d.ts +14 -0
  223. package/dist/voice/live-context.d.ts.map +1 -0
  224. package/dist/voice/live-context.js +137 -0
  225. package/dist/voice/live-context.js.map +1 -0
  226. package/dist/voice/live.d.ts +37 -0
  227. package/dist/voice/live.d.ts.map +1 -0
  228. package/dist/voice/live.js +448 -0
  229. package/dist/voice/live.js.map +1 -0
  230. package/package.json +49 -16
  231. package/public/assets/index-CYz_kpAp.js +126 -0
  232. package/public/assets/index-DmRid5xE.css +1 -0
  233. package/public/assets/index-Ymd62i3g.js +40 -0
  234. package/public/assets/persona-C_m1CVKe.css +1 -0
  235. package/public/assets/persona-XbLvCqRT.js +1 -0
  236. package/public/assets/scene-CBV2oPhA.js +3854 -0
  237. package/public/index.html +6 -8
  238. package/public/persona/THREE-LICENSE.txt +21 -0
  239. package/public/persona/call-poster.webp +0 -0
  240. package/public/persona/reference.jpg +0 -0
  241. package/public/persona/sample.wav +0 -0
  242. package/public/persona.html +7 -0
  243. package/runtime/pi/package-lock.json +1549 -0
  244. package/runtime/pi/package.json +12 -0
  245. package/tests/fixtures/voice-question.wav +0 -0
  246. package/tray/assets/error.ico +0 -0
  247. package/tray/assets/error.png +0 -0
  248. package/tray/assets/running.ico +0 -0
  249. package/tray/assets/running.png +0 -0
  250. package/tray/assets/starting.ico +0 -0
  251. package/tray/assets/starting.png +0 -0
  252. package/tray/assets/stopped.ico +0 -0
  253. package/tray/assets/stopped.png +0 -0
  254. package/tray/assets/stopping.ico +0 -0
  255. package/tray/assets/stopping.png +0 -0
  256. package/tray/macos.js +72 -0
  257. package/tray/windows.ps1 +104 -0
  258. package/voice/realtime/README.md +84 -0
  259. package/voice/realtime/backend.mjs +81 -0
  260. package/voice/realtime/native.mjs +210 -0
  261. package/voice/realtime/peer.d.mts +15 -0
  262. package/voice/realtime/peer.mjs +116 -0
  263. package/voice/realtime/probe-browser.mjs +81 -0
  264. package/voice/realtime/probe-server.mjs +86 -0
  265. package/voice/realtime/probe.html +11 -0
  266. package/voice/realtime/probe.mjs +42 -0
  267. package/voice/realtime/rpc.mjs +118 -0
  268. package/voice/realtime/setup.mjs +32 -0
  269. package/voice/tls.mjs +49 -0
  270. package/dist/db/queries.d.ts +0 -16
  271. package/dist/db/queries.d.ts.map +0 -1
  272. package/dist/db/queries.js +0 -91
  273. package/dist/db/queries.js.map +0 -1
  274. package/dist/docker/dockerfile.d.ts +0 -50
  275. package/dist/docker/dockerfile.d.ts.map +0 -1
  276. package/dist/docker/dockerfile.js +0 -160
  277. package/dist/docker/dockerfile.js.map +0 -1
  278. package/dist/docker/manager.d.ts +0 -42
  279. package/dist/docker/manager.d.ts.map +0 -1
  280. package/dist/docker/manager.js +0 -421
  281. package/dist/docker/manager.js.map +0 -1
  282. package/dist/git/status.d.ts +0 -76
  283. package/dist/git/status.d.ts.map +0 -1
  284. package/dist/git/status.js +0 -332
  285. package/dist/git/status.js.map +0 -1
  286. package/dist/routes/browse.d.ts.map +0 -1
  287. package/dist/routes/browse.js +0 -49
  288. package/dist/routes/browse.js.map +0 -1
  289. package/dist/routes/git.d.ts +0 -9
  290. package/dist/routes/git.d.ts.map +0 -1
  291. package/dist/routes/git.js +0 -338
  292. package/dist/routes/git.js.map +0 -1
  293. package/dist/routes/projects.d.ts.map +0 -1
  294. package/dist/routes/projects.js +0 -220
  295. package/dist/routes/projects.js.map +0 -1
  296. package/dist/routes/tasks.d.ts.map +0 -1
  297. package/dist/routes/tasks.js +0 -313
  298. package/dist/routes/tasks.js.map +0 -1
  299. package/draken.dockerfile.template +0 -18
  300. package/public/assets/index-3ngsG5kF.css +0 -1
  301. package/public/assets/index-B3YSD0ee.js +0 -252
@@ -0,0 +1,116 @@
1
+ /** Media-only browser boundary. It owns transferred tracks, never credentials or sync data.
2
+ * No automatic microphone acquisition/reconnect; callers must obtain a stream explicitly.
3
+ */
4
+ export class VoicePeer {
5
+ #pc;
6
+ #context;
7
+ #input;
8
+ #sender;
9
+ #remote = new Set();
10
+ #sources = [];
11
+ #meter;
12
+ #gain;
13
+ #decoder;
14
+ #samples = new Float32Array(1024);
15
+ #controller = new AbortController();
16
+ #closed = false;
17
+ #opened = false;
18
+ #mutedOutput = false;
19
+ #rejectConnection;
20
+ #timer;
21
+ #onClose;
22
+ #onEvent;
23
+ constructor({ onClose = () => {}, onEvent = () => {} } = {}) { this.#onClose = onClose; this.#onEvent = onEvent; }
24
+ get closed() { return this.#closed; }
25
+ get clock() { return !this.#closed && this.#context?.state === 'running' ? this.#context.currentTime : NaN; }
26
+ get audioState() { return this.#context?.state || 'closed'; }
27
+ async resume() { if (!this.#closed) { await this.#context?.resume(); await this.#decoder?.play(); } }
28
+ gateInput(enabled) { this.#input?.getAudioTracks().forEach(track => { track.enabled = !!enabled; }); }
29
+ get inputActive() { return !!this.#input?.getAudioTracks().some(track => track.readyState === 'live'); }
30
+ get level() {
31
+ if (this.#closed || this.#mutedOutput || this.#context?.state !== 'running' || !this.#meter) return 0;
32
+ this.#meter.getFloatTimeDomainData(this.#samples);
33
+ const rms = Math.sqrt(this.#samples.reduce((sum, value) => sum + value * value, 0) / this.#samples.length);
34
+ return Number.isFinite(rms) ? Math.min(1, rms * 12) : 0;
35
+ }
36
+ async open(input, negotiate) {
37
+ if (this.#opened || this.#closed) { input.getTracks().forEach(track => track.stop()); throw new Error('A voice peer can open only once.'); }
38
+ this.#opened = true; this.#input = input;
39
+ try {
40
+ if (input.getAudioTracks().length !== 1 || input.getVideoTracks().length) throw new Error('Exactly one audio track is required.');
41
+ this.#pc = new RTCPeerConnection(); this.#context = new AudioContext();
42
+ this.#meter = this.#context.createAnalyser(); this.#meter.fftSize = this.#samples.length;
43
+ this.#gain = this.#context.createGain(); this.#meter.connect(this.#gain); this.#gain.connect(this.#context.destination);
44
+ this.#timer = setTimeout(() => this.close('Voice connection timed out.'), 60_000);
45
+ await this.#context.resume();
46
+ if (this.#closed || this.#context.state !== 'running') throw new Error('Voice playback could not start.');
47
+ this.#sender = this.#pc.addTrack(input.getAudioTracks()[0], input);
48
+ const channel = this.#pc.createDataChannel('oai-events');
49
+ const connected = new Promise((resolve, reject) => {
50
+ this.#rejectConnection = reject;
51
+ const check = () => {
52
+ if (this.#pc.connectionState === 'connected' && channel.readyState === 'open') { this.#rejectConnection = undefined; clearTimeout(this.#timer); resolve(); }
53
+ else if (['failed', 'disconnected', 'closed'].includes(this.#pc.connectionState)) this.close('Voice connection lost. Start a new call explicitly.');
54
+ };
55
+ this.#pc.onconnectionstatechange = check; channel.onopen = check;
56
+ channel.onclose = () => this.close('Voice event connection closed.');
57
+ channel.onerror = () => this.close('Voice event connection failed.');
58
+ });
59
+ // A close during the HTTP negotiation must not cause an unhandled rejection.
60
+ void connected.catch(() => {});
61
+ channel.onmessage = event => {
62
+ if (this.#closed) return;
63
+ if (typeof event.data !== 'string' || event.data.length > 200_000) { this.close('Voice event exceeds its limit.'); return; }
64
+ try { this.#onEvent(JSON.parse(event.data)); } catch { this.close('Invalid voice event.'); }
65
+ };
66
+ this.#pc.ontrack = event => {
67
+ if (this.#closed) { event.track.stop(); return; }
68
+ if (event.track.kind !== 'audio' || this.#remote.size) { this.close('Unexpected remote media track.'); return; }
69
+ this.#remote.add(event.track);
70
+ const stream = new MediaStream([event.track]);
71
+ // Chromium can leave a WebRTC track silent when only Web Audio consumes
72
+ // it. A muted media element starts the receiver; the graph is the sole
73
+ // audible output, so this does not play the voice twice.
74
+ this.#decoder = new Audio(); this.#decoder.muted = true; this.#decoder.srcObject = stream;
75
+ void this.#decoder.play().catch(() => this.close('Voice playback could not start.'));
76
+ const source = this.#context.createMediaStreamSource(stream);
77
+ this.#sources.push(source); source.connect(this.#meter);
78
+ event.track.onended = () => this.close('Voice output ended.');
79
+ };
80
+ await this.#pc.setLocalDescription(await this.#pc.createOffer());
81
+ if (this.#closed) throw new Error('Voice connection cancelled.');
82
+ const answer = await negotiate(this.#pc.localDescription.sdp, this.#controller.signal);
83
+ if (this.#closed) throw new Error('Voice connection cancelled.');
84
+ if (typeof answer !== 'string' || answer.length > 64_000 || !answer.startsWith('v=0')) throw new Error('Invalid voice answer.');
85
+ await this.#pc.setRemoteDescription({ type: 'answer', sdp: answer });
86
+ await connected;
87
+ } catch (error) { this.close('Voice connection failed or cancelled.'); throw error; }
88
+ }
89
+ muteOutput(muted) { this.#mutedOutput = !!muted; if (this.#gain) this.#gain.gain.value = muted ? 0 : 1; }
90
+ stopInput() {
91
+ this.#input?.getTracks().forEach(track => track.stop()); this.#input = undefined;
92
+ if (this.#sender && !this.#closed) void this.#sender.replaceTrack(null).catch(() => this.close('Cannot mute voice input.'));
93
+ }
94
+ async replaceInput(stream) {
95
+ if (this.#closed || !this.#sender || stream.getAudioTracks().length !== 1 || stream.getVideoTracks().length) {
96
+ stream.getTracks().forEach(track => track.stop()); throw new Error('Cannot attach voice input.');
97
+ }
98
+ const old = this.#input; this.#input = stream;
99
+ old?.getTracks().forEach(track => track.stop());
100
+ try { await this.#sender.replaceTrack(stream.getAudioTracks()[0]); }
101
+ catch (error) { stream.getTracks().forEach(track => track.stop()); this.close('Cannot attach voice input.'); throw error; }
102
+ if (this.#closed || this.#input !== stream) { stream.getTracks().forEach(track => track.stop()); throw new Error('Voice input was superseded.'); }
103
+ }
104
+ close(reason = 'Call ended.') {
105
+ if (this.#closed) return;
106
+ this.#closed = true; clearTimeout(this.#timer); this.#controller.abort();
107
+ this.#rejectConnection?.(new Error(reason)); this.#rejectConnection = undefined;
108
+ this.#pc?.close(); this.#input?.getTracks().forEach(track => track.stop());
109
+ this.#remote.forEach(track => track.stop()); this.#remote.clear();
110
+ this.#sources.forEach(source => source.disconnect()); this.#sources = [];
111
+ this.#meter?.disconnect(); this.#gain?.disconnect(); this.#samples.fill(0);
112
+ if (this.#decoder) { this.#decoder.pause(); this.#decoder.srcObject = null; this.#decoder = undefined; }
113
+ if (this.#context && this.#context.state !== 'closed') void this.#context.close().catch(() => {});
114
+ try { this.#onClose(reason); } catch { /* Cleanup must not depend on the UI. */ }
115
+ }
116
+ }
@@ -0,0 +1,81 @@
1
+ import { VoicePeer } from './peer.mjs';
2
+ let peer, context, monitor, poll, finished = false, cursor = 0, pollRunning = false;
3
+ const startButton = document.querySelector('#start'), endButton = document.querySelector('#end'), status = document.querySelector('#status');
4
+ const result = window.voiceResult = { completed: false, success: false, setupMs: null, audioFrames: 0, questions: [], transcripts: [], events: {}, errors: [], localCleanup: false, helperCleanup: false };
5
+ const waitFor = async (predicate, ms = 20_000) => {
6
+ const deadline = performance.now() + ms;
7
+ while (!predicate()) { if (finished || performance.now() > deadline) throw new Error('Synthetic voice turn did not complete.'); await new Promise(resolve => setTimeout(resolve, 50)); }
8
+ };
9
+ async function end() {
10
+ if (finished) return;
11
+ finished = true; clearInterval(monitor); clearInterval(poll); peer?.close();
12
+ if (context && context.state !== 'closed') await context.close();
13
+ result.localCleanup = !!peer?.closed && !peer?.inputActive && (!context || context.state === 'closed');
14
+ try {
15
+ const response = await fetch('/end', { method: 'POST', signal: AbortSignal.timeout(5000) });
16
+ result.helperCleanup = response.ok && (await response.json()).helperExited === true;
17
+ } catch { /* Lab lease is the final guard; do not claim confirmed helper cleanup. */ }
18
+ if (!result.helperCleanup) result.success = false;
19
+ result.completed = true; endButton.disabled = true; status.textContent = result.success ? 'Synthetic exchange passed. Account quota and human-device behaviour still need testing.' : 'Test ended; see the private structural report.';
20
+ }
21
+ endButton.onclick = () => { void end(); };
22
+ window.addEventListener('pagehide', () => { peer?.close(); void fetch('/end', { method: 'POST', keepalive: true }); });
23
+ startButton.onclick = async () => {
24
+ startButton.disabled = true; endButton.disabled = false; status.textContent = 'Connecting…';
25
+ const start = performance.now(); let lastAudible = 0, current;
26
+ try {
27
+ context = new AudioContext(); await context.resume();
28
+ if (finished) throw new Error('Test cancelled before audio setup.');
29
+ const input = context.createMediaStreamDestination();
30
+ // A real microphone continues sending silence between utterances. Keep
31
+ // the synthetic source active too, rather than ending its audio graph.
32
+ const silence = context.createConstantSource(); silence.offset.value = 0; silence.connect(input); silence.start();
33
+ peer = new VoicePeer({ onClose: () => { if (!finished) { result.errors.push('Media connection closed.'); void end(); } }, onEvent: event => {
34
+ const type = typeof event.type === 'string' ? event.type : 'unknown';
35
+ result.events[type] = (result.events[type] || 0) + 1;
36
+ if (event.type === 'error') { result.errors.push('Remote voice event reported an error.'); void end(); }
37
+ } });
38
+ poll = setInterval(async () => {
39
+ if (pollRunning || finished) return; pollRunning = true;
40
+ try {
41
+ const response = await fetch(`/events?after=${cursor}`, { signal: AbortSignal.timeout(5000) });
42
+ if (!response.ok) throw new Error('Events unavailable.');
43
+ for (const event of await response.json()) {
44
+ cursor = event.sequence;
45
+ if (event.method === 'thread/realtime/transcript/done') result.transcripts.push(event.params);
46
+ if (['thread/realtime/error', 'thread/realtime/closed', 'draken/closed', 'draken/blocked'].includes(event.method)) { result.errors.push(event.method); void end(); }
47
+ }
48
+ } catch { result.errors.push('Native voice event stream failed.'); void end(); }
49
+ finally { pollRunning = false; }
50
+ }, 250);
51
+ await peer.open(input.stream, async (offer, signal) => {
52
+ const response = await fetch('/call', { method: 'POST', body: offer, signal });
53
+ if (!response.ok) throw new Error('Native voice connection was rejected.');
54
+ return response.text();
55
+ });
56
+ result.setupMs = Math.round(performance.now() - start); status.textContent = 'Running two spoken turns on one connection…';
57
+ monitor = setInterval(() => {
58
+ if (peer.level > 0.01) {
59
+ result.audioFrames++; lastAudible = performance.now();
60
+ if (current && current.firstAudioAt === undefined) current.firstAudioAt = Math.round(lastAudible);
61
+ }
62
+ }, 20);
63
+ const buffer = await context.decodeAudioData(await (await fetch('/question.wav')).arrayBuffer());
64
+ for (let index = 0; index < 2; index++) {
65
+ const assistantBefore = result.transcripts.filter(item => item.role === 'assistant').length;
66
+ const userBefore = result.transcripts.filter(item => item.role === 'user').length;
67
+ const framesBefore = result.audioFrames;
68
+ current = { index, inputStartedAt: Math.round(performance.now()) }; result.questions.push(current);
69
+ const source = context.createBufferSource(); source.buffer = buffer; source.connect(input);
70
+ await new Promise(resolve => { source.onended = resolve; source.start(); });
71
+ current.inputEndedAt = Math.round(performance.now()); source.disconnect();
72
+ await waitFor(() => result.transcripts.filter(item => item.role === 'assistant').slice(assistantBefore).some(item => /pass/i.test(item.text)) &&
73
+ result.transcripts.filter(item => item.role === 'user').slice(userBefore).some(item => /export.*test/i.test(item.text)) &&
74
+ result.audioFrames > framesBefore + 5 && performance.now() - lastAudible > 900);
75
+ current.firstAudioAfterFixtureMs = current.firstAudioAt - current.inputEndedAt;
76
+ current = undefined;
77
+ }
78
+ result.success = true;
79
+ } catch (error) { result.errors.push(error.message); }
80
+ finally { await end(); }
81
+ };
@@ -0,0 +1,86 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { NativeVoice } from './native.mjs';
6
+
7
+ /** Synthetic-only lab server. Not mounted in Draken; never imports its DB or routes. */
8
+ export async function createProbeServer({ voiceFactory = () => new NativeVoice({
9
+ // Deliberately fixed synthetic facts, not a coding model or real sync context.
10
+ answer: async () => 'The targeted export tests passed. Deployment has not been checked.',
11
+ }), leaseMs = 10_000, lifetimeMs = 90_000 } = {}) {
12
+ const token = randomBytes(32).toString('hex');
13
+ const expected = Buffer.from(`Bearer ${token}`);
14
+ let call, accepted = false, touched = Date.now(), origin, closing;
15
+ const events = []; let sequence = 0;
16
+ const finish = () => { if (!closing) closing = Promise.resolve().then(() => call?.close()); return closing; };
17
+ const body = async req => {
18
+ req.setEncoding('utf8');
19
+ let data = ''; for await (const chunk of req) { data += chunk; if (data.length > 64_000) throw new Error('Request too large.'); }
20
+ return data;
21
+ };
22
+ const server = http.createServer(async (req, res) => {
23
+ res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff');
24
+ const auth = Buffer.from(typeof req.headers.authorization === 'string' ? req.headers.authorization : '');
25
+ if (auth.length !== expected.length || !timingSafeEqual(auth, expected)) { res.writeHead(401); res.end(); return; }
26
+ if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) { res.writeHead(403); res.end(); return; }
27
+ try {
28
+ const url = new URL(req.url, origin);
29
+ if (req.method === 'GET' && url.pathname === '/') {
30
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; connect-src 'self'; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
31
+ res.setHeader('Content-Type', 'text/html'); res.end(fs.readFileSync(new URL('./probe.html', import.meta.url))); return;
32
+ }
33
+ if (req.method === 'GET' && ['/peer.mjs', '/probe-browser.mjs'].includes(url.pathname)) {
34
+ res.setHeader('Content-Type', 'text/javascript'); res.end(fs.readFileSync(new URL(`.${url.pathname}`, import.meta.url))); return;
35
+ }
36
+ if (req.method === 'GET' && url.pathname === '/question.wav') {
37
+ res.setHeader('Content-Type', 'audio/wav'); res.end(fs.readFileSync(fileURLToPath(new URL('../../tests/fixtures/voice-question.wav', import.meta.url)))); return;
38
+ }
39
+ if (req.method === 'GET' && url.pathname === '/events') {
40
+ const after = Number(url.searchParams.get('after') || 0);
41
+ if (!Number.isSafeInteger(after) || after < 0) { res.writeHead(400); res.end(); return; }
42
+ touched = Date.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(events.filter(event => event.sequence > after))); return;
43
+ }
44
+ if (req.method === 'POST' && url.pathname === '/call') {
45
+ if (accepted || closing) { res.writeHead(409); res.end(); return; }
46
+ accepted = true; touched = Date.now();
47
+ const offer = await body(req);
48
+ if (closing) throw new Error('Call setup cancelled.');
49
+ call = voiceFactory();
50
+ call.subscribe(event => {
51
+ const method = event.method;
52
+ if (!['thread/realtime/transcript/done', 'thread/realtime/error', 'thread/realtime/closed', 'thread/realtime/started', 'draken/closed', 'draken/blocked'].includes(method)) return;
53
+ // Only synthetic transcripts and event kinds. Never SDP, tokens, helper errors or IDs.
54
+ const params = method === 'thread/realtime/transcript/done' && ['user', 'assistant'].includes(event.params?.role)
55
+ ? { role: event.params.role, text: String(event.params.text || '').slice(0, 4000) } : {};
56
+ events.push({ sequence: ++sequence, method, params }); if (events.length > 128) events.shift();
57
+ if (['thread/realtime/error', 'thread/realtime/closed', 'draken/closed', 'draken/blocked'].includes(method)) void finish();
58
+ });
59
+ const controller = new AbortController();
60
+ res.on('close', () => { if (!res.writableEnded) { controller.abort(); void finish(); } });
61
+ const answer = await call.open(offer, { signal: controller.signal });
62
+ res.setHeader('Content-Type', 'application/sdp'); res.end(answer); return;
63
+ }
64
+ if (req.method === 'POST' && url.pathname === '/text') {
65
+ const question = JSON.parse(await body(req)).question;
66
+ if (!call || closing || typeof question !== 'string' || question.length > 2000) { res.writeHead(400); res.end(); return; }
67
+ touched = Date.now(); await call.text(question); res.end('{}'); return;
68
+ }
69
+ if (req.method === 'POST' && url.pathname === '/end') {
70
+ const result = await finish();
71
+ res.setHeader('Content-Type', 'application/json');
72
+ if (result?.helperExited === false) res.statusCode = 502;
73
+ res.end(JSON.stringify({ helperExited: result?.helperExited !== false })); return;
74
+ }
75
+ res.writeHead(404); res.end();
76
+ } catch { await finish(); if (!res.headersSent) res.writeHead(502); res.end('Native voice qualification failed. No provider fallback was attempted.'); }
77
+ });
78
+ server.requestTimeout = 65_000;
79
+ server.headersTimeout = 10_000;
80
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
81
+ origin = `http://127.0.0.1:${server.address().port}`;
82
+ const sweep = setInterval(() => { if (accepted && Date.now() - touched > leaseMs) void finish(); }, Math.min(1000, leaseMs)); sweep.unref();
83
+ const deadline = setTimeout(() => { void finish(); }, lifetimeMs); deadline.unref();
84
+ return { origin, token, events,
85
+ async close() { clearInterval(sweep); clearTimeout(deadline); await finish(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } };
86
+ }
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Draken private voice qualification</title></head>
4
+ <body>
5
+ <h1>Private voice qualification</h1>
6
+ <p>Synthetic audio only. No microphone capture, sync database, tools, work execution, or paid-key fallback.</p>
7
+ <button id="start">Run synthetic call</button> <button id="end" disabled>End test</button>
8
+ <p id="status" role="status">Ready. This test is separate from the deployed call screen.</p>
9
+ <script type="module" src="/probe-browser.mjs"></script>
10
+ </body>
11
+ </html>
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { realtimeRoot, nativeStatus } from './native.mjs';
4
+ import { createProbeServer } from './probe-server.mjs';
5
+
6
+ process.umask(0o077);
7
+ let browser, lab, stopping = false;
8
+ const onSignal = signal => {
9
+ if (stopping) return; stopping = true;
10
+ void Promise.allSettled([browser?.close(), lab?.close()]).then(() => process.exit(signal === 'SIGINT' ? 130 : 143));
11
+ };
12
+ process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal);
13
+ try {
14
+ const flags = process.argv.slice(2);
15
+ if (flags.length !== 1 || !['--check', '--allow-provider'].includes(flags[0])) throw new Error('Use --check, or explicitly --allow-provider for one bounded synthetic audio test.');
16
+ const status = await nativeStatus();
17
+ if (flags[0] === '--check') console.log(JSON.stringify(status, null, 2));
18
+ else {
19
+ if (!status.readyForProbe) throw new Error('Native ChatGPT sign-in is missing or the pinned helper is unavailable. Run npm run voice:realtime:setup, then npm run voice:realtime:login. No provider request was made.');
20
+ const { chromium } = await import('@playwright/test');
21
+ lab = await createProbeServer(); browser = await chromium.launch();
22
+ const context = await browser.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${lab.token}` } });
23
+ // Do not let page HTTP requests carry the lab credential to any other origin.
24
+ await context.route('**/*', route => new URL(route.request().url()).origin === lab.origin ? route.continue() : route.abort());
25
+ const page = await context.newPage(); const pageErrors = [];
26
+ page.on('pageerror', () => pageErrors.push('Browser runtime error.'));
27
+ await page.goto(lab.origin); await page.getByRole('button', { name: 'Run synthetic call' }).click();
28
+ await page.waitForFunction(() => window.voiceResult?.completed, {}, { timeout: 85_000 });
29
+ const result = await page.evaluate(() => window.voiceResult);
30
+ result.errors.push(...pageErrors);
31
+ result.success = result.success && result.localCleanup && result.helperCleanup && result.questions.length === 2 && result.audioFrames > 10 && !result.errors.length;
32
+ result.audioVerified = result.success;
33
+ result.authentication = 'native-chatgpt-only'; result.syntheticOnly = true;
34
+ result.interruptionVerified = false; result.realSyncVerified = false; result.quotaAccountingKnown = false;
35
+ const dir = path.join(realtimeRoot, 'reports'); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ const report = path.join(dir, `voice-${Date.now()}.json`); fs.writeFileSync(report, JSON.stringify(result, null, 2), { mode: 0o600 });
37
+ console.log(JSON.stringify({ success: result.success, setupMs: result.setupMs, spokenTurns: result.questions.length,
38
+ receivedAudioFrames: result.audioFrames, transcripts: result.transcripts.length, localCleanup: result.localCleanup, helperCleanup: result.helperCleanup, report }, null, 2));
39
+ if (!result.success) process.exitCode = 1;
40
+ }
41
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
42
+ finally { await browser?.close(); await lab?.close(); process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); }
@@ -0,0 +1,118 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /** Private stdio only. No HTTP listener, shell, generic command route, or raw logging. */
4
+ export class NativeRpc {
5
+ #child;
6
+ #pending = new Map();
7
+ #listeners = new Set();
8
+ #sequence = 0;
9
+ #buffer = '';
10
+ #closed = false;
11
+ #exited = false;
12
+ #killTimer;
13
+ #exitPromise;
14
+ #resolveExit;
15
+ constructor(binary, args, { cwd, env, spawnProcess = spawn } = {}) {
16
+ this.#exitPromise = new Promise(resolve => { this.#resolveExit = resolve; });
17
+ this.#child = spawnProcess(binary, args, { cwd, env, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
18
+ this.#child.stdout.setEncoding('utf8');
19
+ this.#child.stdout.on('data', chunk => this.#receive(chunk));
20
+ // Native diagnostics can contain prompts, transcripts, paths and auth details.
21
+ // Drain them, but never copy them into routine logs or browser errors.
22
+ this.#child.stderr.resume();
23
+ this.#child.stdin.on('error', () => this.close('Native voice input closed.'));
24
+ this.#child.on('error', () => this.close('Cannot start the pinned native voice helper.'));
25
+ this.#child.on('close', () => {
26
+ this.#exited = true;
27
+ clearTimeout(this.#killTimer);
28
+ this.#resolveExit(true);
29
+ this.close('Native voice helper stopped.');
30
+ });
31
+ }
32
+ get closed() { return this.#closed; }
33
+ subscribe(listener) { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }
34
+ #emit(event) {
35
+ for (const listener of this.#listeners) {
36
+ try { listener(event); } catch { this.close('Native voice event consumer failed.'); return; }
37
+ }
38
+ }
39
+ #receive(chunk) {
40
+ if (this.#closed) return;
41
+ this.#buffer += chunk;
42
+ if (this.#buffer.length > 1_000_000) return this.close('Native voice event exceeds its limit.');
43
+ let newline;
44
+ while ((newline = this.#buffer.indexOf('\n')) >= 0) {
45
+ const line = this.#buffer.slice(0, newline); this.#buffer = this.#buffer.slice(newline + 1);
46
+ if (!line.trim()) continue;
47
+ let event;
48
+ try { event = JSON.parse(line); } catch { return this.close('Malformed native voice event.'); }
49
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return this.close('Malformed native voice event.');
50
+ if (typeof event.method === 'string') {
51
+ // A call must never become an execution/approval/elicitation client.
52
+ // Do not accept even a nominally read-only command request.
53
+ if (Object.hasOwn(event, 'id')) {
54
+ try { this.#send({ id: event.id, error: { code: -32601, message: 'Draken voice does not execute tools or grant approvals.' } }); }
55
+ catch { /* A closed input pipe cannot grant an operation. */ }
56
+ finally { this.close('Native voice requested an unsupported operation.'); }
57
+ return;
58
+ }
59
+ this.#emit(event);
60
+ if (this.#closed) return;
61
+ } else if (Object.hasOwn(event, 'id')) {
62
+ const pending = this.#pending.get(event.id);
63
+ if (!pending) continue; // A timed-out response must not settle a later request.
64
+ this.#pending.delete(event.id); pending.cleanup();
65
+ if (Object.hasOwn(event, 'error')) pending.reject(new Error('Native voice request was rejected. Check account access and helper compatibility.'));
66
+ else if (Object.hasOwn(event, 'result')) pending.resolve(event.result);
67
+ else { pending.reject(new Error('Malformed native voice response.')); this.close(); }
68
+ } else return this.close('Malformed native voice event.');
69
+ }
70
+ }
71
+ #send(value) {
72
+ if (this.#closed || this.#child.stdin.destroyed) throw new Error('Native voice connection is closed.');
73
+ const line = JSON.stringify(value) + '\n';
74
+ if (line.length > 200_000 || this.#child.stdin.writableLength > 1_000_000) throw new Error('Native voice input exceeds its limit.');
75
+ this.#child.stdin.write(line);
76
+ }
77
+ notify(method, params = {}) { this.#send({ method, params }); }
78
+ request(method, params = {}, { timeoutMs = 15_000, signal } = {}) {
79
+ if (this.#closed || signal?.aborted) return Promise.reject(new Error('Native voice request cancelled.'));
80
+ if (this.#pending.size >= 16) return Promise.reject(new Error('Too many pending native voice requests.'));
81
+ const id = ++this.#sequence;
82
+ return new Promise((resolve, reject) => {
83
+ const abort = () => this.close('Native voice request cancelled.');
84
+ const timer = setTimeout(() => this.close('Native voice request timed out.'), Math.max(1, Math.min(timeoutMs, 60_000)));
85
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
86
+ this.#pending.set(id, { resolve, reject, cleanup });
87
+ signal?.addEventListener('abort', abort, { once: true });
88
+ try { this.#send({ id, method, params }); } catch { this.close('Cannot send native voice request.'); }
89
+ if (signal?.aborted) abort();
90
+ });
91
+ }
92
+ async waitForExit(timeoutMs = 2500) {
93
+ let timer;
94
+ try { return await Promise.race([this.#exitPromise, new Promise(resolve => { timer = setTimeout(() => resolve(false), timeoutMs); })]); }
95
+ finally { clearTimeout(timer); }
96
+ }
97
+ #terminate(signal) {
98
+ // The official npm launcher owns a native child. On Unix terminate the
99
+ // entire group, including escalation if a launcher exits before its child.
100
+ if (process.platform !== 'win32' && Number.isInteger(this.#child.pid) && this.#child.pid > 1) {
101
+ try { process.kill(-this.#child.pid, signal); return; } catch { /* Already gone, or test double. */ }
102
+ }
103
+ try { this.#child.kill(signal); } catch { /* Exit confirmation is separate. */ }
104
+ }
105
+ close(reason = 'Native voice connection closed.') {
106
+ if (this.#closed) return;
107
+ this.#closed = true; this.#buffer = '';
108
+ for (const pending of this.#pending.values()) { pending.cleanup(); pending.reject(new Error(reason)); }
109
+ this.#pending.clear();
110
+ this.#child.stdin.destroy();
111
+ if (!this.#exited) {
112
+ this.#terminate('SIGTERM');
113
+ this.#killTimer = setTimeout(() => this.#terminate('SIGKILL'), 1500); this.#killTimer.unref();
114
+ }
115
+ this.#emit({ method: 'draken/closed', params: { reason } });
116
+ this.#listeners.clear();
117
+ }
118
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { npmCli } from '../../dist/npm-cli.js';
5
+ import { CODEX_VERSION, realtimeRoot, defaultBinary, defaultHome, helperEnvironment, nativeStatus } from './native.mjs';
6
+
7
+ process.umask(0o077);
8
+ const flags = process.argv.slice(2);
9
+ if (flags.length !== 1 || !['--install', '--login', '--check'].includes(flags[0])) {
10
+ console.error('Usage: node voice/realtime/setup.mjs --install | --login | --check'); process.exitCode = 2;
11
+ } else {
12
+ const run = (binary, args, env) => new Promise((resolve, reject) => {
13
+ const child = spawn(binary, args, { stdio: 'inherit', shell: false, env });
14
+ child.once('error', () => reject(new Error('Cannot start the requested helper.')));
15
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error('The requested helper did not complete.')));
16
+ });
17
+ try {
18
+ if (flags[0] === '--install') {
19
+ fs.mkdirSync(realtimeRoot, { recursive: true, mode: 0o700 });
20
+ await run(process.execPath, [npmCli(), 'install', '--prefix', path.join(realtimeRoot, 'runtime'), '--ignore-scripts', '--no-audit', '--no-fund', '--save-exact', `@openai/codex@${CODEX_VERSION}`], helperEnvironment(defaultHome));
21
+ }
22
+ if (flags[0] === '--login') {
23
+ if (!(await nativeStatus()).installed) throw new Error('Run npm run voice:realtime:setup first.');
24
+ fs.mkdirSync(defaultHome, { recursive: true, mode: 0o700 });
25
+ if (fs.existsSync(path.join(defaultHome, 'config.toml'))) throw new Error('The dedicated voice account directory must not contain a custom config.toml.');
26
+ // An explicit operator command, not an automatic browser popup or token conversion.
27
+ // This is native ChatGPT sign-in, not API-key login and not pi's direct-token registration.
28
+ await run(defaultBinary, ['-c', 'cli_auth_credentials_store="file"', '-c', 'forced_login_method="chatgpt"', 'login', '--device-auth'], helperEnvironment(defaultHome));
29
+ }
30
+ console.log(JSON.stringify(await nativeStatus(), null, 2));
31
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
32
+ }
package/voice/tls.mjs ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Creates a private local CA and LAN certificate. Does NOT install trust or open a firewall.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import net from 'node:net';
7
+ import { X509Certificate, createPrivateKey } from 'node:crypto';
8
+ import { execFileSync } from 'node:child_process';
9
+
10
+ const args = process.argv.slice(2);
11
+ const host = args.includes('--host') ? args[args.indexOf('--host') + 1] : process.env.DRAKEN_HOST || 'localhost';
12
+ const dir = process.env.DRAKEN_TLS_DIR || path.join(process.env.DRAKEN_DATA_DIR || path.join(os.homedir(), '.draken'), 'runtime/tls');
13
+ const ca = path.join(dir, 'draken-local-root.crt'), caKey = path.join(dir, 'draken-local-root.key');
14
+ const cert = path.join(dir, 'draken-server.crt'), key = path.join(dir, 'draken-server.key');
15
+ let locked = false;
16
+ const run = args => execFileSync('openssl', args, { stdio: ['ignore', 'pipe', 'pipe'] });
17
+ try {
18
+ if (!host || (!net.isIP(host) && !/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(host)) || ['0.0.0.0', '::'].includes(host))
19
+ throw new Error('Use --host with the actual LAN IP or hostname used in your browser, not a wildcard bind address.');
20
+ process.umask(0o077); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ fs.mkdirSync(path.join(dir, '.setup-lock')); locked = true;
22
+ if (fs.existsSync(ca) !== fs.existsSync(caKey)) throw new Error('Incomplete existing CA. Refusing to overwrite it.');
23
+ if (!fs.existsSync(ca)) {
24
+ run(['req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-days', '3650', '-nodes', '-keyout', caKey, '-out', ca,
25
+ '-subj', '/CN=Draken local voice root', '-addext', 'basicConstraints=critical,CA:TRUE,pathlen:0', '-addext', 'keyUsage=critical,keyCertSign,cRLSign']);
26
+ }
27
+ const root = new X509Certificate(fs.readFileSync(ca));
28
+ if (!root.ca || !root.checkPrivateKey(createPrivateKey(fs.readFileSync(caKey)))) throw new Error('The existing CA and private key do not match');
29
+ const hosts = [...new Set([host, 'localhost', '127.0.0.1', '::1'])];
30
+ let valid = false;
31
+ if (fs.existsSync(cert) && fs.existsSync(key)) {
32
+ const current = new X509Certificate(fs.readFileSync(cert));
33
+ valid = current.verify(root.publicKey) && current.checkPrivateKey(createPrivateKey(fs.readFileSync(key))) &&
34
+ Date.parse(current.validTo) > Date.now() + 30 * 86400000 && hosts.every(name => net.isIP(name) ? current.checkIP(name) : current.checkHost(name));
35
+ }
36
+ if (!valid) {
37
+ const request = path.join(dir, 'server.csr'), extensions = path.join(dir, 'server.ext');
38
+ fs.writeFileSync(extensions, `basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=${hosts.map(name => `${net.isIP(name) ? 'IP' : 'DNS'}:${name}`).join(',')}\n`);
39
+ run(['req', '-new', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-keyout', key + '.new', '-out', request, '-subj', '/CN=Draken local voice']);
40
+ run(['x509', '-req', '-in', request, '-CA', ca, '-CAkey', caKey, '-CAcreateserial', '-days', '365', '-sha256', '-extfile', extensions, '-out', cert + '.new']);
41
+ fs.renameSync(key + '.new', key); fs.renameSync(cert + '.new', cert);
42
+ fs.rmSync(request); fs.rmSync(extensions);
43
+ }
44
+ console.log(`DRAKEN_HTTPS_PORT=44433\nDRAKEN_TLS_CERT=${cert}\nDRAKEN_TLS_KEY=${key}\nDRAKEN_TLS_CA=${ca}`);
45
+ console.log(`\nPublic CA SHA-256 fingerprint: ${root.fingerprint256}`);
46
+ console.log('Keep both .key files private. Install ONLY draken-local-root.crt as a trusted CA on your client device. See VOICE.md.');
47
+ console.log('Existing CA retained on reruns. Restart Draken after configuring TLS; allow the HTTPS port on your trusted LAN if needed.');
48
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
49
+ finally { if (locked) fs.rmdirSync(path.join(dir, '.setup-lock')); }
@@ -1,16 +0,0 @@
1
- import { Project, Task } from '../types';
2
- export declare function getAllProjects(): Project[];
3
- export declare function getProjectById(id: number): Project | undefined;
4
- export declare function createProject(name: string, path: string): Project;
5
- export declare function deleteProject(id: number): boolean;
6
- export declare function updateProjectDockerfileStatus(id: number, exists: boolean): void;
7
- export declare function getTasksByProjectId(projectId: number): Task[];
8
- export declare function getTaskById(id: number): Task | undefined;
9
- export declare function createTask(projectId: number, prompt: string, parentTaskId?: number): Task;
10
- export declare function updateTaskSessionId(id: number, sessionId: string): void;
11
- export declare function getLatestTaskWithSession(projectId: number): Task | undefined;
12
- export declare function updateTaskStatus(id: number, status: Task['status'], containerId?: string): void;
13
- export declare function updateTaskCompleted(id: number, status: Task['status']): void;
14
- export declare function appendTaskLogs(id: number, newLogs: string): void;
15
- export declare function getRunningTasks(): Task[];
16
- //# sourceMappingURL=queries.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"queries.d.ts","sourceRoot":"","sources":["../../src/db/queries.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAGzC,wBAAgB,cAAc,IAAI,OAAO,EAAE,CAG1C;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAG9D;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAIjE;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAIjD;AAED,wBAAgB,6BAA6B,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAG/E;AAGD,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,CAG7D;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAGxD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAIzF;AAED,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAGvE;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAQ5E;AAED,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAO/F;AAED,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,IAAI,CAG5E;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAKhE;AAED,wBAAgB,eAAe,IAAI,IAAI,EAAE,CAGxC"}