dsh-realtime-audio-ws 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/strip.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The strip: the panel this plugin puts in the host's own page.
3
+ *
4
+ * S2 story 3. Until it existed the plugin was driven from a `globalThis` key in a devtools console, and the
5
+ * roadmap's judgement on that was blunt — *a plugin driven from a console global will not be installed by
6
+ * anyone*. What changes is not decoration: the on-switch moves into the page, and `sessionId`, which cost
7
+ * two restarts and a false lead to change, becomes a picker.
8
+ *
9
+ * ## Where the behaviour lives, and why not here
10
+ *
11
+ * This module mints **rows** for `webserver/index-inject` — the door the page already receives its settings
12
+ * through. Two of the six row kinds carry UI: `html` for raw markup, `script` for an inline classic script.
13
+ * So the markup is here and the bootstrap is here, and **the panel's logic is not**: everything that
14
+ * decides what to render and what a button does lives in the client bundle
15
+ * (`src/client/index.ts`), where it is covered by tests. A panel implemented inside an injected string is a
16
+ * feature nothing can execute in CI, which is the same defect class as a build artefact no test reads.
17
+ *
18
+ * The bootstrap is therefore three statements, and it is still *executed* by a test — as a function with a
19
+ * fake scope — so the injected code is not exempt from the gate just because it is shipped as text.
20
+ *
21
+ * ## Two ways in, deliberately
22
+ *
23
+ * The bundle mounts the panel when it applies, and the injected script mounts it again in case it lands
24
+ * first. Both are idempotent, because the order between the module table and the body rows is not something
25
+ * this package controls, and a panel that appears only when two independent orderings agree is a panel that
26
+ * appears only on the author's machine.
27
+ *
28
+ * @module dsh-realtime-audio-ws/strip
29
+ */
30
+ import type { InjectedRow } from './injection.ts';
31
+ /** The element the panel mounts on. Duplicated in the client half, which cannot import it. */
32
+ export declare const STRIP_ELEMENT_ID = "dsh-realtime-strip";
33
+ /**
34
+ * The panel's container, as markup.
35
+ *
36
+ * Fixed to the bottom right, inheriting the host's font and colour rather than inventing a second visual
37
+ * language beside it — but **opaque**, and that is a correction rather than a taste. This panel sits over
38
+ * the host's own text, so a translucent wash lets the page show through and the two become unreadable
39
+ * together: two texts superimposed, neither of them legible. That was reported from a screenshot of the
40
+ * running app, which is the only way it was ever going to be found — nothing in a DOM test can see what is
41
+ * *behind* the panel.
42
+ *
43
+ * `Canvas` is the theme's own surface colour, so the panel follows light or dark mode without naming a
44
+ * palette of its own, and the 6% tint of the inherited colour keeps the surface from reading as a hole
45
+ * punched in the page. The soft shadow says it sits above the page — not a border, which would be visual
46
+ * noise around a strip whose job is to be glanced at.
47
+ * @returns one `html` row's payload.
48
+ */
49
+ export declare function stripMarkup(): string;
50
+ /**
51
+ * The bootstrap the page runs after the markup.
52
+ *
53
+ * It calls the bundle's own `mount`, and does nothing when the bundle has not been materialised yet — in
54
+ * which case the bundle mounts the panel itself when it applies, or this is called again on
55
+ * `DOMContentLoaded`. Nothing here decides anything about the panel; it is a doorbell.
56
+ * @returns one `script` row's payload.
57
+ */
58
+ export declare function stripBootstrap(): string;
59
+ /**
60
+ * The two rows this plugin contributes for its panel.
61
+ *
62
+ * `body` placement for both: the strip belongs to the page's chrome rather than to its head, and a `body`
63
+ * row is rendered after the module queue's own `head` rows, which is the ordering the bundle's bootstrap
64
+ * expects.
65
+ *
66
+ * Neither payload may contain a closing script or style tag — the table's own contract, because the row is
67
+ * spliced into the document text — and a test asserts that for both, alongside executing the bootstrap.
68
+ * @returns the rows, in the order they must execute.
69
+ */
70
+ export declare function stripRows(): InjectedRow[];
71
+ //# sourceMappingURL=strip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip.d.ts","sourceRoot":"","sources":["../src/strip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAEjD,8FAA8F;AAC9F,eAAO,MAAM,gBAAgB,uBAAuB,CAAA;AAEpD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAUpC;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAQvC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,IAAI,WAAW,EAAE,CAKzC"}
package/lib/strip.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The strip: the panel this plugin puts in the host's own page.
3
+ *
4
+ * S2 story 3. Until it existed the plugin was driven from a `globalThis` key in a devtools console, and the
5
+ * roadmap's judgement on that was blunt — *a plugin driven from a console global will not be installed by
6
+ * anyone*. What changes is not decoration: the on-switch moves into the page, and `sessionId`, which cost
7
+ * two restarts and a false lead to change, becomes a picker.
8
+ *
9
+ * ## Where the behaviour lives, and why not here
10
+ *
11
+ * This module mints **rows** for `webserver/index-inject` — the door the page already receives its settings
12
+ * through. Two of the six row kinds carry UI: `html` for raw markup, `script` for an inline classic script.
13
+ * So the markup is here and the bootstrap is here, and **the panel's logic is not**: everything that
14
+ * decides what to render and what a button does lives in the client bundle
15
+ * (`src/client/index.ts`), where it is covered by tests. A panel implemented inside an injected string is a
16
+ * feature nothing can execute in CI, which is the same defect class as a build artefact no test reads.
17
+ *
18
+ * The bootstrap is therefore three statements, and it is still *executed* by a test — as a function with a
19
+ * fake scope — so the injected code is not exempt from the gate just because it is shipped as text.
20
+ *
21
+ * ## Two ways in, deliberately
22
+ *
23
+ * The bundle mounts the panel when it applies, and the injected script mounts it again in case it lands
24
+ * first. Both are idempotent, because the order between the module table and the body rows is not something
25
+ * this package controls, and a panel that appears only when two independent orderings agree is a panel that
26
+ * appears only on the author's machine.
27
+ *
28
+ * @module dsh-realtime-audio-ws/strip
29
+ */
30
+ /** The element the panel mounts on. Duplicated in the client half, which cannot import it. */
31
+ export const STRIP_ELEMENT_ID = 'dsh-realtime-strip';
32
+ /**
33
+ * The panel's container, as markup.
34
+ *
35
+ * Fixed to the bottom right, inheriting the host's font and colour rather than inventing a second visual
36
+ * language beside it — but **opaque**, and that is a correction rather than a taste. This panel sits over
37
+ * the host's own text, so a translucent wash lets the page show through and the two become unreadable
38
+ * together: two texts superimposed, neither of them legible. That was reported from a screenshot of the
39
+ * running app, which is the only way it was ever going to be found — nothing in a DOM test can see what is
40
+ * *behind* the panel.
41
+ *
42
+ * `Canvas` is the theme's own surface colour, so the panel follows light or dark mode without naming a
43
+ * palette of its own, and the 6% tint of the inherited colour keeps the surface from reading as a hole
44
+ * punched in the page. The soft shadow says it sits above the page — not a border, which would be visual
45
+ * noise around a strip whose job is to be glanced at.
46
+ * @returns one `html` row's payload.
47
+ */
48
+ export function stripMarkup() {
49
+ return [
50
+ `<div id="${STRIP_ELEMENT_ID}"`,
51
+ ' style="position:fixed;right:12px;bottom:12px;z-index:40;',
52
+ 'max-width:26rem;max-height:60vh;overflow:auto;padding:10px 12px;',
53
+ 'font:inherit;color:inherit;line-height:1.4;',
54
+ 'background:color-mix(in srgb, Canvas 94%, currentColor);',
55
+ 'box-shadow:0 4px 16px rgb(0 0 0 / 0.18);border-radius:10px">',
56
+ '</div>',
57
+ ].join('');
58
+ }
59
+ /**
60
+ * The bootstrap the page runs after the markup.
61
+ *
62
+ * It calls the bundle's own `mount`, and does nothing when the bundle has not been materialised yet — in
63
+ * which case the bundle mounts the panel itself when it applies, or this is called again on
64
+ * `DOMContentLoaded`. Nothing here decides anything about the panel; it is a doorbell.
65
+ * @returns one `script` row's payload.
66
+ */
67
+ export function stripBootstrap() {
68
+ return [
69
+ ';(function () {',
70
+ " function mount() { var G = globalThis.__dshRealtimeAudio; if (G && typeof G.mount === 'function') G.mount() }",
71
+ ' mount()',
72
+ " if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount)",
73
+ '})()',
74
+ ].join('\n');
75
+ }
76
+ /**
77
+ * The two rows this plugin contributes for its panel.
78
+ *
79
+ * `body` placement for both: the strip belongs to the page's chrome rather than to its head, and a `body`
80
+ * row is rendered after the module queue's own `head` rows, which is the ordering the bundle's bootstrap
81
+ * expects.
82
+ *
83
+ * Neither payload may contain a closing script or style tag — the table's own contract, because the row is
84
+ * spliced into the document text — and a test asserts that for both, alongside executing the bootstrap.
85
+ * @returns the rows, in the order they must execute.
86
+ */
87
+ export function stripRows() {
88
+ return [
89
+ { kind: 'html', placement: 'body', html: stripMarkup() },
90
+ { kind: 'script', placement: 'body', text: stripBootstrap() },
91
+ ];
92
+ }
93
+ //# sourceMappingURL=strip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip.js","sourceRoot":"","sources":["../src/strip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,8FAA8F;AAC9F,MAAM,CAAC,MAAM,gBAAgB,GAAG,oBAAoB,CAAA;AAEpD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,WAAW;IACzB,OAAO;QACL,YAAY,gBAAgB,GAAG;QAC/B,2DAA2D;QAC3D,kEAAkE;QAClE,6CAA6C;QAC7C,0DAA0D;QAC1D,8DAA8D;QAC9D,QAAQ;KACT,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACZ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO;QACL,iBAAiB;QACjB,iHAAiH;QACjH,WAAW;QACX,kIAAkI;QAClI,MAAM;KACP,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACd,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,SAAS;IACvB,OAAO;QACL,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE;QACxD,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,EAAE;KAC9D,CAAA;AACH,CAAC"}
package/lib/types.d.ts CHANGED
@@ -61,6 +61,14 @@ export interface RealtimeAudioWsConfig {
61
61
  * more to a stranger than an empty socket.
62
62
  */
63
63
  readonly diagnosticsPath: string;
64
+ /**
65
+ * File the journal is appended to, one JSON object per line. Empty means no file.
66
+ *
67
+ * Off by default, and named by the deployment that wants a recording: the journal is held in memory and
68
+ * served over a route that wants a capability token, so a deployment that needs the record to outlive the
69
+ * process — or to be handed to somebody else — says so here.
70
+ */
71
+ readonly journalPath: string;
64
72
  }
