dsh-her 0.0.1 → 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/cordis.patch.yml +5 -0
  4. package/examples/workshop/README.md +20 -0
  5. package/lib/client.js +1602 -0
  6. package/lib/core/character.js +50 -0
  7. package/lib/core/cues.js +62 -0
  8. package/lib/core/echo.js +27 -0
  9. package/lib/core/index.js +10 -0
  10. package/lib/core/paths.js +14 -0
  11. package/lib/core/prompt.js +63 -0
  12. package/lib/core/sentences.js +115 -0
  13. package/lib/core/speakable.js +26 -0
  14. package/lib/core/voices.js +43 -0
  15. package/lib/core/wav.js +71 -0
  16. package/lib/core/workshop.js +330 -0
  17. package/lib/dsh/api.js +156 -0
  18. package/lib/dsh/body-install.js +137 -0
  19. package/lib/dsh/body-launcher.js +48 -0
  20. package/lib/dsh/config.js +27 -0
  21. package/lib/dsh/her-session.js +282 -0
  22. package/lib/dsh/import-status.js +6 -0
  23. package/lib/dsh/plugin.js +1010 -0
  24. package/lib/dsh/workshop.js +222 -0
  25. package/lib/index.js +11 -0
  26. package/lib/memory/digest.js +119 -0
  27. package/lib/memory/search.js +52 -0
  28. package/lib/memory/store.js +267 -0
  29. package/lib/protocol/index.js +39 -0
  30. package/lib/server/brain-server.js +219 -0
  31. package/lib/types/core/character.d.ts +32 -0
  32. package/lib/types/core/cues.d.ts +20 -0
  33. package/lib/types/core/echo.d.ts +10 -0
  34. package/lib/types/core/index.d.ts +10 -0
  35. package/lib/types/core/paths.d.ts +11 -0
  36. package/lib/types/core/prompt.d.ts +39 -0
  37. package/lib/types/core/sentences.d.ts +40 -0
  38. package/lib/types/core/speakable.d.ts +6 -0
  39. package/lib/types/core/voices.d.ts +23 -0
  40. package/lib/types/core/wav.d.ts +9 -0
  41. package/lib/types/core/workshop.d.ts +156 -0
  42. package/lib/types/dsh/api.d.ts +163 -0
  43. package/lib/types/dsh/body-install.d.ts +58 -0
  44. package/lib/types/dsh/body-launcher.d.ts +28 -0
  45. package/lib/types/dsh/config.d.ts +34 -0
  46. package/lib/types/dsh/her-session.d.ts +215 -0
  47. package/lib/types/dsh/import-status.d.ts +17 -0
  48. package/lib/types/dsh/plugin.d.ts +34 -0
  49. package/lib/types/dsh/workshop.d.ts +94 -0
  50. package/lib/types/index.d.ts +11 -0
  51. package/lib/types/memory/digest.d.ts +33 -0
  52. package/lib/types/memory/search.d.ts +17 -0
  53. package/lib/types/memory/store.d.ts +132 -0
  54. package/lib/types/protocol/index.d.ts +434 -0
  55. package/lib/types/server/brain-server.d.ts +71 -0
  56. package/lib/types/voice/cloud-key.d.ts +10 -0
  57. package/lib/types/voice/cosyvoice.d.ts +47 -0
  58. package/lib/types/voice/edge.d.ts +52 -0
  59. package/lib/types/voice/engine.d.ts +30 -0
  60. package/lib/types/voice/models.d.ts +21 -0
  61. package/lib/types/voice/speech-queue.d.ts +55 -0
  62. package/lib/types/voice/voice.d.ts +75 -0
  63. package/lib/types/voice/worker.d.ts +37 -0
  64. package/lib/voice/cloud-key.js +23 -0
  65. package/lib/voice/cosyvoice.js +194 -0
  66. package/lib/voice/edge.js +199 -0
  67. package/lib/voice/engine.js +101 -0
  68. package/lib/voice/models.js +69 -0
  69. package/lib/voice/speech-queue.js +118 -0
  70. package/lib/voice/voice.js +171 -0
  71. package/lib/voice/worker.js +47 -0
  72. package/package.json +61 -4
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The daily digest: once a day, the day's conversation is read back from her
3
+ * DSH session and a model writes her diary page and picks out what is worth
4
+ * keeping. This file is the pure part: the transcript, the prompt, and a
5
+ * forgiving parser for the model's JSON.
6
+ */
7
+ import type { DigestResult } from './store.ts';
8
+ export interface TranscriptLine {
9
+ who: 'you' | 'her';
10
+ text: string;
11
+ time: number;
12
+ }
13
+ /** Events of her session within one local day, as "you" / "her" lines. Context notes and tags are left out. */
14
+ export declare function transcriptOf(events: Array<{
15
+ type: string;
16
+ time?: number;
17
+ data: unknown;
18
+ }>, dayStart: number, dayEnd: number): TranscriptLine[];
19
+ export declare function digestPrompt(name: string, day: string, transcript: TranscriptLine[], knownFacts: string[], knownEvents?: string[], pronoun?: string): {
20
+ system: string;
21
+ user: string;
22
+ };
23
+ /**
24
+ * Right after a talk: what in these few turns is worth keeping, so it shows
25
+ * on the "她记得的事" page at once (the diary still waits for the day's digest).
26
+ * Same answer shape as the digest, without the diary; parseDigest reads it.
27
+ */
28
+ export declare function notePrompt(name: string, day: string, transcript: TranscriptLine[], knownFacts: string[], knownEvents?: string[], pronoun?: string): {
29
+ system: string;
30
+ user: string;
31
+ };
32
+ /** The first JSON object in the model's answer, checked and cleaned; undefined if there is none. */
33
+ export declare function parseDigest(answer: string): DigestResult | undefined;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Recall without embeddings (docs/内置记忆.md §3): Chinese is cut into
3
+ * character bigrams (single characters for one-character runs), Latin text
4
+ * into lowercase words, scored with BM25, and newer memories weigh a little
5
+ * more. DeepSeek offers no embedding API and a local embedding model would
6
+ * add hundreds of MB, so this is enough for one person's memories.
7
+ */
8
+ export interface SearchDoc {
9
+ id: string;
10
+ text: string;
11
+ /** When it was written or happened (ms). */
12
+ at: number;
13
+ }
14
+ export declare function tokens(text: string): string[];
15
+ export declare function search(docs: SearchDoc[], query: string, now: number, limit?: number): Array<SearchDoc & {
16
+ score: number;
17
+ }>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Her built-in memory (docs/内置记忆.md): plain files under
3
+ * `<home>/characters/<cid>/memory/`, readable and editable by hand.
4
+ *
5
+ * - facts.json about you: name, likes, dislikes, people, dates. Facts she
6
+ * was told to remember are confirmed at once; facts found by
7
+ * the daily digest are candidates, confirmed by you or after
8
+ * 7 days, except sensitive ones (health, money, others'
9
+ * privacy), which wait for you.
10
+ * - episodes.jsonl things worth bringing up later, some with a date: on that
11
+ * day and the day before, they are put in front of her.
12
+ * - diary/<day>.md her diary, one page a day, written by the digest.
13
+ *
14
+ * `MemoryProvider` is the seam where the soul engine (dsh-vael) can replace
15
+ * all of this later. Writes go to a temporary file first and are renamed.
16
+ */
17
+ export interface Fact {
18
+ id: string;
19
+ text: string;
20
+ status: 'confirmed' | 'candidate';
21
+ sensitive: boolean;
22
+ source: 'remember' | 'digest';
23
+ createdAt: string;
24
+ }
25
+ export interface Episode {
26
+ id: string;
27
+ text: string;
28
+ /** When it was said or happened. */
29
+ at: string;
30
+ /** The day it is about (YYYY-MM-DD), if any. */
31
+ due?: string;
32
+ }
33
+ export interface MemoryHit {
34
+ id: string;
35
+ kind: 'fact' | 'episode' | 'diary';
36
+ text: string;
37
+ at: string;
38
+ }
39
+ export interface MemoryProvider {
40
+ /** "About you" for her prompt (at most ~300 characters). */
41
+ profile(): string;
42
+ /** The last two days of her diary, short. */
43
+ recent(now: Date): string;
44
+ /** Things with a date of today or tomorrow. */
45
+ due(now: Date): Episode[];
46
+ remember(item: {
47
+ kind: 'fact' | 'episode';
48
+ text: string;
49
+ due?: string;
50
+ sensitive?: boolean;
51
+ source?: Fact['source'];
52
+ }, now: Date): MemoryHit;
53
+ recall(query: string, now: Date, limit?: number): MemoryHit[];
54
+ list(): {
55
+ facts: Fact[];
56
+ episodes: Episode[];
57
+ diary: string[];
58
+ };
59
+ remove(ids: string[]): number;
60
+ confirm(ids: string[]): number;
61
+ /** Candidates older than 7 days and not sensitive become facts; returns how many. */
62
+ settle(now: Date): number;
63
+ /** Today's diary and what the digest found. */
64
+ applyDigest(day: string, digest: DigestResult, now: Date): {
65
+ facts: number;
66
+ episodes: number;
67
+ };
68
+ keep(found: Pick<DigestResult, 'facts' | 'episodes'>, now: Date): {
69
+ facts: number;
70
+ episodes: number;
71
+ };
72
+ lastDigestDay(): string | undefined;
73
+ exportMarkdown(): string;
74
+ clear(): void;
75
+ }
76
+ export interface DigestResult {
77
+ diary: string;
78
+ facts: Array<{
79
+ text: string;
80
+ sensitive?: boolean;
81
+ }>;
82
+ episodes: Array<{
83
+ text: string;
84
+ due?: string;
85
+ }>;
86
+ }
87
+ export declare function dayOf(date: Date): string;
88
+ export declare class FileMemory implements MemoryProvider {
89
+ private readonly dir;
90
+ constructor(dir: string);
91
+ private read;
92
+ private write;
93
+ private facts;
94
+ private saveFacts;
95
+ private episodes;
96
+ private saveEpisodes;
97
+ private diaryDays;
98
+ private nextId;
99
+ profile(): string;
100
+ recent(now: Date): string;
101
+ due(now: Date): Episode[];
102
+ remember(item: {
103
+ kind: 'fact' | 'episode';
104
+ text: string;
105
+ due?: string;
106
+ sensitive?: boolean;
107
+ source?: Fact['source'];
108
+ }, now: Date): MemoryHit;
109
+ recall(query: string, now: Date, limit?: number): MemoryHit[];
110
+ list(): {
111
+ facts: Fact[];
112
+ episodes: Episode[];
113
+ diary: string[];
114
+ };
115
+ remove(ids: string[]): number;
116
+ /** Drop diary sentences that mostly repeat any of these texts. */
117
+ private redactDiary;
118
+ confirm(ids: string[]): number;
119
+ settle(now: Date): number;
120
+ applyDigest(day: string, digest: DigestResult, now: Date): {
121
+ facts: number;
122
+ episodes: number;
123
+ };
124
+ /** Keep what a digest or a note found: new facts as candidates, events not already written down. */
125
+ keep(found: Pick<DigestResult, 'facts' | 'episodes'>, now: Date): {
126
+ facts: number;
127
+ episodes: number;
128
+ };
129
+ lastDigestDay(): string | undefined;
130
+ exportMarkdown(): string;
131
+ clear(): void;
132
+ }
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Body protocol v1: the messages between the brain (this plugin) and a body
3
+ * (the Electron desktop app, later Unity or a phone). Design: docs/身体协议.md.
4
+ *
5
+ * Text frames carry one JSON message each. Binary frames carry audio: a 4-byte
6
+ * big-endian header holding the stream id, then the payload. The audio frame
7
+ * is sent first, then the `speak.seg` or `user.utterance` naming its stream.
8
+ *
9
+ * This file has no runtime dependencies so the body can import the same source.
10
+ */
11
+ export declare const PROTOCOL_VERSION = 1;
12
+ export type AvatarState = 'idle' | 'listen' | 'think' | 'talk' | 'sleep';
13
+ export type WindowMode = 'overlay' | 'wallpaper' | 'window';
14
+ export type AudioFormat = 'wav' | 'mp3' | 'opus';
15
+ export interface CharMark {
16
+ /** Character offset in the segment text. */
17
+ i: number;
18
+ /** Milliseconds from the start of the segment audio. */
19
+ ms: number;
20
+ }
21
+ /** A performance cue riding on a sentence: applied when that sentence starts playing. */
22
+ export interface Cue {
23
+ kind: 'emotion' | 'act';
24
+ name: string;
25
+ }
26
+ export interface PackAction {
27
+ id: string;
28
+ desc: string;
29
+ durationMs?: number;
30
+ hasAudio?: boolean;
31
+ transcript?: string;
32
+ }
33
+ export interface PackSummary {
34
+ packId: string;
35
+ kind: 'video';
36
+ name: string;
37
+ layer: 'overlay' | 'scene';
38
+ states: AvatarState[];
39
+ emotions: string[];
40
+ actions: PackAction[];
41
+ hitParts: string[];
42
+ }
43
+ /**
44
+ * A look on the body: one character in several scenes and how they switch
45
+ * (docs/形象工坊与多场景.md §5). Its pack id in `pack.list` is `look:<id>`.
46
+ */
47
+ export interface LookView {
48
+ id: string;
49
+ name: string;
50
+ scenes: Array<{
51
+ id: string;
52
+ name: string;
53
+ when: string;
54
+ on: boolean;
55
+ }>;
56
+ current: string;
57
+ /** Switching by time of day and by whether you are around. */
58
+ auto: boolean;
59
+ /** Held in the current scene until asked to move. */
60
+ locked: boolean;
61
+ /** It has a desk-pet version ("浮在最上层" shows that instead of the scene). */
62
+ desk: boolean;
63
+ }
64
+ export interface BodyCaps {
65
+ audioOut: AudioFormat[];
66
+ micIn?: {
67
+ formats: string[];
68
+ vad?: string;
69
+ };
70
+ asrLocal: boolean;
71
+ ttsLocal: string[];
72
+ modes: WindowMode[];
73
+ screens: Array<{
74
+ id: number;
75
+ w: number;
76
+ h: number;
77
+ scale: number;
78
+ }>;
79
+ }
80
+ export interface CharacterView {
81
+ cid: string;
82
+ name: string;
83
+ packId?: string;
84
+ mode: WindowMode;
85
+ subtitle?: {
86
+ color?: string;
87
+ };
88
+ }
89
+ export interface Hello {
90
+ t: 'hello';
91
+ v: number;
92
+ token: string;
93
+ body: {
94
+ kind: 'video' | 'vrm' | 'unity' | 'web';
95
+ app: string;
96
+ version: string;
97
+ os: string;
98
+ };
99
+ caps: BodyCaps;
100
+ packs: PackSummary[];
101
+ }
102
+ export type BodyMessage = Hello | {
103
+ t: 'ping';
104
+ at: number;
105
+ } | {
106
+ t: 'user.text';
107
+ cid?: string;
108
+ text: string;
109
+ } | {
110
+ t: 'user.utterance';
111
+ cid?: string;
112
+ uttId: string;
113
+ format: string;
114
+ durationMs: number;
115
+ stream?: number;
116
+ text?: string;
117
+ } | {
118
+ t: 'user.speechStart';
119
+ cid?: string;
120
+ } | {
121
+ t: 'interrupt';
122
+ cid: string;
123
+ uid: string;
124
+ seq: number;
125
+ playedMs: number;
126
+ } | {
127
+ t: 'speak.progress';
128
+ cid: string;
129
+ uid: string;
130
+ seq: number;
131
+ phase: 'start' | 'end';
132
+ } | {
133
+ t: 'act.done';
134
+ cid: string;
135
+ id: string;
136
+ ok: boolean;
137
+ reason?: string;
138
+ } | {
139
+ t: 'answer';
140
+ cid: string;
141
+ id: string;
142
+ option?: string;
143
+ text?: string;
144
+ } | {
145
+ t: 'touch';
146
+ cid: string;
147
+ part: string;
148
+ kind: 'tap' | 'double' | 'long';
149
+ } | {
150
+ t: 'drag';
151
+ cid: string;
152
+ phase: 'start' | 'end';
153
+ pos: {
154
+ x: number;
155
+ y: number;
156
+ };
157
+ } | {
158
+ t: 'presence';
159
+ state: 'active' | 'idle' | 'away';
160
+ fullscreen: boolean;
161
+ locked: boolean;
162
+ } | {
163
+ t: 'posture';
164
+ cid: string;
165
+ anchor: string;
166
+ } | {
167
+ t: 'mode.changed';
168
+ cid: string;
169
+ mode: WindowMode;
170
+ pos?: {
171
+ x: number;
172
+ y: number;
173
+ screen?: number;
174
+ };
175
+ } | {
176
+ t: 'pack.progress';
177
+ id: string;
178
+ done: number;
179
+ total: number;
180
+ step: string;
181
+ } | {
182
+ t: 'pack.report';
183
+ id: string;
184
+ ok: boolean;
185
+ pack?: PackSummary;
186
+ lines: Array<{
187
+ level: 'ok' | 'warn' | 'error';
188
+ text: string;
189
+ }>;
190
+ }
191
+ /** The body's settings window wants who she is and what she remembers (answered with `panel`). */
192
+ | {
193
+ t: 'panel.get';
194
+ cid: string;
195
+ }
196
+ /** Changes made in the body's settings window; answered with a fresh `panel`. */
197
+ | {
198
+ t: 'panel.set';
199
+ cid: string;
200
+ character?: Partial<PanelCharacter>;
201
+ forget?: string[];
202
+ confirm?: string[];
203
+ }
204
+ /** The packs this body has imported, and the one on screen. */
205
+ | {
206
+ t: 'pack.list';
207
+ packs: PackSummary[];
208
+ active?: string;
209
+ }
210
+ /** The look on screen and its scenes (null: a single pack, no look). */
211
+ | {
212
+ t: 'look.state';
213
+ look: LookView | null;
214
+ }
215
+ /** Answer to `folder.pick`: what the user chose (none: cancelled). */
216
+ | {
217
+ t: 'folder.picked';
218
+ id: string;
219
+ path?: string;
220
+ } | {
221
+ t: 'error';
222
+ code: string;
223
+ message: string;
224
+ };
225
+ export interface Welcome {
226
+ t: 'welcome';
227
+ v: number;
228
+ sessionId: string;
229
+ characters: CharacterView[];
230
+ config: {
231
+ mic: 'push-to-talk' | 'always';
232
+ bargeIn: boolean;
233
+ };
234
+ }
235
+ /** What the body's settings window can show and change about her. */
236
+ export interface PanelCharacter {
237
+ name: string;
238
+ persona: string;
239
+ callYou: string;
240
+ pronoun: '他' | '她' | 'TA';
241
+ }
242
+ export interface PanelMemory {
243
+ facts: Array<{
244
+ id: string;
245
+ text: string;
246
+ status: 'confirmed' | 'candidate';
247
+ sensitive: boolean;
248
+ createdAt: string;
249
+ }>;
250
+ episodes: Array<{
251
+ id: string;
252
+ text: string;
253
+ at: string;
254
+ due?: string;
255
+ }>;
256
+ diary: string[];
257
+ }
258
+ export type BrainMessage = Welcome | {
259
+ t: 'reject';
260
+ reason: 'version' | 'token';
261
+ message: string;
262
+ } | {
263
+ t: 'pong';
264
+ at: number;
265
+ } | {
266
+ t: 'character.load';
267
+ cid: string;
268
+ name: string;
269
+ packId?: string;
270
+ mode: WindowMode;
271
+ subtitle?: {
272
+ color?: string;
273
+ };
274
+ } | {
275
+ t: 'character.unload';
276
+ cid: string;
277
+ } | {
278
+ t: 'state';
279
+ cid: string;
280
+ state: AvatarState;
281
+ } | {
282
+ t: 'emotion';
283
+ cid: string;
284
+ emotion: string;
285
+ intensity?: number;
286
+ } | {
287
+ t: 'speak.begin';
288
+ cid: string;
289
+ uid: string;
290
+ emotion?: string;
291
+ } | {
292
+ t: 'speak.seg';
293
+ cid: string;
294
+ uid: string;
295
+ seq: number;
296
+ text: string;
297
+ durationMs: number;
298
+ format?: AudioFormat;
299
+ stream?: number;
300
+ marks?: CharMark[];
301
+ cues?: Cue[];
302
+ } | {
303
+ t: 'speak.end';
304
+ cid: string;
305
+ uid: string;
306
+ } | {
307
+ t: 'speak.cancel';
308
+ cid: string;
309
+ uid: string;
310
+ } | {
311
+ t: 'act';
312
+ cid: string;
313
+ id: string;
314
+ action: string;
315
+ args?: unknown;
316
+ } | {
317
+ t: 'bubble';
318
+ cid: string;
319
+ text: string;
320
+ ttlMs?: number;
321
+ }
322
+ /**
323
+ * What the brain recognized in a `user.utterance` (empty: nothing understood).
324
+ * `ignored`: right after a barge-in it was her own voice (echo) or only a
325
+ * filler sound, so it was dropped and does not count as your turn.
326
+ */
327
+ | {
328
+ t: 'heard';
329
+ cid: string;
330
+ uttId: string;
331
+ text: string;
332
+ ignored?: 'echo' | 'filler';
333
+ } | {
334
+ t: 'ask';
335
+ cid: string;
336
+ id: string;
337
+ question: string;
338
+ options: string[];
339
+ } | {
340
+ t: 'mode';
341
+ cid: string;
342
+ mode: WindowMode;
343
+ screen?: number;
344
+ pos?: {
345
+ x: number;
346
+ y: number;
347
+ };
348
+ scale?: number;
349
+ } | {
350
+ t: 'config';
351
+ mic?: 'push-to-talk' | 'always';
352
+ bargeIn?: boolean;
353
+ }
354
+ /** Let the user choose a folder on the body's computer and import it (progress and report follow). */
355
+ | {
356
+ t: 'pack.pick';
357
+ id: string;
358
+ } | {
359
+ t: 'pack.import';
360
+ id: string;
361
+ dir: string;
362
+ }
363
+ /** Show this imported pack (or `look:<id>`) from now on. */
364
+ | {
365
+ t: 'pack.use';
366
+ packId: string;
367
+ }
368
+ /** Change how the look on screen switches scenes, or go to one now. */
369
+ | {
370
+ t: 'look.set';
371
+ auto?: boolean;
372
+ locked?: boolean;
373
+ go?: string;
374
+ on?: Record<string, boolean>;
375
+ }
376
+ /**
377
+ * Import a 形象工坊 project (folders on the body's computer): each part with
378
+ * clips becomes a pack, tied together as look `id`, and shown. Progress and
379
+ * the report come back as `pack.progress` / `pack.report`.
380
+ */
381
+ | {
382
+ t: 'look.import';
383
+ id: string;
384
+ name: string;
385
+ dir: string;
386
+ desk?: string;
387
+ default?: string;
388
+ scenes: Array<{
389
+ id: string;
390
+ name: string;
391
+ folder: string;
392
+ when?: {
393
+ hours?: string;
394
+ user?: 'active' | 'idle';
395
+ };
396
+ }>;
397
+ }
398
+ /** Open 做场景 on a project's scene folder: its plate.png is the photo, scene.json and crop.png are written there. */
399
+ | {
400
+ t: 'scene.frame';
401
+ dir: string;
402
+ name: string;
403
+ }
404
+ /**
405
+ * Let the user choose a folder (or, with `file`, also a file of that
406
+ * extension) on the body's computer; answered with `folder.picked`.
407
+ */
408
+ | {
409
+ t: 'folder.pick';
410
+ id: string;
411
+ title: string;
412
+ button: string;
413
+ file?: string;
414
+ }
415
+ /** Who she is and what she remembers, for the body's settings window; `error` when a change was refused. */
416
+ | {
417
+ t: 'panel';
418
+ cid: string;
419
+ character: PanelCharacter;
420
+ memory: PanelMemory;
421
+ error?: string;
422
+ };
423
+ export type AnyMessage = BodyMessage | BrainMessage;
424
+ /** Parse one text frame. Unknown `t` values pass through so newer peers degrade instead of failing. */
425
+ export declare function parseMessage<T extends {
426
+ t: string;
427
+ } = AnyMessage>(raw: string): T | undefined;
428
+ /** Prefix an audio payload with its stream id. */
429
+ export declare function encodeAudioFrame(stream: number, payload: Uint8Array): Uint8Array;
430
+ /** Split a binary frame into its stream id and payload; undefined when it is too short. */
431
+ export declare function decodeAudioFrame(frame: Uint8Array): {
432
+ stream: number;
433
+ payload: Uint8Array;
434
+ } | undefined;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The brain's side of the body protocol: a WebSocket server on loopback.
3
+ * Design: docs/身体协议.md §2–3.
4
+ *
5
+ * - Port: the preferred one, else the next free ones, else any free port.
6
+ * - Token: created once and kept in `<home>/token` (mode 600); a body proves
7
+ * itself with it in `hello`, never in the URL.
8
+ * - Discovery: `<home>/brain.json` (mode 600) says where the brain listens and
9
+ * carries the token, so a body started by hand can find it; it is rewritten
10
+ * on every start because the port may change, and removed on stop.
11
+ * - Heartbeat: a body pings every 15 s; one silent for 45 s is dropped.
12
+ * - Audio: a binary frame (4-byte stream id + payload) comes first, then the
13
+ * JSON message naming its stream; the server pairs them, both directions.
14
+ *
15
+ * The server knows nothing about DSH or sessions; the plugin wires it.
16
+ */
17
+ import { type BodyMessage, type BrainMessage, type Hello } from '../protocol/index.ts';
18
+ export declare const DEFAULT_PORT = 38100;
19
+ export interface BodyClient {
20
+ readonly id: number;
21
+ readonly hello: Hello;
22
+ send(message: BrainMessage): void;
23
+ /** Send audio for a stream; the message naming the stream must follow. */
24
+ sendAudio(stream: number, payload: Uint8Array): void;
25
+ }
26
+ export interface BrainServerOptions {
27
+ /** `<home>/token` and `<home>/brain.json`. */
28
+ tokenFile: string;
29
+ discoveryFile: string;
30
+ preferredPort?: number;
31
+ /** Called once a body has said a valid hello; return its welcome. */
32
+ welcome(client: BodyClient): BrainMessage;
33
+ /** Every later message from an admitted body, with the audio of its `stream` if it named one. */
34
+ message(client: BodyClient, message: BodyMessage, audio?: Uint8Array): void;
35
+ /** An admitted body went away. */
36
+ closed?(client: BodyClient): void;
37
+ logger?: {
38
+ info(message: string): void;
39
+ warn(message: string): void;
40
+ };
41
+ }
42
+ export interface Discovery {
43
+ url: string;
44
+ token: string;
45
+ pid: number;
46
+ protocol: number;
47
+ startedAt: string;
48
+ }
49
+ /** The brain's token: read, or created on first start. */
50
+ export declare function loadToken(file: string): string;
51
+ export declare class BrainServer {
52
+ private server;
53
+ private token;
54
+ private nextId;
55
+ private readonly clients;
56
+ private reaper;
57
+ private readonly lastSeen;
58
+ private readonly options;
59
+ constructor(options: BrainServerOptions);
60
+ get port(): number | undefined;
61
+ get url(): string | undefined;
62
+ get bodies(): BodyClient[];
63
+ start(): Promise<Discovery>;
64
+ broadcast(message: BrainMessage): void;
65
+ /** Send audio and then the message naming its stream to every body. */
66
+ broadcastWithAudio(message: BrainMessage, stream: number, payload: Uint8Array): void;
67
+ stop(): Promise<void>;
68
+ private accept;
69
+ private admit;
70
+ private reap;
71
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Model Studio (百炼) API key, never kept in a file: the environment
3
+ * variable `DASHSCOPE_API_KEY`, or on macOS the keychain item the user adds with
4
+ *
5
+ * security add-generic-password -a dsh-her -s dsh-her-dashscope -w
6
+ *
7
+ * Read on every connection, so a key added later works without restarting DSH.
8
+ */
9
+ export declare const KEYCHAIN_SERVICE = "dsh-her-dashscope";
10
+ export declare function readCloudKey(): Promise<string | undefined>;