dsh-realtime 0.2.2 → 0.2.3

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.
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The settings surface: what a running plugin's settings are, and which of them a change can reach.
3
+ *
4
+ * `docs/control-plane-fields.md` is the design gate, and it classifies every field into one of three
5
+ * classes: **live** (read at the moment of use, so a change takes effect then), **session-bound** (it
6
+ * travelled in the provider's `session.start`, so only a new session can carry a new value) and
7
+ * **restart-bound** (claimed once against the web server's registry or enforced by the socket). This
8
+ * module is that classification in code, because a classification that lives only in prose cannot
9
+ * refuse anything: the design rule is *an affordance the protocol cannot honour is worse than no
10
+ * affordance*, and the only way to honour it is for the surface to know a setting's class and answer a
11
+ * change with the reason instead of applying it.
12
+ *
13
+ * Four properties are load-bearing, and each is a test rather than a promise:
14
+ *
15
+ * - **The value is read at the moment of use, from the plugin that owns it.** A setting holds a
16
+ * `get()`, and the consumer calls it where it acts — not a copy taken at apply time. That is what
17
+ * makes the change take effect on the next use, and it is why a stale copy is impossible rather than
18
+ * merely discouraged.
19
+ * - **A change is refused with a reason on the same channel it arrived on.** Unknown key, a class that
20
+ * cannot honour a change, a value that does not parse, a setter that rejects one — four different
21
+ * reasons, because they call for four different responses and collapsing them is the failure this
22
+ * project has already paid for once (see `realtime-responder/src/turn.ts`).
23
+ * - **A secret setting is write-only.** `redactSecrets` holds values that must never be spoken,
24
+ * journalled or handed to a page; a surface that echoed them back through `status` or a `set`
25
+ * outcome would breach `design.md` invariant 3 one layer out. A setting declared `secret` reports no
26
+ * value at all.
27
+ * - **Registration is a contract, checked at registration.** The declared `kind` must match what
28
+ * `get()` actually returns, and only a `live` setting may have a setter — so a setting that would
29
+ * render the wrong control, or claim a change it cannot apply, fails where it is declared rather
30
+ * than in a user's session.
31
+ *
32
+ * @module dsh-realtime/settings
33
+ */
34
+ import { REALTIME_ERROR_CODES, RealtimeError } from './error.js';
35
+ import { redact } from './redact.js';
36
+ /** The classes a setting may declare, for validating a caller that is not TypeScript. */
37
+ const SCOPES = Object.freeze(['live', 'session', 'restart']);
38
+ /** The kinds a setting may declare, for the same reason. */
39
+ const KINDS = Object.freeze(['string', 'number', 'boolean', 'string-list']);
40
+ /** Field names are dotted onto an owner, so they must be a single identifier-shaped word. */
41
+ const FIELD_SHAPE = /^[A-Za-z][A-Za-z0-9]*$/;
42
+ /** Longest reason carried out of a refusal, so an owner's error cannot flood a status surface. */
43
+ const MAX_REASON_CHARS = 200;
44
+ /**
45
+ * The registry of settings a running plugin can be steered by.
46
+ *
47
+ * Owned by the seam, like the journal, and for the same reason: it is an object every plugin in the
48
+ * bundle already holds, and a surface split across three of them would leave a reader — or a control
49
+ * plane — correlating three partial answers to one question.
50
+ *
51
+ * Not a service and not tied to a Cordis context: a plain object, so it can be constructed in a test
52
+ * and asked things without a harness. Registration returns a disposer rather than taking ownership of
53
+ * one, so the *contributing* fiber is what releases a plugin's settings — call it inside
54
+ * `ctx.effect`, exactly as a plugin registers a listener.
55
+ */
56
+ export class RealtimeSettings {
57
+ journal;
58
+ settings = new Map();
59
+ /**
60
+ * @param journal - the seam's journal. A successful change is recorded there as `config.changed`;
61
+ * see {@link apply} for what is deliberately not recorded.
62
+ */
63
+ constructor(journal) {
64
+ this.journal = journal;
65
+ }
66
+ /**
67
+ * Declare the settings one plugin owns, all-or-nothing.
68
+ *
69
+ * A malformed spec fails here rather than at the moment somebody tries to change it: the point of a
70
+ * declared kind is that a control and a parser agree with the plugin's own type, and a setting whose
71
+ * declaration does not match what it returns has neither.
72
+ * @param owner - the plugin's name, as it appears in Loader diagnostics. Becomes the key's prefix.
73
+ * @param specs - every setting this plugin declares.
74
+ * @returns the disposer that releases them. Call it inside the contributing fiber's effect.
75
+ * @throws RealtimeError `INVALID_SETTING` for a malformed owner, field, kind, scope or declaration.
76
+ * @throws RealtimeError `DUPLICATE_SETTING` for a key another registration already holds.
77
+ */
78
+ register(owner, specs) {
79
+ if (typeof owner !== 'string' || owner.length === 0) {
80
+ throw new RealtimeError('a settings owner must be a non-empty string', REALTIME_ERROR_CODES.INVALID_SETTING);
81
+ }
82
+ const prepared = [];
83
+ const claimed = new Set();
84
+ for (const spec of specs) {
85
+ const key = `${owner}.${String(spec.field)}`;
86
+ if (typeof spec.field !== 'string' || !FIELD_SHAPE.test(spec.field)) {
87
+ throw new RealtimeError(`a setting's field must be identifier-shaped, received "${String(spec.field)}"`, REALTIME_ERROR_CODES.INVALID_SETTING);
88
+ }
89
+ if (claimed.has(key) || this.settings.has(key)) {
90
+ throw new RealtimeError(`a setting named "${key}" is already registered`, REALTIME_ERROR_CODES.DUPLICATE_SETTING);
91
+ }
92
+ if (!SCOPES.includes(spec.scope)) {
93
+ throw new RealtimeError(`"${key}" declared an unknown scope "${String(spec.scope)}"`, REALTIME_ERROR_CODES.INVALID_SETTING);
94
+ }
95
+ if (!KINDS.includes(spec.kind)) {
96
+ throw new RealtimeError(`"${key}" declared an unknown kind "${String(spec.kind)}"`, REALTIME_ERROR_CODES.INVALID_SETTING);
97
+ }
98
+ // A list of candidates is a claim about the values a *picker* offers, and a picker only exists for a
99
+ // string: numbers, booleans and lists each have one representation a control does not need help with.
100
+ if (spec.choices !== undefined && spec.kind !== 'string') {
101
+ throw new RealtimeError(`"${key}" declares choices and the kind "${spec.kind}" — candidates are for a string setting`, REALTIME_ERROR_CODES.INVALID_SETTING);
102
+ }
103
+ // A change can only reach a `live` field, and only a `live` field has anything to apply it. Two
104
+ // one-sided declarations, and both are refusals rather than warnings for the same reason: the
105
+ // first would offer a control that silently does nothing, the second would answer a change by
106
+ // doing nothing at all.
107
+ if (spec.scope === 'live' && spec.set === undefined) {
108
+ throw new RealtimeError(`"${key}" is live and must declare how a change is applied`, REALTIME_ERROR_CODES.INVALID_SETTING);
109
+ }
110
+ if (spec.scope !== 'live' && spec.set !== undefined) {
111
+ throw new RealtimeError(`"${key}" is ${spec.scope}-bound and cannot declare a setter`, REALTIME_ERROR_CODES.INVALID_SETTING);
112
+ }
113
+ // Read once, at registration: the declared kind is a claim about what this setting *is*, and a
114
+ // claim that does not hold here produces a control that shows the wrong thing for ever.
115
+ const now = spec.get();
116
+ if (!matchesKind(spec.kind, now)) {
117
+ throw new RealtimeError(`"${key}" declares the kind "${spec.kind}" and returns ${describeValue(now)}`, REALTIME_ERROR_CODES.INVALID_SETTING);
118
+ }
119
+ claimed.add(key);
120
+ prepared.push({
121
+ key,
122
+ owner,
123
+ field: spec.field,
124
+ kind: spec.kind,
125
+ scope: spec.scope,
126
+ ...spec.describe === undefined ? {} : { describe: spec.describe },
127
+ secret: spec.secret === true,
128
+ ...spec.choices === undefined ? {} : { choices: spec.choices },
129
+ get: spec.get,
130
+ ...spec.set === undefined ? {} : { set: spec.set },
131
+ });
132
+ }
133
+ for (const registered of prepared)
134
+ this.settings.set(registered.key, registered);
135
+ return () => {
136
+ for (const registered of prepared)
137
+ this.settings.delete(registered.key);
138
+ };
139
+ }
140
+ /**
141
+ * Every registered setting, in the order its plugins registered them.
142
+ *
143
+ * Registration order is composition order, which is what a status surface wants to show; sorting
144
+ * would be deterministic and would also hide which plugin arrived first, which is exactly the thing
145
+ * a reader is trying to work out when a row waits.
146
+ * @returns detached descriptions, each with the value read now.
147
+ */
148
+ list() {
149
+ return [...this.settings.values()].map(registered => this.describe(registered));
150
+ }
151
+ /**
152
+ * Describe one setting.
153
+ * @param key - `<owner>.<field>`.
154
+ * @returns the description, or `undefined` when nothing is registered under that key.
155
+ */
156
+ get(key) {
157
+ const registered = this.settings.get(key);
158
+ return registered === undefined ? undefined : this.describe(registered);
159
+ }
160
+ /**
161
+ * Apply a change addressed as text, and say what happened.
162
+ *
163
+ * The one operation a control plane needs: it parses by the declared kind, refuses anything the
164
+ * setting's class cannot honour, hands the value to the owner's own setter and reports the value the
165
+ * owner now holds. A change that landed is journalled as `config.changed` **with its key and not its
166
+ * value** — the value of a secret setting is precisely the text that must not be retained, and a
167
+ * surface that journalled "the values it was given" would write them into the one record built to be
168
+ * read and pasted.
169
+ * @param key - the setting's `<owner>.<field>` key, exactly as {@link RealtimeSettingInfo.key} reports it.
170
+ * @param text - the value as a text frame carries it.
171
+ * @returns whether the change was applied, and either the value now or the reason it was refused.
172
+ */
173
+ apply(key, text) {
174
+ const registered = this.settings.get(key);
175
+ // Checked before the value is looked at: whether a setting can be changed at all is a property of
176
+ // the setting, and answering a malformed value first would tell a caller to fix a typo in a number
177
+ // where the real answer is that this field needs a restart.
178
+ if (registered === undefined) {
179
+ return this.refuse(key, 'UNKNOWN_SETTING', `no setting named "${key}" is registered`);
180
+ }
181
+ if (registered.scope !== 'live')
182
+ return this.refuse(key, 'FROZEN_SETTING', frozenReason(registered));
183
+ const parsed = parseValue(registered.kind, text);
184
+ if (!parsed.ok) {
185
+ return this.refuse(key, 'INVALID_SETTING', `"${key}" expects a ${registered.kind}, received ${describeText(text)}`);
186
+ }
187
+ try {
188
+ registered.set?.(parsed.value);
189
+ }
190
+ catch (error) {
191
+ // The owner's own rules — a non-empty session id, a positive budget — are the ones a caller can
192
+ // act on, so its message is carried rather than replaced by a generic one.
193
+ return this.refuse(key, 'INVALID_SETTING', `"${key}" refused the change: ${reasonOf(error)}`);
194
+ }
195
+ this.journal.record('config.changed', { key });
196
+ return { ok: true, key, value: registered.secret ? undefined : registered.get() };
197
+ }
198
+ /**
199
+ * Build one detached description, reading the value now.
200
+ * @param registered - the stored setting.
201
+ * @returns the description, with no value at all when the setting is secret.
202
+ */
203
+ describe(registered) {
204
+ return {
205
+ key: registered.key,
206
+ owner: registered.owner,
207
+ field: registered.field,
208
+ kind: registered.kind,
209
+ scope: registered.scope,
210
+ ...registered.describe === undefined ? {} : { describe: registered.describe },
211
+ ...registered.choices === undefined ? {} : { choices: registered.choices() },
212
+ value: registered.secret ? undefined : registered.get(),
213
+ };
214
+ }
215
+ /**
216
+ * Build one refusal, redacted and bounded.
217
+ * @param key - the setting the change addressed.
218
+ * @param code - which class of refusal this is.
219
+ * @param reason - the line to relay.
220
+ * @returns the refusal to hand back.
221
+ */
222
+ refuse(key, code, reason) {
223
+ // The reason may quote the value the caller sent, and a caller is not always entitled to read it:
224
+ // the shape arm runs over it, and the bound stops an owner's error message becoming the payload.
225
+ return { ok: false, key, code, reason: redact(reason).slice(0, MAX_REASON_CHARS) };
226
+ }
227
+ }
228
+ /**
229
+ * Why a setting of this class cannot take a change, in the words the person making the change needs.
230
+ *
231
+ * The two classes are different instructions — one reconnect, one restart — and naming which is the
232
+ * whole value of refusing rather than ignoring.
233
+ * @param registered - the frozen setting.
234
+ * @returns the reason.
235
+ */
236
+ function frozenReason(registered) {
237
+ return registered.scope === 'session'
238
+ ? `"${registered.key}" is fixed when the voice session opens — reconnect to apply a new value`
239
+ : `"${registered.key}" is claimed when the plugin loads — restart to change it`;
240
+ }
241
+ /**
242
+ * Does this value match the kind the setting declared?
243
+ *
244
+ * `get()` is the plugin's own, so the check costs nothing at runtime and catches the failure that
245
+ * matters: a declaration that would render a control for one kind over a value of another.
246
+ * @param kind - the declared kind.
247
+ * @param value - what `get()` returned.
248
+ * @returns whether they agree.
249
+ */
250
+ function matchesKind(kind, value) {
251
+ switch (kind) {
252
+ case 'string': return typeof value === 'string';
253
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
254
+ case 'boolean': return typeof value === 'boolean';
255
+ case 'string-list': return Array.isArray(value) && value.every(entry => typeof entry === 'string');
256
+ }
257
+ }
258
+ /**
259
+ * Describe a value's shape, for a registration that declared the wrong kind.
260
+ * @param value - whatever `get()` returned.
261
+ * @returns a short description, never the value's content.
262
+ */
263
+ function describeValue(value) {
264
+ if (value === null)
265
+ return 'null';
266
+ if (Array.isArray(value))
267
+ return 'an array';
268
+ return `a ${typeof value}`;
269
+ }
270
+ /**
271
+ * Parse one text value into the declared kind.
272
+ *
273
+ * `string` cannot fail — every text is a string — so an empty value is deliberately *not* refused here:
274
+ * whether a particular string may be empty is the owner's rule, and it can say why. The other three
275
+ * kinds have exactly one representation each, because an ambiguous parse is a change that applies
276
+ * something the caller did not ask for.
277
+ * @param kind - the setting's declared kind.
278
+ * @param text - the value as it arrived.
279
+ * @returns the parsed value, or that it did not parse.
280
+ */
281
+ function parseValue(kind, text) {
282
+ switch (kind) {
283
+ case 'string':
284
+ return { ok: true, value: text };
285
+ case 'number': {
286
+ const trimmed = text.trim();
287
+ if (trimmed.length === 0)
288
+ return { ok: false };
289
+ const value = Number(trimmed);
290
+ return Number.isFinite(value) ? { ok: true, value } : { ok: false };
291
+ }
292
+ case 'boolean':
293
+ if (text !== 'true' && text !== 'false')
294
+ return { ok: false };
295
+ return { ok: true, value: text === 'true' };
296
+ case 'string-list': {
297
+ // JSON, and only JSON: a list of secrets separated by commas cannot be split on a comma, and a
298
+ // rule that guessed would silently drop half a secret from the redaction list.
299
+ let parsed;
300
+ try {
301
+ parsed = JSON.parse(text);
302
+ }
303
+ catch {
304
+ return { ok: false };
305
+ }
306
+ if (!Array.isArray(parsed) || !parsed.every(entry => typeof entry === 'string'))
307
+ return { ok: false };
308
+ return { ok: true, value: parsed };
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * Quote a received value back, redacted and bounded.
314
+ *
315
+ * Redacted because the echo travels to whoever asked and into whatever they render it in, and a caller
316
+ * that sent a credential to the right route by mistake should not have it handed back for display.
317
+ * @param text - the value as it arrived.
318
+ * @returns a short, safe quotation of it.
319
+ */
320
+ function describeText(text) {
321
+ return JSON.stringify(redact(text).slice(0, 40));
322
+ }
323
+ /**
324
+ * The reason a setter refused, as a line to relay.
325
+ *
326
+ * A rejection that carries no message is still a rejection, and naming the absence is more useful than
327
+ * an empty string that reads as "no reason".
328
+ * @param error - whatever the setter threw.
329
+ * @returns a non-empty reason.
330
+ */
331
+ function reasonOf(error) {
332
+ if (error instanceof Error && error.message.length > 0)
333
+ return error.message;
334
+ if (typeof error === 'string' && error.length > 0)
335
+ return error;
336
+ return 'the setting declined the value';
337
+ }
338
+ //# sourceMappingURL=settings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.js","sourceRoot":"","sources":["../src/settings.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Realtime voice capability seam for DeepSeek Harness: a provider registry plus the session adapter base.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/error.ts CHANGED
@@ -27,6 +27,10 @@ export const REALTIME_ERROR_CODES = Object.freeze({
27
27
  PROVIDER_ERROR: 'PROVIDER_ERROR',
28
28
  /** A recorded session could not be read as a recording. */
29
29
  INVALID_RECORDING: 'INVALID_RECORDING',
30
+ /** A setting's declaration is malformed: a bad field name, kind, scope, or a kind that does not match what it returns. */
31
+ INVALID_SETTING: 'INVALID_SETTING',
32
+ /** A setting is already registered under that key by another registration. */
33
+ DUPLICATE_SETTING: 'DUPLICATE_SETTING',
30
34
 
31
35
  // ---------------------------------------------------------------------------------------------
32
36
  // Failure taxonomy. These exist so a consumer can branch on the *class* of a failure rather than
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { Service, type Context } from '@deepseek-ai/cordis'
13
13
  import { REALTIME_ERROR_CODES, RealtimeError } from './error.ts'
14
14
  import { Journal } from './journal.ts'
15
+ import { RealtimeSettings } from './settings.ts'
15
16
  import type {
16
17
  RealtimeDelegation,
17
18
  RealtimeModelInfo,
@@ -25,6 +26,7 @@ export * from './types.ts'
25
26
  export * from './error.ts'
26
27
  export * from './redact.ts'
27
28
  export * from './journal.ts'
29
+ export * from './settings.ts'
28
30
  export { RealtimeError, REALTIME_ERROR_CODES } from './error.ts'
29
31
 
30
32
  declare module '@deepseek-ai/cordis' {
@@ -120,6 +122,18 @@ export class RealtimeRuntime extends Service {
120
122
  */
121
123
  readonly journal = new Journal()
122
124
 
125
+ /**
126
+ * The settings every plugin in this bundle declares, and the surface a control plane changes them
127
+ * through.
128
+ *
129
+ * Owned here beside the journal, for the same reason: it is the one object all of them already hold,
130
+ * and a surface split across three of them would leave a reader correlating three partial answers to
131
+ * one question. A setting's class comes from `docs/control-plane-fields.md`, and the registry is what
132
+ * makes that classification bite — a change to a field the protocol cannot honour is refused with the
133
+ * reason, on the same channel it arrived on.
134
+ */
135
+ readonly settings = new RealtimeSettings(this.journal)
136
+
123
137
  /**
124
138
  * @param ctx - the Cordis context this service is mounted on.
125
139
  */
package/src/journal.ts CHANGED
@@ -59,6 +59,15 @@ export type JournalKind =
59
59
  | 'speech.sent'
60
60
  /** The configuration as resolved, so a journal can be read without the profile beside it. */
61
61
  | 'config.resolved'
62
+ /**
63
+ * A live setting changed while the plugin was running.
64
+ *
65
+ * Recorded with the setting's **key and not its value**: the value of a secret-bearing setting is
66
+ * exactly the text that must not be retained, and this is the one record built to be read, quoted
67
+ * and pasted. What a reader needs is that a change happened and which setting it was — the value is
68
+ * whatever the plugin now reports through the settings surface.
69
+ */
70
+ | 'config.changed'
62
71
 
63
72
  /** One retained entry. */
64
73
  export interface JournalEntry {