65
73
  /**
66
74
  * The slice of a WebSocket this package uses, described structurally.
@@ -74,7 +82,7 @@ export interface RealtimeAudioWsConfig {
74
82
  * `Uint8Array`.
75
83
  */
76
84
  export interface AudioSocket {
77
- send(data: Uint8Array): void;
85
+ send(data: Uint8Array | string): void;
78
86
  close(code?: number, reason?: string): void;
79
87
  terminate(): void;
80
88
  on(event: 'message', listener: (data: unknown, isBinary: boolean) => void): unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAAA;IACrC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime-audio-ws",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,7 +52,8 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "ws": "^8.22.0",
55
- "dsh-realtime-agent": "^0.2.6"
55
+ "dsh-realtime-agent": "^0.2.8",
56
+ "dsh-realtime": "^0.2.5"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "@deepseek-ai/cordis": "^4.0.3",
package/src/bridge.ts CHANGED
@@ -14,6 +14,20 @@ export interface AudioSocketBridgeDeps {
14
14
  readonly subscribeAudio: (listener: (pcm16: Uint8Array) => void) => () => void
15
15
  /** Longest inbound frame accepted, in bytes. */
16
16
  readonly maxFrameBytes: number
17
+ /**
18
+ * Handle one **control frame** — a text frame on this socket.
19
+ *
20
+ * Until S2 story 2 a text frame was not part of this contract, and the comment that said so was right
21
+ * about the wire as it was: the socket carried PCM16 in both directions and nothing else. It now
22
+ * carries a channel too, because it is the only duplex connection the client already holds, and one
23
+ * text frame in / one text frame out is a smaller contract than a second route with its own
24
+ * authentication would have been.
25
+ *
26
+ * `reply` writes exactly one text frame back, and silently drops it once the socket is gone: a control
27
+ * handler is asynchronous, and a reply to a closed socket would throw from inside a promise nobody
28
+ * awaits.
29
+ */
30
+ readonly onControl: (frame: string, reply: (text: string) => void) => void
17
31
  /** Called exactly once when the bridge stops, however it stops. */
18
32
  readonly onDetach: () => void
19
33
  }
@@ -35,6 +49,22 @@ export function toBytes(data: unknown): Uint8Array {
35
49
  return new Uint8Array(Buffer.from(data as ArrayBufferLike))
36
50
  }
37
51
 
52
+ /**
53
+ * Normalise a `ws` message payload to text.
54
+ *
55
+ * The same three shapes {@link toBytes} handles, decoded as UTF-8 — a control frame arrives as a Buffer
56
+ * whichever way it was framed, and a fragmented one as an array of them.
57
+ *
58
+ * @param data - the payload as `ws` delivered it.
59
+ * @returns the payload as text.
60
+ */
61
+ export function toText(data: unknown): string {
62
+ if (typeof data === 'string') return data
63
+ if (Array.isArray(data)) return Buffer.concat(data as readonly Uint8Array[]).toString('utf8')
64
+ if (data instanceof Uint8Array) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8')
65
+ return Buffer.from(data as ArrayBufferLike).toString('utf8')
66
+ }
67
+
38
68
  /**
39
69
  * Wire one accepted socket to the agent's audio events.
40
70
  *
@@ -61,10 +91,36 @@ export function attachAudioSocket(client: AudioSocket, deps: AudioSocketBridgeDe
61
91
  deps.onDetach()
62
92
  }
63
93
 
94
+ /**
95
+ * Answer one control frame, if the socket is still there.
96
+ *
97
+ * The guard is here rather than at the call site because this is the only place that knows whether the
98
+ * bridge is still live, and because a control handler settles asynchronously: a reply that arrives after
99
+ * the socket went away must be dropped, not thrown.
100
+ *
101
+ * **Total**, including the write: a socket can die between the check above and the send, and a throw here
102
+ * would escape into a promise nobody awaits — and, in the plugin, would break the reply queue for every
103
+ * frame behind it.
104
+ * @param text - the reply frame.
105
+ */
106
+ const reply = (text: string): void => {
107
+ if (stopped) return
108
+ try {
109
+ client.send(text)
110
+ } catch {
111
+ stop()
112
+ }
113
+ }
114
+
64
115
  client.on('message', (data, isBinary) => {
65
- // A text frame is not part of this contract. Ignoring one is cheaper than closing the connection and
66
- // no less correct: nothing on this wire is JSON.
67
- if (!isBinary) return
116
+ // A **text** frame is a control frame. This is the deliberate widening of the contract that used to
117
+ // say the opposite: the socket is the one duplex connection the client already holds, and a control
118
+ // plane that needs a second route with a second authentication is a control plane nobody builds.
119
+ // Checked before the audio path, because a text frame is never audio whatever it contains.
120
+ if (!isBinary) {
121
+ deps.onControl(toText(data), reply)
122
+ return
123
+ }
68
124
  const pcm16 = toBytes(data)
69
125
  if (pcm16.byteLength > deps.maxFrameBytes) {
70
126
  // 1009 = message too big. `ws` enforces its own `maxPayload` first, so reaching this means the bound