draken 0.1.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCEPTANCE.md +34 -0
- package/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +129 -0
- package/MEMORY.md +118 -0
- package/PERSONA.md +67 -0
- package/README.md +168 -78
- package/SETUP.md +111 -0
- package/SOURCES.md +75 -0
- package/TRAY.md +54 -0
- package/VOICE.md +126 -0
- package/dist/auth/middleware.d.ts +3 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +20 -15
- package/dist/auth/middleware.js.map +1 -1
- package/dist/cli.js +77 -113
- package/dist/cli.js.map +1 -1
- package/dist/db/agent-migration.d.ts +7 -0
- package/dist/db/agent-migration.d.ts.map +1 -0
- package/dist/db/agent-migration.js +36 -0
- package/dist/db/agent-migration.js.map +1 -0
- package/dist/db/evidence.d.ts +37 -0
- package/dist/db/evidence.d.ts.map +1 -0
- package/dist/db/evidence.js +110 -0
- package/dist/db/evidence.js.map +1 -0
- package/dist/db/pointer-migration.d.ts +7 -0
- package/dist/db/pointer-migration.d.ts.map +1 -0
- package/dist/db/pointer-migration.js +117 -0
- package/dist/db/pointer-migration.js.map +1 -0
- package/dist/db/retained-text.d.ts +8 -0
- package/dist/db/retained-text.d.ts.map +1 -0
- package/dist/db/retained-text.js +36 -0
- package/dist/db/retained-text.js.map +1 -0
- package/dist/db/schema.d.ts +1 -5
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +206 -55
- package/dist/db/schema.js.map +1 -1
- package/dist/db/snapshot-pointers.d.ts +5 -0
- package/dist/db/snapshot-pointers.d.ts.map +1 -0
- package/dist/db/snapshot-pointers.js +174 -0
- package/dist/db/snapshot-pointers.js.map +1 -0
- package/dist/memory/automatic.d.ts +38 -0
- package/dist/memory/automatic.d.ts.map +1 -0
- package/dist/memory/automatic.js +155 -0
- package/dist/memory/automatic.js.map +1 -0
- package/dist/memory/context.d.ts +12 -0
- package/dist/memory/context.d.ts.map +1 -0
- package/dist/memory/context.js +107 -0
- package/dist/memory/context.js.map +1 -0
- package/dist/memory/embedding-pool.d.ts +28 -0
- package/dist/memory/embedding-pool.d.ts.map +1 -0
- package/dist/memory/embedding-pool.js +135 -0
- package/dist/memory/embedding-pool.js.map +1 -0
- package/dist/memory/embedding-worker.d.ts +2 -0
- package/dist/memory/embedding-worker.d.ts.map +1 -0
- package/dist/memory/embedding-worker.js +32 -0
- package/dist/memory/embedding-worker.js.map +1 -0
- package/dist/memory/index.d.ts +32 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +441 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/memory/model-files.d.ts +9 -0
- package/dist/memory/model-files.d.ts.map +1 -0
- package/dist/memory/model-files.js +13 -0
- package/dist/memory/model-files.js.map +1 -0
- package/dist/memory/model.d.ts +15 -0
- package/dist/memory/model.d.ts.map +1 -0
- package/dist/memory/model.js +147 -0
- package/dist/memory/model.js.map +1 -0
- package/dist/memory/readiness.d.ts +16 -0
- package/dist/memory/readiness.d.ts.map +1 -0
- package/dist/memory/readiness.js +131 -0
- package/dist/memory/readiness.js.map +1 -0
- package/dist/memory/reconcile.d.ts +11 -0
- package/dist/memory/reconcile.d.ts.map +1 -0
- package/dist/memory/reconcile.js +132 -0
- package/dist/memory/reconcile.js.map +1 -0
- package/dist/memory/service.d.ts +10 -0
- package/dist/memory/service.d.ts.map +1 -0
- package/dist/memory/service.js +243 -0
- package/dist/memory/service.js.map +1 -0
- package/dist/memory/sources.d.ts +15 -0
- package/dist/memory/sources.d.ts.map +1 -0
- package/dist/memory/sources.js +94 -0
- package/dist/memory/sources.js.map +1 -0
- package/dist/memory/stamps.d.ts +13 -0
- package/dist/memory/stamps.d.ts.map +1 -0
- package/dist/memory/stamps.js +101 -0
- package/dist/memory/stamps.js.map +1 -0
- package/dist/memory/storage.d.ts +5 -0
- package/dist/memory/storage.d.ts.map +1 -0
- package/dist/memory/storage.js +116 -0
- package/dist/memory/storage.js.map +1 -0
- package/dist/memory/types.d.ts +89 -0
- package/dist/memory/types.d.ts.map +1 -0
- package/dist/memory/types.js +8 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/memory/worker.d.ts +2 -0
- package/dist/memory/worker.d.ts.map +1 -0
- package/dist/memory/worker.js +84 -0
- package/dist/memory/worker.js.map +1 -0
- package/dist/npm-cli.d.ts +4 -0
- package/dist/npm-cli.d.ts.map +1 -0
- package/dist/npm-cli.js +36 -0
- package/dist/npm-cli.js.map +1 -0
- package/dist/pi/runtime.d.ts +34 -0
- package/dist/pi/runtime.d.ts.map +1 -0
- package/dist/pi/runtime.js +210 -0
- package/dist/pi/runtime.js.map +1 -0
- package/dist/pi/setup.d.ts +5 -0
- package/dist/pi/setup.d.ts.map +1 -0
- package/dist/pi/setup.js +147 -0
- package/dist/pi/setup.js.map +1 -0
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +3 -1
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/live-voice.d.ts +3 -0
- package/dist/routes/live-voice.d.ts.map +1 -0
- package/dist/routes/live-voice.js +49 -0
- package/dist/routes/live-voice.js.map +1 -0
- package/dist/routes/{browse.d.ts → memory.d.ts} +1 -1
- package/dist/routes/memory.d.ts.map +1 -0
- package/dist/routes/memory.js +18 -0
- package/dist/routes/memory.js.map +1 -0
- package/dist/routes/{tasks.d.ts → sync.d.ts} +1 -1
- package/dist/routes/sync.d.ts.map +1 -0
- package/dist/routes/sync.js +145 -0
- package/dist/routes/sync.js.map +1 -0
- package/dist/routes/{projects.d.ts → voice.d.ts} +1 -1
- package/dist/routes/voice.d.ts.map +1 -0
- package/dist/routes/voice.js +35 -0
- package/dist/routes/voice.js.map +1 -0
- package/dist/server-state.d.ts +30 -0
- package/dist/server-state.d.ts.map +1 -0
- package/dist/server-state.js +113 -0
- package/dist/server-state.js.map +1 -0
- package/dist/server-status.d.ts +7 -0
- package/dist/server-status.d.ts.map +1 -0
- package/dist/server-status.js +34 -0
- package/dist/server-status.js.map +1 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +148 -46
- package/dist/server.js.map +1 -1
- package/dist/sources/adapters.d.ts +15 -0
- package/dist/sources/adapters.d.ts.map +1 -0
- package/dist/sources/adapters.js +159 -0
- package/dist/sources/adapters.js.map +1 -0
- package/dist/sources/registry.d.ts +17 -0
- package/dist/sources/registry.d.ts.map +1 -0
- package/dist/sources/registry.js +56 -0
- package/dist/sources/registry.js.map +1 -0
- package/dist/sync/collector.d.ts +3 -0
- package/dist/sync/collector.d.ts.map +1 -0
- package/dist/sync/collector.js +324 -0
- package/dist/sync/collector.js.map +1 -0
- package/dist/sync/cost.d.ts +7 -0
- package/dist/sync/cost.d.ts.map +1 -0
- package/dist/sync/cost.js +31 -0
- package/dist/sync/cost.js.map +1 -0
- package/dist/sync/jsonl.d.ts +34 -0
- package/dist/sync/jsonl.d.ts.map +1 -0
- package/dist/sync/jsonl.js +161 -0
- package/dist/sync/jsonl.js.map +1 -0
- package/dist/sync/manager.d.ts +15 -0
- package/dist/sync/manager.d.ts.map +1 -0
- package/dist/sync/manager.js +287 -0
- package/dist/sync/manager.js.map +1 -0
- package/dist/sync/normalize.d.ts +22 -0
- package/dist/sync/normalize.d.ts.map +1 -0
- package/dist/sync/normalize.js +107 -0
- package/dist/sync/normalize.js.map +1 -0
- package/dist/sync/pi-output.d.ts +33 -0
- package/dist/sync/pi-output.d.ts.map +1 -0
- package/dist/sync/pi-output.js +186 -0
- package/dist/sync/pi-output.js.map +1 -0
- package/dist/sync/pi.d.ts +15 -0
- package/dist/sync/pi.d.ts.map +1 -0
- package/dist/sync/pi.js +149 -0
- package/dist/sync/pi.js.map +1 -0
- package/dist/sync/recap.d.ts +25 -0
- package/dist/sync/recap.d.ts.map +1 -0
- package/dist/sync/recap.js +118 -0
- package/dist/sync/recap.js.map +1 -0
- package/dist/sync/reconciliation.d.ts +16 -0
- package/dist/sync/reconciliation.d.ts.map +1 -0
- package/dist/sync/reconciliation.js +22 -0
- package/dist/sync/reconciliation.js.map +1 -0
- package/dist/sync/references.d.ts +10 -0
- package/dist/sync/references.d.ts.map +1 -0
- package/dist/sync/references.js +50 -0
- package/dist/sync/references.js.map +1 -0
- package/dist/sync/source-files.d.ts +16 -0
- package/dist/sync/source-files.d.ts.map +1 -0
- package/dist/sync/source-files.js +56 -0
- package/dist/sync/source-files.js.map +1 -0
- package/dist/sync/store.d.ts +36 -0
- package/dist/sync/store.d.ts.map +1 -0
- package/dist/sync/store.js +359 -0
- package/dist/sync/store.js.map +1 -0
- package/dist/sync/summary-budget.d.ts +19 -0
- package/dist/sync/summary-budget.d.ts.map +1 -0
- package/dist/sync/summary-budget.js +39 -0
- package/dist/sync/summary-budget.js.map +1 -0
- package/dist/sync/tree.d.ts +69 -0
- package/dist/sync/tree.d.ts.map +1 -0
- package/dist/sync/tree.js +599 -0
- package/dist/sync/tree.js.map +1 -0
- package/dist/tray/controller.d.ts +51 -0
- package/dist/tray/controller.d.ts.map +1 -0
- package/dist/tray/controller.js +178 -0
- package/dist/tray/controller.js.map +1 -0
- package/dist/tray/launcher.d.ts +3 -0
- package/dist/tray/launcher.d.ts.map +1 -0
- package/dist/tray/launcher.js +47 -0
- package/dist/tray/launcher.js.map +1 -0
- package/dist/tray/native.d.ts +12 -0
- package/dist/tray/native.d.ts.map +1 -0
- package/dist/tray/native.js +93 -0
- package/dist/tray/native.js.map +1 -0
- package/dist/tray/processes.d.ts +5 -0
- package/dist/tray/processes.d.ts.map +1 -0
- package/dist/tray/processes.js +99 -0
- package/dist/tray/processes.js.map +1 -0
- package/dist/tray/worker.d.ts +4 -0
- package/dist/tray/worker.d.ts.map +1 -0
- package/dist/tray/worker.js +150 -0
- package/dist/tray/worker.js.map +1 -0
- package/dist/types.d.ts +221 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/voice/continuation.d.ts +25 -0
- package/dist/voice/continuation.d.ts.map +1 -0
- package/dist/voice/continuation.js +95 -0
- package/dist/voice/continuation.js.map +1 -0
- package/dist/voice/history-focus.d.ts +14 -0
- package/dist/voice/history-focus.d.ts.map +1 -0
- package/dist/voice/history-focus.js +39 -0
- package/dist/voice/history-focus.js.map +1 -0
- package/dist/voice/legacy-history.d.ts +5 -0
- package/dist/voice/legacy-history.d.ts.map +1 -0
- package/dist/voice/legacy-history.js +34 -0
- package/dist/voice/legacy-history.js.map +1 -0
- package/dist/voice/live-context.d.ts +14 -0
- package/dist/voice/live-context.d.ts.map +1 -0
- package/dist/voice/live-context.js +137 -0
- package/dist/voice/live-context.js.map +1 -0
- package/dist/voice/live.d.ts +37 -0
- package/dist/voice/live.d.ts.map +1 -0
- package/dist/voice/live.js +462 -0
- package/dist/voice/live.js.map +1 -0
- package/dist/voice/preparation.d.ts +8 -0
- package/dist/voice/preparation.d.ts.map +1 -0
- package/dist/voice/preparation.js +59 -0
- package/dist/voice/preparation.js.map +1 -0
- package/package.json +49 -16
- package/public/assets/index-BQFBaSKm.js +40 -0
- package/public/assets/index-CYCbTSh1.js +131 -0
- package/public/assets/index-DntUQtGK.css +1 -0
- package/public/assets/persona-BVIfx5k8.js +1 -0
- package/public/assets/persona-C_m1CVKe.css +1 -0
- package/public/assets/scene-CBV2oPhA.js +3854 -0
- package/public/index.html +6 -8
- package/public/persona/THREE-LICENSE.txt +21 -0
- package/public/persona/call-poster.webp +0 -0
- package/public/persona/chat-avatar.webp +0 -0
- package/public/persona/reference.jpg +0 -0
- package/public/persona/sample.wav +0 -0
- package/public/persona.html +7 -0
- package/runtime/pi/package-lock.json +1549 -0
- package/runtime/pi/package.json +12 -0
- package/tests/fixtures/voice-question.wav +0 -0
- package/tray/assets/error.ico +0 -0
- package/tray/assets/error.png +0 -0
- package/tray/assets/running.ico +0 -0
- package/tray/assets/running.png +0 -0
- package/tray/assets/starting.ico +0 -0
- package/tray/assets/starting.png +0 -0
- package/tray/assets/stopped.ico +0 -0
- package/tray/assets/stopped.png +0 -0
- package/tray/assets/stopping.ico +0 -0
- package/tray/assets/stopping.png +0 -0
- package/tray/macos.js +72 -0
- package/tray/windows.ps1 +104 -0
- package/voice/realtime/README.md +84 -0
- package/voice/realtime/backend.mjs +81 -0
- package/voice/realtime/native.mjs +210 -0
- package/voice/realtime/peer.d.mts +15 -0
- package/voice/realtime/peer.mjs +116 -0
- package/voice/realtime/probe-browser.mjs +81 -0
- package/voice/realtime/probe-server.mjs +86 -0
- package/voice/realtime/probe.html +11 -0
- package/voice/realtime/probe.mjs +42 -0
- package/voice/realtime/rpc.mjs +118 -0
- package/voice/realtime/setup.mjs +32 -0
- package/voice/tls.mjs +49 -0
- package/dist/db/queries.d.ts +0 -16
- package/dist/db/queries.d.ts.map +0 -1
- package/dist/db/queries.js +0 -91
- package/dist/db/queries.js.map +0 -1
- package/dist/docker/dockerfile.d.ts +0 -50
- package/dist/docker/dockerfile.d.ts.map +0 -1
- package/dist/docker/dockerfile.js +0 -160
- package/dist/docker/dockerfile.js.map +0 -1
- package/dist/docker/manager.d.ts +0 -42
- package/dist/docker/manager.d.ts.map +0 -1
- package/dist/docker/manager.js +0 -421
- package/dist/docker/manager.js.map +0 -1
- package/dist/git/status.d.ts +0 -76
- package/dist/git/status.d.ts.map +0 -1
- package/dist/git/status.js +0 -332
- package/dist/git/status.js.map +0 -1
- package/dist/routes/browse.d.ts.map +0 -1
- package/dist/routes/browse.js +0 -49
- package/dist/routes/browse.js.map +0 -1
- package/dist/routes/git.d.ts +0 -9
- package/dist/routes/git.d.ts.map +0 -1
- package/dist/routes/git.js +0 -338
- package/dist/routes/git.js.map +0 -1
- package/dist/routes/projects.d.ts.map +0 -1
- package/dist/routes/projects.js +0 -220
- package/dist/routes/projects.js.map +0 -1
- package/dist/routes/tasks.d.ts.map +0 -1
- package/dist/routes/tasks.js +0 -313
- package/dist/routes/tasks.js.map +0 -1
- package/draken.dockerfile.template +0 -18
- package/public/assets/index-3ngsG5kF.css +0 -1
- package/public/assets/index-B3YSD0ee.js +0 -252
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "draken-private-pi-runtime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Pinned private summarization runtime; installed only by draken setup",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@earendil-works/pi-coding-agent": "1.5.0"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.19.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tray/macos.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* Native menu-bar helper, run by /usr/bin/osascript -l JavaScript.
|
|
2
|
+
* No Apple Events to other apps, shell commands, provider state or network access. */
|
|
3
|
+
ObjC.import('AppKit');
|
|
4
|
+
ObjC.import('Foundation');
|
|
5
|
+
|
|
6
|
+
function run() {
|
|
7
|
+
var environment = $.NSProcessInfo.processInfo.environment;
|
|
8
|
+
var statePath = ObjC.unwrap(environment.objectForKey('DRAKEN_TRAY_STATE'));
|
|
9
|
+
var assets = ObjC.unwrap(environment.objectForKey('DRAKEN_TRAY_ASSETS'));
|
|
10
|
+
var app = $.NSApplication.sharedApplication;
|
|
11
|
+
app.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
|
|
12
|
+
var tray = $.NSStatusBar.systemStatusBar.statusItemWithLength($.NSVariableStatusItemLength);
|
|
13
|
+
var menu = $.NSMenu.alloc.init;
|
|
14
|
+
menu.autoenablesItems = false;
|
|
15
|
+
var items = {}, lastPhase = '', timer;
|
|
16
|
+
function emit(value) {
|
|
17
|
+
$.NSFileHandle.fileHandleWithStandardOutput.writeData($(JSON.stringify(value) + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
|
|
18
|
+
}
|
|
19
|
+
function update() {
|
|
20
|
+
var bytes = $.NSData.dataWithContentsOfFile($(statePath));
|
|
21
|
+
if (Number(bytes.length) > 16384) throw new Error('Invalid tray state');
|
|
22
|
+
var state = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(bytes, $.NSUTF8StringEncoding)));
|
|
23
|
+
if (state.quit || Math.abs(Date.now() - state.updated) > 15000) { app.terminate(null); return; }
|
|
24
|
+
if (state.version !== 1 || ['running', 'stopped', 'starting', 'stopping', 'error'].indexOf(state.phase) < 0) throw new Error('Invalid tray state');
|
|
25
|
+
tray.button.toolTip = $(state.title + '\n' + state.detail);
|
|
26
|
+
items.status.title = $(state.title);
|
|
27
|
+
items.detail.title = $(state.detail || 'Server connectivity only — not model readiness');
|
|
28
|
+
items.open.enabled = !!state.canOpen;
|
|
29
|
+
items.start.enabled = !!state.canStart;
|
|
30
|
+
items.stop.enabled = !!state.canStop;
|
|
31
|
+
items.quit.title = $(state.owned ? 'Quit tray and stop Draken' : 'Quit tray (leave server running)');
|
|
32
|
+
if (lastPhase !== state.phase) {
|
|
33
|
+
var image = $.NSImage.alloc.initWithContentsOfFile($(assets + '/' + state.phase + '.png'));
|
|
34
|
+
image.size = $.NSMakeSize(20, 20);
|
|
35
|
+
image.template = false;
|
|
36
|
+
tray.button.image = image;
|
|
37
|
+
lastPhase = state.phase;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
ObjC.registerSubclass({ name: 'DrakenTrayDelegate', superclass: 'NSObject', methods: {
|
|
41
|
+
'clicked:': { types: ['void', ['id']], implementation: function(sender) {
|
|
42
|
+
emit({ type: 'action', action: ObjC.unwrap(sender.representedObject) });
|
|
43
|
+
} },
|
|
44
|
+
'tick:': { types: ['void', ['id']], implementation: function() {
|
|
45
|
+
try { update(); } catch (error) { app.terminate(null); }
|
|
46
|
+
} }
|
|
47
|
+
} });
|
|
48
|
+
var delegate = $.DrakenTrayDelegate.alloc.init;
|
|
49
|
+
function item(id, title, enabled) {
|
|
50
|
+
var value = $.NSMenuItem.alloc.initWithTitleActionKeyEquivalent($(title), enabled ? 'clicked:' : null, $(''));
|
|
51
|
+
value.target = delegate;
|
|
52
|
+
value.representedObject = $(id);
|
|
53
|
+
value.enabled = enabled;
|
|
54
|
+
items[id] = value;
|
|
55
|
+
menu.addItem(value);
|
|
56
|
+
}
|
|
57
|
+
item('status', 'Draken — Starting', false);
|
|
58
|
+
item('detail', 'Checking server', false);
|
|
59
|
+
menu.addItem($.NSMenuItem.separatorItem);
|
|
60
|
+
item('open', 'Open Draken', true);
|
|
61
|
+
item('start', 'Start Draken (current settings)', true);
|
|
62
|
+
item('stop', 'Stop Draken (ends active calls)', true);
|
|
63
|
+
menu.addItem($.NSMenuItem.separatorItem);
|
|
64
|
+
item('quit', 'Quit tray', true);
|
|
65
|
+
tray.menu = menu;
|
|
66
|
+
update();
|
|
67
|
+
timer = $.NSTimer.timerWithTimeIntervalTargetSelectorUserInfoRepeats(0.5, delegate, 'tick:', null, true);
|
|
68
|
+
$.NSRunLoop.mainRunLoop.addTimerForMode(timer, $.NSRunLoopCommonModes);
|
|
69
|
+
app.finishLaunching;
|
|
70
|
+
emit({ type: 'ready' });
|
|
71
|
+
app.run;
|
|
72
|
+
}
|
package/tray/windows.ps1
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Native notification-area helper. Executed as fixed packaged PowerShell code;
|
|
2
|
+
# never evaluates presentation data or changes execution/application-control policy.
|
|
3
|
+
$ErrorActionPreference = 'Stop'
|
|
4
|
+
[Console]::Out.WriteLine('{"type":"progress","phase":"configuring-output"}')
|
|
5
|
+
[Console]::Out.Flush()
|
|
6
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
7
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
8
|
+
$PSModuleAutoLoadingPreference = 'None'
|
|
9
|
+
# Load only the required system module, not user module discovery (which can
|
|
10
|
+
# spend 30+ seconds scanning a fresh Windows profile before the first cmdlet).
|
|
11
|
+
Import-Module ($PSHOME + '\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop
|
|
12
|
+
[Console]::Out.WriteLine('{"type":"progress","phase":"loading-frameworks"}')
|
|
13
|
+
[Console]::Out.Flush()
|
|
14
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
15
|
+
Add-Type -AssemblyName System.Drawing
|
|
16
|
+
if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) { throw 'An interactive Windows desktop is required' }
|
|
17
|
+
$statePath = $env:DRAKEN_TRAY_STATE
|
|
18
|
+
$assets = $env:DRAKEN_TRAY_ASSETS
|
|
19
|
+
$tray = [System.Windows.Forms.NotifyIcon]::new()
|
|
20
|
+
$menu = [System.Windows.Forms.ContextMenuStrip]::new()
|
|
21
|
+
$items = @{}
|
|
22
|
+
$icons = @{}
|
|
23
|
+
$timer = [System.Windows.Forms.Timer]::new()
|
|
24
|
+
|
|
25
|
+
function Emit($value) {
|
|
26
|
+
[Console]::Out.WriteLine(($value | ConvertTo-Json -Compress))
|
|
27
|
+
[Console]::Out.Flush()
|
|
28
|
+
}
|
|
29
|
+
function Add-Item($id, $text, $actionable) {
|
|
30
|
+
$item = [System.Windows.Forms.ToolStripMenuItem]::new()
|
|
31
|
+
$item.Text = $text
|
|
32
|
+
$item.Tag = $id
|
|
33
|
+
$item.Enabled = $actionable
|
|
34
|
+
if ($actionable) {
|
|
35
|
+
$item.add_Click({ param($sender, $eventArgs) Emit @{type='action'; action=[string]$sender.Tag} })
|
|
36
|
+
}
|
|
37
|
+
$items[$id] = $item
|
|
38
|
+
[void]$menu.Items.Add($item)
|
|
39
|
+
}
|
|
40
|
+
function Update-State {
|
|
41
|
+
# Allow atomic replacement while the parent publishes the next heartbeat.
|
|
42
|
+
$share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete
|
|
43
|
+
$stream = [System.IO.FileStream]::new($statePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $share)
|
|
44
|
+
$reader = $null
|
|
45
|
+
try {
|
|
46
|
+
if ($stream.Length -gt 16384) { throw 'Invalid tray state' }
|
|
47
|
+
$reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8)
|
|
48
|
+
$state = $reader.ReadToEnd() | ConvertFrom-Json
|
|
49
|
+
} finally {
|
|
50
|
+
if ($null -ne $reader) { $reader.Dispose() } else { $stream.Dispose() }
|
|
51
|
+
}
|
|
52
|
+
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
53
|
+
if ($state.quit -or [Math]::Abs($now - [double]$state.updated) -gt 15000) {
|
|
54
|
+
$timer.Stop()
|
|
55
|
+
[System.Windows.Forms.Application]::ExitThread()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if ($state.version -ne 1 -or -not $icons.ContainsKey([string]$state.phase)) { throw 'Invalid tray state' }
|
|
59
|
+
$tray.Icon = $icons[[string]$state.phase]
|
|
60
|
+
$title = [string]$state.title
|
|
61
|
+
$tray.Text = $title.Substring(0, [Math]::Min(63, $title.Length))
|
|
62
|
+
$items.status.Text = $title
|
|
63
|
+
$items.detail.Text = if ($state.detail) { [string]$state.detail } else { 'Server connectivity only - not model readiness' }
|
|
64
|
+
$items.open.Enabled = [bool]$state.canOpen
|
|
65
|
+
$items.start.Enabled = [bool]$state.canStart
|
|
66
|
+
$items.stop.Enabled = [bool]$state.canStop
|
|
67
|
+
$items.quit.Text = if ($state.owned) { 'Quit tray and stop Draken' } else { 'Quit tray (leave server running)' }
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
Emit @{type='progress'; phase='creating-menu'}
|
|
71
|
+
foreach ($phase in @('running','stopped','starting','stopping','error')) {
|
|
72
|
+
$icons[$phase] = [System.Drawing.Icon]::new([System.IO.Path]::Combine($assets, ($phase + '.ico')))
|
|
73
|
+
}
|
|
74
|
+
Add-Item 'status' 'Draken - Starting' $false
|
|
75
|
+
Add-Item 'detail' 'Checking server' $false
|
|
76
|
+
[void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new())
|
|
77
|
+
Add-Item 'open' 'Open Draken' $true
|
|
78
|
+
Add-Item 'start' 'Start Draken (current settings)' $true
|
|
79
|
+
Add-Item 'stop' 'Stop Draken (ends active calls)' $true
|
|
80
|
+
[void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new())
|
|
81
|
+
Add-Item 'quit' 'Quit tray' $true
|
|
82
|
+
$tray.ContextMenuStrip = $menu
|
|
83
|
+
$tray.add_DoubleClick({ if ($items.open.Enabled) { Emit @{type='action'; action='open'} } })
|
|
84
|
+
Update-State
|
|
85
|
+
Emit @{type='progress'; phase='showing-icon'}
|
|
86
|
+
$tray.Visible = $true
|
|
87
|
+
$timer.Interval = 500
|
|
88
|
+
$timer.add_Tick({
|
|
89
|
+
try { Update-State } catch {
|
|
90
|
+
$timer.Stop()
|
|
91
|
+
[System.Windows.Forms.Application]::ExitThread()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
$timer.Start()
|
|
95
|
+
Emit @{type='ready'}
|
|
96
|
+
[System.Windows.Forms.Application]::Run()
|
|
97
|
+
} finally {
|
|
98
|
+
$timer.Stop()
|
|
99
|
+
$timer.Dispose()
|
|
100
|
+
$tray.Visible = $false
|
|
101
|
+
$tray.Dispose()
|
|
102
|
+
$menu.Dispose()
|
|
103
|
+
foreach ($icon in $icons.Values) { $icon.Dispose() }
|
|
104
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Experimental subscription-backed live voice
|
|
2
|
+
|
|
3
|
+
This is Draken’s native WebRTC voice backend, using the official **Codex 0.166.0 App Server** and a separate native ChatGPT sign-in to the same paid account. The browser has one persistent audio connection to OpenAI. It does not start pi, local transcription, or local synthesis for each question.
|
|
4
|
+
|
|
5
|
+
**Experimental, not a stable public embedding API.** Account/workspace access and future compatibility can change. A paid plan or successful SDP alone does not establish usable voice. There is no Platform API-key or alternative-provider fallback. This is the only voice engine. The local speech engine has been removed; only its historical call records remain readable.
|
|
6
|
+
|
|
7
|
+
## Operator setup
|
|
8
|
+
|
|
9
|
+
From a development checkout:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm run voice:realtime:setup
|
|
13
|
+
npm run voice:realtime:login # owner completes native ChatGPT device sign-in
|
|
14
|
+
npm run voice:realtime:check # structural check only, no microphone/model
|
|
15
|
+
npm run voice:realtime:probe -- --allow-provider
|
|
16
|
+
|
|
17
|
+
# After qualification, launch the server:
|
|
18
|
+
npm start
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
No mode setting is required. Existing `DRAKEN_VOICE_MODE=native` is accepted, but `local` and unknown values are rejected rather than silently changing an old audio-privacy choice. `.env` is not loaded automatically. For an existing installation, stop active calls/model runs, back up its database and retain its existing authentication/TLS settings before restarting. Do not regenerate a sync to enable voice.
|
|
22
|
+
|
|
23
|
+
Node/npm must be on the operator’s PATH. A pi-managed runtime may already exist at `$HOME/.local/share/pi-node/current/bin`; no OS package installation is necessary if that runtime works.
|
|
24
|
+
|
|
25
|
+
The pinned helper and its separate credentials live under `~/.draken/realtime/`, outside the repository. Authentication/refresh belongs to the official helper. This implementation never reads or converts pi’s credential. The browser receives no provider token. No inherited API keys, proxy/provider overrides, pi ownership variables or Node preload settings reach the helper. Custom account `config.toml` files and unqualified non-default workspace regions fail closed. Trusted operators/tests may override `DRAKEN_CODEX_BIN` and `DRAKEN_CODEX_HOME`; the exact helper-version and native-auth checks still apply.
|
|
26
|
+
|
|
27
|
+
## Audio proof, not just a handshake
|
|
28
|
+
|
|
29
|
+
The opt-in probe requires two real spoken-input fixtures, both roles’ transcripts, and nonzero received audio on one connection. It reports setup separately from turn-to-first-audio timing and confirms browser cleanup and helper exit separately. It uses synthetic data, not the microphone or Draken database. Its temporary server is loopback-only, capability-header authenticated, same-origin/Host restricted, with a ten-second lease and ninety-second lifetime. Reports are private under `~/.draken/realtime/reports/`; no recordings, SDP or tokens are saved.
|
|
30
|
+
|
|
31
|
+
`nativeLoginPresent` and `readyForProbe` describe local prerequisites, not entitlement. The structural check always reports `audioVerified: false`; it does not reuse an earlier success. A successful current provider probe is the account-specific audio proof. Local-peer tests are not a substitute.
|
|
32
|
+
|
|
33
|
+
## Read-only architecture
|
|
34
|
+
|
|
35
|
+
- `rpc.mjs`: bounded private stdio RPC, correlation, deadlines/abort, sanitized errors, and unconditional denial of server-initiated tools/approvals. Unix shutdown targets the launcher/native-child process group, with escalation and exit confirmation.
|
|
36
|
+
- `native.mjs`: native-only account check, ephemeral thread, early-SDP-safe negotiation, text append and cleanup. Call creation uses the Codex backend; the sideband uses the official helper’s default route rather than a guessed override.
|
|
37
|
+
- `backend.mjs`: an authenticated, owned loopback Responses-compatible **text-only adapter**, not a proxy to a coding model. Without an explicit callback it denies inference. A callback may return only one assistant text message, never executable tool items. Incoming instructions, tool schemas and arbitrary options are not forwarded. Catalog probes are denied without being mistaken for work execution. Requests, body size, concurrency and callback duration are bounded.
|
|
38
|
+
- Sandbox/feature flags are additional precautions. The primary execution boundary is that there is **no backing coding-model inference or function-call output**, and every RPC execution/approval request is rejected. `clientManagedHandoffs` alone is not an execution-disable switch.
|
|
39
|
+
- `peer.mjs`: one audio track, persistent WebRTC/data channel, received-energy meter, explicit input replacement, immediate local mute/hang-up, late-SDP rejection and complete track/context cleanup. A muted decoder starts Chromium’s receiver; only the Web Audio graph is audible. No automatic capture or reconnection.
|
|
40
|
+
|
|
41
|
+
### Frozen evidence, not another model call
|
|
42
|
+
|
|
43
|
+
`src/voice/live-context.ts` supplies a bounded frozen briefing, saved notes, prior-sync decisions and source excerpts at call start. Historical discussion remains durable and is returned as up to eight dated, bounded quotes only when the current user explicitly asks to recall older discussion; keyword matching can select older relevant messages. A request for the last recorded question selects the last user message, not the assistant’s closing question. Old requests are never injected as an active conversation at startup. The persistent provider session retains this call’s own ongoing conversation. Initial packets are at most 24,000 UTF-8 bytes. Retained bracketed source markers always have supplied excerpts; unavailable markers are replaced explicitly. Excerpts preserve source roles and visibly omit their middles when needed, retaining closing qualifications rather than only a prefix.
|
|
44
|
+
|
|
45
|
+
Current questions proactively receive bounded context before typed input or when a spoken transcript completes, without depending on model delegation. Ordinary questions automatically use the hybrid historical index described in [MEMORY.md](../../MEMORY.md); explicit E/C requests retain the snapshot path. Spoken context delivery can race the beginning of a reply, so it is not a claim-validation gate. Requests about what was just said in this call do not automatically inject older snapshot discussion.
|
|
46
|
+
|
|
47
|
+
`src/voice/continuation.ts` tracks one pending answer per real user message. Ordinary developer context is background, not a reliable request to resume speech. After context delivery, a pending answer gets at most one official `thread/realtime/appendSpeech` completion cue. This is application control for the original question, not another user message, tool execution, or another speech/model provider. A response already in progress is allowed to finish; a normal completed reply is not repeated. Short English lookup promises (the default response language), including the reported “I’ll check what’s recorded”, keep the turn pending, as do brief acknowledgements and backchannels such as “Mm-hmm” on a real question. A final transcript part is not necessarily a completed answer. Simple social replies do not restart a lookup conversation. This conservative recognizer is not a semantic answer-quality validator or a guarantee for every language/phrasing. The prompt also requires actual follow-through and forbids speaking the control text.
|
|
48
|
+
|
|
49
|
+
Each turn has its own cancellation signal and identity, including repeated identical questions. Stop, first transcript of newer input, call end and helper failure cancel timers and old retrieval ownership. Results delivered after cancellation cannot send a completion cue; already-sent provider work cannot be recalled, so local Stop still mutes immediately. Acknowledgements (including delayed or duplicated events) never authorize another cue. A failed completion request or the fixed 45-second completion deadline produces a visible failure without further automatic attempts. Normal transcript/playback qualifications and explicit review boundaries remain unchanged.
|
|
50
|
+
|
|
51
|
+
If the voice model delegates a lookup, `src/voice/live.ts` runs deterministic retrieval over that same frozen snapshot. The delegation text is an untrusted query, not instructions. No shell, files, network searches or pi model calls are available. Handoff packets are at most 3,500 UTF-8 bytes, below the pinned helper’s approximately 1,000-token handoff limit. Explicit source requests take priority. This is bounded lexical retrieval, not a comprehensive search.
|
|
52
|
+
|
|
53
|
+
**Native speech may paraphrase.** It does not validate a complete JSON answer before speech is played. The UI says **Sources supplied**, not “verified citations.” An assistant’s claim is not independent test evidence, truncated output cannot establish a final suite pass, and an unverified deployment is not proof of a failed or absent deployment. These remain model instructions and human-review obligations, not a proof of semantic correctness.
|
|
54
|
+
|
|
55
|
+
### Main call UI, history and cleanup
|
|
56
|
+
|
|
57
|
+
`client/src/voice/live-session.ts` owns the native call, capture, polling and controls. The existing image-only dragon follows received audio energy through the read-only playback contract. Live motion is energy-driven, not phoneme-aligned. Speaker-safe input gating is the default; Headphone mode allows spoken interruption. Mute releases microphone tracks, typed questions work without microphone permission, and hang-up/page exit stops local media immediately.
|
|
58
|
+
|
|
59
|
+
**Stop reply cuts local playback and requests a remote stop.** It is not confirmation of immediate provider cancellation or accounting. A new typed question can continue within the same provider turn, so playback must not wait forever for another `turn.created`. A brief old tail or acknowledgment can precede the new reply. Physical echo, Bluetooth routing and device behavior still need hands-on qualification.
|
|
60
|
+
|
|
61
|
+
`live_calls` / `live_messages` are additive tables. Legacy history is retained. Live records are ordered by message initiation, not invented question–answer pairing; streams may overlap. They distinguish generated/interrupted text and observed audio energy from **exact playback**, which is not confirmed. Generated speech and proposals are not agreement. Transcript memory is explicitly marked as such. Calls cannot write decisions, finish syncs or advance coverage.
|
|
62
|
+
|
|
63
|
+
There is one call at a time, a 90-second disconnect lease, a 30-minute limit and at most 60 user messages. The main voice session allows at most 120 delegated lookup requests per call, independently of the user-message limit; these are local data lookups, not additional pi model calls. The text-only adapter defaults to eight requests when no limit is supplied and accepts only integer limits from 1 to 120. Rejected authentication and catalog probes do not consume that allowance; an admitted lookup still counts if its body or callback fails. A new call cannot start until helper cleanup is confirmed. Reload never resumes capture automatically; server recovery marks unfinished native history interrupted. LAN APIs retain the dashboard’s authentication and same-origin restrictions. Trusted HTTPS remains necessary for LAN microphones.
|
|
64
|
+
|
|
65
|
+
## Verification
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm test
|
|
69
|
+
npm run test:realtime:browser # real LOCAL peer-to-peer audio, synthetic service
|
|
70
|
+
npm run test:live:browser # main controller, local peers/API doubles
|
|
71
|
+
npm run test:realtime:native # real pinned helper; unusable synthetic auth, loopback only
|
|
72
|
+
|
|
73
|
+
# Explicit account/provider use, synthetic data and injected audio, no real mic:
|
|
74
|
+
DRAKEN_TEST_STATIC_DIR=/path/to/staged/frontend \
|
|
75
|
+
npm run test:live:real -- --allow-provider
|
|
76
|
+
|
|
77
|
+
# One spoken question → acknowledgement → delayed synthetic lookup → spoken answer:
|
|
78
|
+
DRAKEN_TEST_STATIC_DIR=/path/to/staged/frontend \
|
|
79
|
+
node tests/voice-continuation-real.cjs --allow-provider
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The main-controller tests cover permission denial, late microphone permission, speaker/headphone gating, mute/replacement, immediate stop, same-provider-turn resumption, late SDP, page exit and no capture after reload. The optional real-helper checks verify both denied inference and tool-looking text remaining plain text, with zero executed tools. Real-provider UI qualification exercises spoken input/interruption, typing, dragon motion, mute/unmute, stop/resume and cleanup on one connection. Use synthetic records for regression tests; any additional private-history qualification needs separate approval. Never use production history as a test scratchpad. Qualification must wait for a substantive answer, not mistake an acknowledgment plus an audio pause for completion. First audio may be an acknowledgment; it is not a measure of time to a fully grounded answer.
|
|
83
|
+
|
|
84
|
+
No unlimited usage, exact quota pool, idle accounting, impact on coding allowances, provider-retention policy, ChatGPT latency parity or future API stability is promised. Audio and bounded context go to OpenAI. No raw audio is persisted locally. Linux/Chromium is the tested combination; physical devices and other browsers are not claimed verified.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
/** No proxying: this endpoint can emit only one assistant TEXT message.
|
|
5
|
+
* Model tool schemas, instructions and arbitrary request options are never
|
|
6
|
+
* forwarded to a provider. The host callback is a separately bounded read-only
|
|
7
|
+
* service; its return value cannot become a function call in the native agent.
|
|
8
|
+
*/
|
|
9
|
+
export async function createReadOnlyBackend({ answer, authorize = () => false, onBlocked = () => {}, maxRequests = 8 } = {}) {
|
|
10
|
+
if (!Number.isSafeInteger(maxRequests) || maxRequests < 1 || maxRequests > 120) {
|
|
11
|
+
throw new Error('Native backing request limit must be an integer between 1 and 120.');
|
|
12
|
+
}
|
|
13
|
+
const active = new Set(); let used = 0, stopped = false;
|
|
14
|
+
const server = http.createServer(async (req, res) => {
|
|
15
|
+
let controller, timer;
|
|
16
|
+
let removeAbort = () => {};
|
|
17
|
+
const stopBody = () => { if (!req.complete) req.destroy(); };
|
|
18
|
+
try {
|
|
19
|
+
// Admission is inside the error boundary too: URL/auth failures must not
|
|
20
|
+
// escape this async listener and terminate the hosting Draken process.
|
|
21
|
+
if (answer && !authorize(req.headers)) { req.resume(); res.writeHead(403); res.end('{}'); return; }
|
|
22
|
+
let pathname;
|
|
23
|
+
try { pathname = new URL(req.url, 'http://localhost').pathname; }
|
|
24
|
+
catch { req.resume(); res.writeHead(400, { 'Content-Type': 'application/json', Connection: 'close' }); res.end('{}'); return; }
|
|
25
|
+
// Native ChatGPT auth always probes catalog metadata. Deny discovery without
|
|
26
|
+
// mistaking it for execution; the helper has built-in metadata.
|
|
27
|
+
if (req.method === 'GET' && pathname === '/v1/models') { req.resume(); res.writeHead(403); res.end('{}'); return; }
|
|
28
|
+
if (stopped || !answer || req.method !== 'POST' || pathname !== '/v1/responses' || active.size || ++used > maxRequests) {
|
|
29
|
+
req.resume(); res.writeHead(403, { 'Content-Type': 'application/json', Connection: 'close' });
|
|
30
|
+
res.end(JSON.stringify({ error: { code: 'draken_voice_read_only', message: 'No backing execution allowed.' } })); await onBlocked(); return;
|
|
31
|
+
}
|
|
32
|
+
controller = new AbortController(); active.add(controller);
|
|
33
|
+
timer = setTimeout(() => controller.abort(), 45_000);
|
|
34
|
+
controller.signal.addEventListener('abort', stopBody, { once: true });
|
|
35
|
+
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
|
|
36
|
+
if (req.headers['content-encoding']) throw new Error('Encoded backend request is not supported.');
|
|
37
|
+
req.setEncoding('utf8'); let body = '';
|
|
38
|
+
for await (const chunk of req) { body += chunk; if (body.length > 300_000 || controller.signal.aborted) throw new Error('Backend request exceeds its limit.'); }
|
|
39
|
+
const value = JSON.parse(body);
|
|
40
|
+
if (!Array.isArray(value.input) || value.input.length > 256) throw new Error('Invalid backend input.');
|
|
41
|
+
const text = await Promise.race([
|
|
42
|
+
Promise.resolve().then(() => answer(value.input, controller.signal)),
|
|
43
|
+
new Promise((_, reject) => {
|
|
44
|
+
const abort = () => reject(new Error('Backend request cancelled.'));
|
|
45
|
+
removeAbort = () => controller.signal.removeEventListener('abort', abort);
|
|
46
|
+
controller.signal.addEventListener('abort', abort, { once: true });
|
|
47
|
+
if (controller.signal.aborted) abort();
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
if (controller.signal.aborted || stopped) throw new Error('Backend request cancelled.');
|
|
51
|
+
if (typeof text !== 'string' || !text.trim() || text.length > 8000) throw new Error('Invalid backend text.');
|
|
52
|
+
const id = `resp_${randomUUID()}`, itemId = `msg_${randomUUID()}`;
|
|
53
|
+
const part = { type: 'output_text', text, annotations: [] };
|
|
54
|
+
const item = { id: itemId, type: 'message', role: 'assistant', phase: 'final_answer', status: 'completed', content: [part] };
|
|
55
|
+
let sequence = 0;
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'close' });
|
|
57
|
+
const event = (type, fields) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence++, ...fields })}\n\n`);
|
|
58
|
+
event('response.created', { response: { id, object: 'response', status: 'in_progress', output: [] } });
|
|
59
|
+
event('response.output_item.added', { output_index: 0, item: { ...item, status: 'in_progress', content: [] } });
|
|
60
|
+
event('response.content_part.added', { item_id: itemId, output_index: 0, content_index: 0, part: { ...part, text: '' } });
|
|
61
|
+
event('response.output_text.delta', { item_id: itemId, output_index: 0, content_index: 0, delta: text });
|
|
62
|
+
event('response.output_text.done', { item_id: itemId, output_index: 0, content_index: 0, text });
|
|
63
|
+
event('response.content_part.done', { item_id: itemId, output_index: 0, content_index: 0, part });
|
|
64
|
+
event('response.output_item.done', { output_index: 0, item });
|
|
65
|
+
event('response.completed', { response: { id, object: 'response', status: 'completed', output: [item] } });
|
|
66
|
+
res.end();
|
|
67
|
+
} catch {
|
|
68
|
+
req.resume();
|
|
69
|
+
if (res.destroyed || res.writableEnded) return;
|
|
70
|
+
if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'application/json', Connection: 'close' });
|
|
71
|
+
res.end(JSON.stringify({ error: { code: 'draken_voice_backend_failed', message: 'No reliable read-only answer was available.' } }));
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer); removeAbort();
|
|
74
|
+
if (controller) { controller.signal.removeEventListener('abort', stopBody); active.delete(controller); }
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
server.requestTimeout = 50_000; server.headersTimeout = 5000;
|
|
78
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
79
|
+
return { url: `http://127.0.0.1:${server.address().port}/v1`,
|
|
80
|
+
async close() { stopped = true; for (const controller of active) controller.abort(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } };
|
|
81
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { NativeRpc } from './rpc.mjs';
|
|
8
|
+
import { createReadOnlyBackend } from './backend.mjs';
|
|
9
|
+
|
|
10
|
+
export const CODEX_VERSION = '0.166.0';
|
|
11
|
+
export const realtimeRoot = path.join(os.homedir(), '.draken', 'realtime');
|
|
12
|
+
export function codexExecutable(root, platform = process.platform, arch = process.arch) {
|
|
13
|
+
if (platform !== 'win32') return path.join(root, 'runtime', 'node_modules', '.bin', 'codex');
|
|
14
|
+
const target = { x64: 'x86_64', arm64: 'aarch64' }[arch];
|
|
15
|
+
if (!target) throw new Error('Native voice requires Windows x64 or arm64.');
|
|
16
|
+
// The pinned official optional package contains the actual executable. Own
|
|
17
|
+
// that process directly: killing a Node/.cmd wrapper on Windows can orphan it.
|
|
18
|
+
return path.join(root, 'runtime', 'node_modules', '@openai', `codex-win32-${arch}`, 'vendor', `${target}-pc-windows-msvc`, 'bin', 'codex.exe');
|
|
19
|
+
}
|
|
20
|
+
export const defaultBinary = codexExecutable(realtimeRoot);
|
|
21
|
+
export const defaultHome = path.join(realtimeRoot, 'account');
|
|
22
|
+
const exec = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
// Positive allowlist: no API keys, pi session ownership, cloud credentials,
|
|
25
|
+
// proxy overrides, provider URLs, NODE_OPTIONS, or user agent configuration.
|
|
26
|
+
export function helperEnvironment(home, source = process.env) {
|
|
27
|
+
const env = { CODEX_HOME: home, HOME: home, USERPROFILE: home,
|
|
28
|
+
APPDATA: path.join(home, 'AppData', 'Roaming'), LOCALAPPDATA: path.join(home, 'AppData', 'Local'), RUST_LOG: 'off' };
|
|
29
|
+
for (const key of ['PATH', 'LANG', 'LC_ALL', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP']) if (source[key]) env[key] = source[key];
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
export function hasNativeLogin(home) {
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(fs.readFileSync(path.join(home, 'auth.json'), 'utf8'));
|
|
35
|
+
return !value.OPENAI_API_KEY && (!value.auth_mode || value.auth_mode === 'chatgpt') &&
|
|
36
|
+
['access_token', 'refresh_token', 'id_token'].every(key => typeof value.tokens?.[key] === 'string' && value.tokens[key].length > 0);
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
export async function nativeStatus({ binary = defaultBinary, home = defaultHome } = {}) {
|
|
40
|
+
let installed = false;
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await exec(binary, ['--version'], { env: helperEnvironment(home), timeout: 5000, maxBuffer: 4096 });
|
|
43
|
+
installed = stdout.trim() === `codex-cli ${CODEX_VERSION}`;
|
|
44
|
+
} catch { /* Report a structural status only; never leak subprocess output. */ }
|
|
45
|
+
return { installed, expectedVersion: CODEX_VERSION, nativeLoginPresent: hasNativeLogin(home),
|
|
46
|
+
readyForProbe: installed && hasNativeLogin(home), audioVerified: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The backing endpoint is owned by Draken, never a coding-model provider.
|
|
50
|
+
* By default it denies every inference; an explicit read-only host callback may
|
|
51
|
+
* return text through backend.mjs. It cannot return executable tool messages.
|
|
52
|
+
* Feature flags and sandboxing are additional precautions, not that boundary.
|
|
53
|
+
*/
|
|
54
|
+
export function helperArguments(denyUrl) {
|
|
55
|
+
const config = {
|
|
56
|
+
forced_login_method: 'chatgpt', cli_auth_credentials_store: 'file',
|
|
57
|
+
check_for_update_on_startup: false, approval_policy: 'on-request', sandbox_mode: 'read-only',
|
|
58
|
+
allow_login_shell: false, project_doc_max_bytes: 0, web_search: 'disabled',
|
|
59
|
+
model_provider: 'draken_readonly', model: 'gpt-5.4',
|
|
60
|
+
'model_providers.draken_readonly.name': 'Draken voice: no backend execution',
|
|
61
|
+
'model_providers.draken_readonly.base_url': denyUrl,
|
|
62
|
+
'model_providers.draken_readonly.wire_api': 'responses',
|
|
63
|
+
'model_providers.draken_readonly.requires_openai_auth': true,
|
|
64
|
+
'model_providers.draken_readonly.request_max_retries': 0,
|
|
65
|
+
'model_providers.draken_readonly.stream_max_retries': 0,
|
|
66
|
+
'features.realtime_conversation': true, 'features.shell_tool': false,
|
|
67
|
+
'features.unified_exec': false, 'features.code_mode': false, 'features.js_repl': false,
|
|
68
|
+
'features.apply_patch_freeform': false, 'features.apps': false, 'features.plugins': false,
|
|
69
|
+
'features.hooks': false, 'features.codex_hooks': false, 'features.plugin_hooks': false,
|
|
70
|
+
'features.multi_agent': false, 'features.multi_agent_v2': false, 'features.collab': false,
|
|
71
|
+
'features.computer_use': false, 'features.browser_use': false, 'features.memories': false,
|
|
72
|
+
'features.remote_models': false, 'features.api_key_model_discovery': false,
|
|
73
|
+
'features.enable_request_compression': false,
|
|
74
|
+
'features.shell_snapshot': false, 'features.shell_snapshot_v2': false,
|
|
75
|
+
'features.skip_host_skill_discovery': true, 'features.plugin_discovery_prefetch': false,
|
|
76
|
+
'features.skill_mcp_dependency_install': false, 'features.image_generation': false,
|
|
77
|
+
'features.worktrees': false, 'features.goals': false, 'features.remote_control': false,
|
|
78
|
+
'history.persistence': 'none', 'analytics.enabled': false, 'feedback.enabled': false,
|
|
79
|
+
// Call creation uses native Codex auth, never pi's direct-token credential.
|
|
80
|
+
// The WebRTC control socket is a DIFFERENT endpoint: leave it at the
|
|
81
|
+
// official helper's default. Pointing it at this call-creation origin fails.
|
|
82
|
+
experimental_realtime_webrtc_call_base_url: 'https://chatgpt.com/backend-api/codex',
|
|
83
|
+
};
|
|
84
|
+
return [...Object.entries(config).flatMap(([key, value]) => ['-c', `${key}=${JSON.stringify(value)}`]), 'app-server', '--stdio'];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class NativeVoice {
|
|
88
|
+
#rpc;
|
|
89
|
+
#deny;
|
|
90
|
+
#thread;
|
|
91
|
+
#closed = false;
|
|
92
|
+
#opened = false;
|
|
93
|
+
#closing;
|
|
94
|
+
#listeners = new Set();
|
|
95
|
+
#home;
|
|
96
|
+
#binary;
|
|
97
|
+
#answer;
|
|
98
|
+
#cleanup;
|
|
99
|
+
#unsubscribe;
|
|
100
|
+
constructor({ binary = defaultBinary, home = defaultHome, answer } = {}) { this.#binary = binary; this.#home = home; this.#answer = answer; }
|
|
101
|
+
subscribe(listener) { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }
|
|
102
|
+
#emit(event) { for (const listener of this.#listeners) { try { listener(event); } catch { void this.close(); } } }
|
|
103
|
+
async open(sdp, { signal, prompt, initialItems, onReady } = {}) {
|
|
104
|
+
if (this.#closed || this.#opened) throw new Error('A native voice instance can open only once.');
|
|
105
|
+
this.#opened = true;
|
|
106
|
+
if (typeof sdp !== 'string' || sdp.length > 64_000 || !sdp.startsWith('v=0') || (sdp.match(/^m=audio /gm) || []).length !== 1 || /^m=video /m.test(sdp)) throw new Error('One audio track and a bounded SDP offer are required.');
|
|
107
|
+
const status = await nativeStatus({ binary: this.#binary, home: this.#home });
|
|
108
|
+
if (!status.installed) throw new Error(`Install the pinned Codex ${CODEX_VERSION} helper first.`);
|
|
109
|
+
if (!status.nativeLoginPresent) throw new Error('A separate native ChatGPT sign-in is required. Run npm run voice:realtime:login. The pi login is not copied or converted.');
|
|
110
|
+
if (fs.existsSync(path.join(this.#home, 'config.toml'))) throw new Error('Use the dedicated voice account directory without a custom Codex config.');
|
|
111
|
+
if (signal?.aborted || this.#closed) throw new Error('Native voice setup cancelled.');
|
|
112
|
+
const abort = () => { void this.close(); };
|
|
113
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
114
|
+
this.#cleanup = () => signal?.removeEventListener('abort', abort);
|
|
115
|
+
try {
|
|
116
|
+
this.#deny = await createReadOnlyBackend({ answer: this.#answer, maxRequests: 120,
|
|
117
|
+
authorize: headers => {
|
|
118
|
+
// Native auth already accompanies the helper's backing requests. Match
|
|
119
|
+
// it locally without another secret in argv or a header that could be
|
|
120
|
+
// forwarded to OpenAI. Refresh remains owned by the official helper.
|
|
121
|
+
try {
|
|
122
|
+
const value = JSON.parse(fs.readFileSync(path.join(this.#home, 'auth.json'), 'utf8'));
|
|
123
|
+
if (value.OPENAI_API_KEY || (value.auth_mode && value.auth_mode !== 'chatgpt') || !['access_token', 'refresh_token', 'id_token'].every(key => typeof value.tokens?.[key] === 'string' && value.tokens[key])) return false;
|
|
124
|
+
const expected = Buffer.from(`Bearer ${value.tokens.access_token}`), actual = Buffer.from(headers.authorization || '');
|
|
125
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
126
|
+
} catch { return false; }
|
|
127
|
+
}, onBlocked: () => { this.#emit({ method: 'draken/blocked', params: {} }); void this.close(); } });
|
|
128
|
+
if (this.#closed || signal?.aborted) { await this.#deny.close(); throw new Error('Native voice setup cancelled.'); }
|
|
129
|
+
const cwd = path.join(this.#home, 'empty-workspace'); fs.mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
|
130
|
+
this.#rpc = new NativeRpc(this.#binary, helperArguments(this.#deny.url), { cwd, env: helperEnvironment(this.#home) });
|
|
131
|
+
this.#unsubscribe = this.#rpc.subscribe(event => {
|
|
132
|
+
if (event.method === 'turn/started' && !this.#answer) { this.#emit({ method: 'draken/blocked', params: {} }); void this.close(); return; }
|
|
133
|
+
if (event.method === 'draken/closed') { this.#emit(event); void this.close(); return; }
|
|
134
|
+
if (event.method.startsWith('thread/realtime/') && event.params?.threadId === this.#thread) {
|
|
135
|
+
// SDP never enters the general event stream or a saved report.
|
|
136
|
+
if (event.method !== 'thread/realtime/sdp') this.#emit(event);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
await this.#rpc.request('initialize', { clientInfo: { name: 'draken', title: 'Draken read-only voice', version: '0.1.6' }, capabilities: { experimentalApi: true } }, { signal });
|
|
140
|
+
this.#rpc.notify('initialized');
|
|
141
|
+
const account = await this.#rpc.request('account/read', { refreshToken: false }, { signal });
|
|
142
|
+
if (account.account?.type !== 'chatgpt') throw new Error('Native ChatGPT authentication is required; API-key fallback is disabled.');
|
|
143
|
+
if (account.workspaceRouting?.backendOrigin && account.workspaceRouting.backendOrigin !== 'https://chatgpt.com') throw new Error('This prototype has not qualified the selected workspace region. No audio was sent.');
|
|
144
|
+
const result = await this.#rpc.request('thread/start', { ephemeral: true, cwd, environments: [], dynamicTools: [],
|
|
145
|
+
baseInstructions: 'This is a read-only voice transport. Never execute commands, edit files, use tools, or start background work.',
|
|
146
|
+
model: 'gpt-5.4', modelProvider: 'draken_readonly', approvalPolicy: 'on-request', sandbox: 'read-only' }, { signal });
|
|
147
|
+
this.#thread = result.thread?.id;
|
|
148
|
+
if (typeof this.#thread !== 'string' || !this.#thread || result.modelProvider !== 'draken_readonly') throw new Error('Native helper did not preserve the no-execution model boundary.');
|
|
149
|
+
onReady?.();
|
|
150
|
+
// Subscribe before issuing start: the SDP notification may precede its RPC response.
|
|
151
|
+
const answer = new Promise((resolve, reject) => {
|
|
152
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error('Native voice SDP timed out.')); }, 45_000);
|
|
153
|
+
const off = this.#rpc.subscribe(event => {
|
|
154
|
+
if (event.method === 'draken/closed') { cleanup(); reject(new Error('Native voice connection closed during setup.')); }
|
|
155
|
+
if (event.params?.threadId !== this.#thread) return;
|
|
156
|
+
if (event.method === 'thread/realtime/error' || event.method === 'thread/realtime/closed') { cleanup(); reject(new Error('Native voice access failed. No fallback provider was used.')); }
|
|
157
|
+
if (event.method === 'thread/realtime/sdp') {
|
|
158
|
+
cleanup(); const value = event.params.sdp;
|
|
159
|
+
if (typeof value !== 'string' || value.length > 64_000 || !value.startsWith('v=0')) reject(new Error('Invalid native voice SDP answer.'));
|
|
160
|
+
else resolve(value);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
const cleanup = () => { clearTimeout(timer); off(); };
|
|
164
|
+
});
|
|
165
|
+
const start = this.#rpc.request('thread/realtime/start', { threadId: this.#thread, outputModality: 'audio', version: 'v3',
|
|
166
|
+
model: 'gpt-live-1-codex', voice: 'cove', includeStartupContext: false, clientManagedHandoffs: !this.#answer,
|
|
167
|
+
flushTranscriptTailOnSessionEnd: false, delegationAckFiller: false,
|
|
168
|
+
...(initialItems ? { initialItems } : {}),
|
|
169
|
+
prompt: prompt || 'You are Draken, an AI voice test. Answer briefly using only supplied facts. You cannot execute work or delegate tasks. The export tests passed; deployment has not been checked. Never claim deployment was verified.',
|
|
170
|
+
transport: { type: 'webrtc', sdp } }, { signal, timeoutMs: 45_000 });
|
|
171
|
+
const [remoteSdp] = await Promise.all([answer, start]);
|
|
172
|
+
if (this.#closed || signal?.aborted) throw new Error('Native voice setup cancelled.');
|
|
173
|
+
return remoteSdp;
|
|
174
|
+
} catch (error) { await this.close(); throw error; }
|
|
175
|
+
}
|
|
176
|
+
async text(text) {
|
|
177
|
+
if (!this.#thread || this.#closed || typeof text !== 'string' || !text.trim() || text.length > 2000) throw new Error('Cannot send this native voice question.');
|
|
178
|
+
await this.#rpc.request('thread/realtime/appendText', { threadId: this.#thread, role: 'user', text });
|
|
179
|
+
}
|
|
180
|
+
async context(text) {
|
|
181
|
+
if (!this.#thread || this.#closed || typeof text !== 'string' || !text.trim() || Buffer.byteLength(text, 'utf8') > 8192) throw new Error('Cannot supply this native voice context.');
|
|
182
|
+
// In v3 this becomes session context, without the user-text prefix.
|
|
183
|
+
await this.#rpc.request('thread/realtime/appendText', { threadId: this.#thread, role: 'developer', text });
|
|
184
|
+
}
|
|
185
|
+
async continueAnswer(question) {
|
|
186
|
+
if (!this.#thread || this.#closed || typeof question !== 'string' || !question.trim() || question.length > 2000) throw new Error('Cannot continue this native voice answer.');
|
|
187
|
+
// Official V3 speakable-context operation, not a fabricated user message.
|
|
188
|
+
// Ordinary developer context is only background data and may not resume speech.
|
|
189
|
+
const text = `DRAKEN_LOOKUP_READY — application control, not words to read aloud or a new user utterance. The read-only lookup is complete for this pending question: ${JSON.stringify([...question].slice(0, 500).join(''))} (bounded reminder; the original question is already in this call). Finish answering it now from the supplied context, briefly, with its uncertainty. If lookup failed, explain that instead. Do not merely acknowledge or promise to check; do not repeat an answer already completed. Do not answer an older question if the user has since interrupted or moved on.`;
|
|
190
|
+
await this.#rpc.request('thread/realtime/appendSpeech', { threadId: this.#thread, text });
|
|
191
|
+
}
|
|
192
|
+
close() {
|
|
193
|
+
if (this.#closing) return this.#closing;
|
|
194
|
+
this.#closed = true;
|
|
195
|
+
this.#closing = Promise.resolve().then(() => this.#closeResources());
|
|
196
|
+
return this.#closing;
|
|
197
|
+
}
|
|
198
|
+
async #closeResources() {
|
|
199
|
+
this.#cleanup?.(); this.#unsubscribe?.();
|
|
200
|
+
if (this.#thread && this.#rpc && !this.#rpc.closed) {
|
|
201
|
+
try { await this.#rpc.request('thread/realtime/stop', { threadId: this.#thread }, { timeoutMs: 1500 }); } catch { /* Terminate below, even if stop was rejected. */ }
|
|
202
|
+
}
|
|
203
|
+
this.#rpc?.close();
|
|
204
|
+
const exited = !this.#rpc || await this.#rpc.waitForExit();
|
|
205
|
+
await this.#deny?.close();
|
|
206
|
+
this.#emit({ method: 'draken/closed', params: { reason: exited ? 'Native voice connection closed.' : 'Native helper exit could not be confirmed.', helperExited: exited } });
|
|
207
|
+
this.#listeners.clear();
|
|
208
|
+
return { helperExited: exited };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class VoicePeer {
|
|
2
|
+
constructor(options?: { onClose?: (reason: string) => void; onEvent?: (event: Record<string, any>) => void });
|
|
3
|
+
readonly closed: boolean;
|
|
4
|
+
readonly inputActive: boolean;
|
|
5
|
+
readonly clock: number;
|
|
6
|
+
readonly audioState: AudioContextState;
|
|
7
|
+
readonly level: number;
|
|
8
|
+
open(input: MediaStream, negotiate: (offer: string, signal: AbortSignal) => Promise<string>): Promise<void>;
|
|
9
|
+
muteOutput(muted: boolean): void;
|
|
10
|
+
gateInput(enabled: boolean): void;
|
|
11
|
+
stopInput(): void;
|
|
12
|
+
replaceInput(stream: MediaStream): Promise<void>;
|
|
13
|
+
resume(): Promise<void>;
|
|
14
|
+
close(reason?: string): void;
|
|
15
|
+
}
|