dsh-output-styles 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.es.md +197 -0
- package/README.ja.md +197 -0
- package/README.ko.md +197 -0
- package/README.md +197 -0
- package/README.zh.md +197 -0
- package/cordis.patch.yml +40 -0
- package/docs/VERIFICATION.zh.md +93 -0
- package/lib/client.js +83 -0
- package/lib/index.js +415 -0
- package/lib/invariant-LV6hQX5s.js +442 -0
- package/lib/invariant.js +2 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/config.d.ts +73 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/index.d.ts +31 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +59 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +144 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/style-command.d.ts +68 -0
- package/lib/types/style-command.d.ts.map +1 -0
- package/lib/types/style-library.d.ts +78 -0
- package/lib/types/style-library.d.ts.map +1 -0
- package/lib/types/types.d.ts +78 -0
- package/lib/types/types.d.ts.map +1 -0
- package/package.json +138 -0
- package/src/client/index.ts +104 -0
- package/src/client/locales.ts +14 -0
- package/src/config.ts +110 -0
- package/src/index.ts +51 -0
- package/src/invariant.ts +144 -0
- package/src/runtime.ts +439 -0
- package/src/style-command.ts +89 -0
- package/src/style-library.ts +348 -0
- package/src/types.ts +86 -0
- package/styles/concise.md +17 -0
- package/styles/explanatory.md +14 -0
- package/styles/formal.md +14 -0
- package/styles/step-by-step.md +16 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime boundary and Cordis activation: style resolution over the durable
|
|
3
|
+
* selection domain, the model-visible system-prompt section, the `/style`
|
|
4
|
+
* command, the `style` session projection, and the invariant registration.
|
|
5
|
+
*
|
|
6
|
+
* Every registration is an effect — Cordis undoes all of them on unload, so
|
|
7
|
+
* configuration hot-reload replaces the whole plugin without residue.
|
|
8
|
+
* @module dsh-output-styles/runtime
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { watch, type FSWatcher } from 'node:fs'
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-session-projection'
|
|
18
|
+
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
|
19
|
+
import type { Domain, DomainFacility, KvTable } from '@deepseek-ai/dsh-storage-domain'
|
|
20
|
+
import z from '@deepseek-ai/schemastery'
|
|
21
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
22
|
+
import { resolveConfig, type Config } from './config.ts'
|
|
23
|
+
import { installInvariant, PACKAGE_NAME, type InvariantFacts, type InvariantRegistry } from './invariant.ts'
|
|
24
|
+
import { loadStyleLibrary, truncateStyle, type OutputStyle } from './style-library.ts'
|
|
25
|
+
import { applyStyleEvent, EMPTY_STYLE_STATE, parseStyleInput, STYLE_COMMAND, type StyleFoldState } from './style-command.ts'
|
|
26
|
+
import { OUTPUT_STYLE_DOMAIN, STYLE_SOURCE, styleSelectionViewSchema, type StyleSelection, type StyleSelectionView } from './types.ts'
|
|
27
|
+
|
|
28
|
+
/** Bundled style-library directory (package `styles/`), the lowest-priority `stylesDir` entry. */
|
|
29
|
+
export const DEFAULT_STYLES_DIR = fileURLToPath(new URL('../styles/', import.meta.url))
|
|
30
|
+
|
|
31
|
+
/** Prompt-section name; a fixed registry key a scoped composition could shadow. */
|
|
32
|
+
export const STYLE_SECTION_NAME = 'output-style:selection'
|
|
33
|
+
|
|
34
|
+
/** Settings namespace owning the project-level default (`outputStyle`). */
|
|
35
|
+
const SETTINGS_NS = settingsNamespace('output-style')
|
|
36
|
+
|
|
37
|
+
/** Coalescing delay for style-file change events; an internal implementation constant, not a deployment knob. */
|
|
38
|
+
const WATCH_DEBOUNCE_MS = 250
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolved style behavior for one session. A forced style (frontmatter
|
|
42
|
+
* `force: true`) wins over everything; otherwise the session's own durable
|
|
43
|
+
* selection wins, sessions that never selected one fall back to the project
|
|
44
|
+
* default (settings `outputStyle`, then the configured default style), and
|
|
45
|
+
* `''` means no style at all.
|
|
46
|
+
*/
|
|
47
|
+
export class OutputStyleRuntime {
|
|
48
|
+
private library: ReadonlyMap<string, OutputStyle>
|
|
49
|
+
private forcedStyle: OutputStyle | undefined
|
|
50
|
+
|
|
51
|
+
private readonly selection: KvTable<SessionId, StyleSelection>
|
|
52
|
+
private readonly defaultStyle: string
|
|
53
|
+
private readonly maxStyleChars: number
|
|
54
|
+
private readonly truncationMarker: string
|
|
55
|
+
private projectDefault: () => string
|
|
56
|
+
|
|
57
|
+
/** Style library in deterministic directory/file order. */
|
|
58
|
+
get styles(): ReadonlyMap<string, OutputStyle> {
|
|
59
|
+
return this.library
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param domain - the opened `output_style` domain; the caller owns `close()`.
|
|
64
|
+
* @param styles - the loaded style library (at most one `force` style).
|
|
65
|
+
* @param options - resolved style budget and default.
|
|
66
|
+
*/
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly domain: Domain<typeof OUTPUT_STYLE_DOMAIN>,
|
|
69
|
+
styles: ReadonlyMap<string, OutputStyle>,
|
|
70
|
+
options: {
|
|
71
|
+
readonly defaultStyle: string
|
|
72
|
+
readonly maxStyleChars: number
|
|
73
|
+
readonly truncationMarker: string
|
|
74
|
+
},
|
|
75
|
+
) {
|
|
76
|
+
this.library = styles
|
|
77
|
+
this.selection = domain.table('selection')
|
|
78
|
+
this.defaultStyle = options.defaultStyle
|
|
79
|
+
this.maxStyleChars = options.maxStyleChars
|
|
80
|
+
this.truncationMarker = options.truncationMarker
|
|
81
|
+
this.forcedStyle = [...styles.values()].find(style => style.force)
|
|
82
|
+
this.projectDefault = () => this.defaultStyle
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every switchable style name, in library order. */
|
|
86
|
+
get names(): readonly string[] {
|
|
87
|
+
return [...this.library.keys()]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The forced style's name, or undefined when the library declares none. */
|
|
91
|
+
get forcedName(): string | undefined {
|
|
92
|
+
return this.forcedStyle?.name
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Atomically swap the style library (style-file hot reload). The forced
|
|
97
|
+
* style is recomputed; the caller already validated the new library.
|
|
98
|
+
* @param styles - the replacement library.
|
|
99
|
+
*/
|
|
100
|
+
reload(styles: ReadonlyMap<string, OutputStyle>): void {
|
|
101
|
+
this.library = styles
|
|
102
|
+
this.forcedStyle = [...styles.values()].find(style => style.force)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Point the project-default resolution at a live source (the settings
|
|
107
|
+
* scope while one is attached, the composition entry otherwise).
|
|
108
|
+
* @param get - thunk returning the project default style name (`''` = none).
|
|
109
|
+
*/
|
|
110
|
+
setProjectDefault(get: () => string): void {
|
|
111
|
+
this.projectDefault = get
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve one style by name.
|
|
116
|
+
* @param name - style name.
|
|
117
|
+
* @returns the style, or undefined when the library has none.
|
|
118
|
+
*/
|
|
119
|
+
get(name: string): OutputStyle | undefined {
|
|
120
|
+
return this.library.get(name)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The session's durable selection record.
|
|
125
|
+
* @param sessionId - the session the selection belongs to.
|
|
126
|
+
* @returns the record, or undefined when the session never selected one.
|
|
127
|
+
*/
|
|
128
|
+
selectionFor(sessionId: SessionId): StyleSelection | undefined {
|
|
129
|
+
return this.selection.get(sessionId)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The style in force for a session: a forced library style, else the
|
|
134
|
+
* session's own selection, else the project default, else none. A stale
|
|
135
|
+
* selection or project default (its style left the library) also degrades
|
|
136
|
+
* to none.
|
|
137
|
+
* @param sessionId - the session the style is resolved for.
|
|
138
|
+
* @returns the effective style, or undefined when no style applies.
|
|
139
|
+
*/
|
|
140
|
+
effectiveStyle(sessionId: SessionId): OutputStyle | undefined {
|
|
141
|
+
if (this.forcedStyle !== undefined) return this.forcedStyle
|
|
142
|
+
const record = this.selectionFor(sessionId)
|
|
143
|
+
const name = record !== undefined ? record.style : this.projectDefault()
|
|
144
|
+
return name === '' ? undefined : this.library.get(name)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The effective style name, or `''` when no style applies.
|
|
149
|
+
* @param sessionId - the session the style is resolved for.
|
|
150
|
+
* @returns the style name, or `''`.
|
|
151
|
+
*/
|
|
152
|
+
currentName(sessionId: SessionId): string {
|
|
153
|
+
return this.effectiveStyle(sessionId)?.name ?? ''
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The model-visible style directive for a session: a header naming the
|
|
158
|
+
* style plus its body under the configured budget. The exact text is what
|
|
159
|
+
* the harness logs in `request/header` before dispatch.
|
|
160
|
+
* @param sessionId - the session the directive is built for.
|
|
161
|
+
* @returns the directive, or `''` when no style applies.
|
|
162
|
+
*/
|
|
163
|
+
promptText(sessionId: SessionId): string {
|
|
164
|
+
const style = this.effectiveStyle(sessionId)
|
|
165
|
+
if (style === undefined) return ''
|
|
166
|
+
const body = truncateStyle(style.body, this.maxStyleChars, this.truncationMarker)
|
|
167
|
+
return `# Output style: ${style.name}\n\nUse the following output style for every response in this conversation:\n\n${body}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The `/style` no-argument listing: the current selection followed by one
|
|
172
|
+
* line per style (`name — description`, with `whenToUse` appended when set).
|
|
173
|
+
* @param sessionId - the session the listing describes.
|
|
174
|
+
* @returns the multi-line command result text.
|
|
175
|
+
*/
|
|
176
|
+
listLine(sessionId: SessionId): string {
|
|
177
|
+
const current = this.currentName(sessionId)
|
|
178
|
+
const lines = [current === '' ? 'output style off' : `current output style: ${current}`]
|
|
179
|
+
for (const style of this.styles.values()) {
|
|
180
|
+
const whenToUse = style.whenToUse === undefined ? '' : ` (${style.whenToUse})`
|
|
181
|
+
lines.push(`${style.name} — ${style.description}${whenToUse}`)
|
|
182
|
+
}
|
|
183
|
+
return lines.join('\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The unknown-name error line, shared by the command handler and the direct
|
|
188
|
+
* {@link OutputStyleRuntime.select} write path.
|
|
189
|
+
* @param name - the rejected switch target.
|
|
190
|
+
* @returns the error text listing every switchable name.
|
|
191
|
+
*/
|
|
192
|
+
unknownStyleLine(name: string): string {
|
|
193
|
+
return `unknown output style "${name}" (available: ${this.names.join(', ')})`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Durably select a style for a session. The write resolves only after the
|
|
198
|
+
* backend acknowledged it, so a settled command implies a stored record.
|
|
199
|
+
* @param session - the session the selection belongs to.
|
|
200
|
+
* @param name - library style name; unknown names throw.
|
|
201
|
+
* @returns resolution after durability.
|
|
202
|
+
*/
|
|
203
|
+
async select(session: Session, name: string): Promise<void> {
|
|
204
|
+
if (this.styles.get(name) === undefined) {
|
|
205
|
+
throw new Error(`dsh-output-styles: ${this.unknownStyleLine(name)}`)
|
|
206
|
+
}
|
|
207
|
+
await this.selection.put(session.id, { style: name, source: STYLE_SOURCE })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Remove a session's selection, restoring the configured default.
|
|
212
|
+
* @param session - the session whose selection is removed.
|
|
213
|
+
* @returns resolution after durability.
|
|
214
|
+
*/
|
|
215
|
+
async turnOff(session: Session): Promise<void> {
|
|
216
|
+
await this.selection.delete(session.id)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Close the opened domain (the plugin fiber's async disposer).
|
|
221
|
+
* @returns resolution after the backend unit is released.
|
|
222
|
+
*/
|
|
223
|
+
async close(): Promise<void> {
|
|
224
|
+
await this.domain.close()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build the `style` projection's wire value for one folded state. */
|
|
229
|
+
function viewStyleSelection(runtime: OutputStyleRuntime, state: StyleFoldState): StyleSelectionView {
|
|
230
|
+
// The library-membership guard covers a settled selection whose style later
|
|
231
|
+
// left the library: the view degrades to "no selection" until the session
|
|
232
|
+
// switches again.
|
|
233
|
+
const currentValue = state.current !== null && runtime.styles.has(state.current) ? state.current : null
|
|
234
|
+
return {
|
|
235
|
+
options: [...runtime.styles.entries()].map(([value, style]) => ({
|
|
236
|
+
value,
|
|
237
|
+
name: style.name,
|
|
238
|
+
description: style.description,
|
|
239
|
+
...style.whenToUse === undefined ? {} : { whenToUse: style.whenToUse },
|
|
240
|
+
})),
|
|
241
|
+
currentValue,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Apply the plugin to its Cordis context.
|
|
247
|
+
*
|
|
248
|
+
* Declares `storageDomain` via `inject` (ready before `apply`; a composition
|
|
249
|
+
* without a routed kv backend keeps the plugin pending). Bad style files are
|
|
250
|
+
* skipped with warnings; a duplicate or reserved style name, an unreadable
|
|
251
|
+
* style directory, or a `defaultStyle` naming no style fails the load.
|
|
252
|
+
* @param ctx - scoped plugin context; registrations must be owned by its effects.
|
|
253
|
+
* @param config - configuration resolved by Cordis from the exported schema.
|
|
254
|
+
*/
|
|
255
|
+
export async function apply(ctx: Context, config: Config): Promise<void> {
|
|
256
|
+
// Defense for direct (non-Loader) callers: the declared inject makes this
|
|
257
|
+
// present in any composition, so the guard only reports programmer error.
|
|
258
|
+
const storageDomain = ctx.get('storageDomain') as DomainFacility | undefined
|
|
259
|
+
if (storageDomain === undefined) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
'dsh-output-styles: the "storageDomain" service declared by inject is unavailable — '
|
|
262
|
+
+ 'mount the storage facility (see README) or declare the inject',
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
const resolved = resolveConfig(config, DEFAULT_STYLES_DIR)
|
|
266
|
+
const styles = loadStyleLibrary(
|
|
267
|
+
resolved.stylesDirs,
|
|
268
|
+
{ compatJson: resolved.compatJson },
|
|
269
|
+
message => { ctx.logger.warn(`dsh-output-styles: ${message}`) },
|
|
270
|
+
)
|
|
271
|
+
if (resolved.defaultStyle !== '' && !styles.has(resolved.defaultStyle)) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`dsh-output-styles: defaultStyle "${resolved.defaultStyle}" names no style in ${resolved.stylesDirs.join(', ')} `
|
|
274
|
+
+ `(available: ${[...styles.keys()].join(', ') || 'none'})`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
const domain = await storageDomain.open(OUTPUT_STYLE_DOMAIN)
|
|
278
|
+
ctx.effect(() => () => domain.close())
|
|
279
|
+
const runtime = new OutputStyleRuntime(domain, styles, resolved)
|
|
280
|
+
|
|
281
|
+
// Style-file hot reload: watch every library directory and atomically swap
|
|
282
|
+
// the runtime library after a coalescing delay. A reload that would break
|
|
283
|
+
// the configured default or otherwise fail keeps the previous library.
|
|
284
|
+
if (resolved.watchStyles) {
|
|
285
|
+
let timer: NodeJS.Timeout | undefined
|
|
286
|
+
const reload = (): void => {
|
|
287
|
+
try {
|
|
288
|
+
const next = loadStyleLibrary(
|
|
289
|
+
resolved.stylesDirs,
|
|
290
|
+
{ compatJson: resolved.compatJson },
|
|
291
|
+
message => { ctx.logger.warn(`dsh-output-styles: ${message}`) },
|
|
292
|
+
)
|
|
293
|
+
if (resolved.defaultStyle !== '' && !next.has(resolved.defaultStyle)) {
|
|
294
|
+
ctx.logger.warn(`dsh-output-styles: style file change removed defaultStyle "${resolved.defaultStyle}"; keeping the previous library`)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
runtime.reload(next)
|
|
298
|
+
} catch (error) {
|
|
299
|
+
ctx.logger.warn(`dsh-output-styles: style file change not applied: ${error instanceof Error ? error.message : String(error)}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const schedule = (): void => {
|
|
303
|
+
if (timer !== undefined) return
|
|
304
|
+
timer = setTimeout(() => {
|
|
305
|
+
timer = undefined
|
|
306
|
+
reload()
|
|
307
|
+
}, WATCH_DEBOUNCE_MS)
|
|
308
|
+
}
|
|
309
|
+
ctx.effect(() => {
|
|
310
|
+
const watchers: FSWatcher[] = []
|
|
311
|
+
for (const dir of resolved.stylesDirs) {
|
|
312
|
+
try {
|
|
313
|
+
watchers.push(watch(dir, { persistent: false }, () => { schedule() }))
|
|
314
|
+
} catch (error) {
|
|
315
|
+
ctx.logger.warn(`dsh-output-styles: cannot watch style directory ${dir}: ${error instanceof Error ? error.message : String(error)}`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return () => {
|
|
319
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
320
|
+
for (const watcher of watchers) watcher.close()
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Project-level default over the settings seam: sessions that never
|
|
326
|
+
// selected a style fall back to `output-style.style` (user settings layer,
|
|
327
|
+
// then the composition defaultStyle). Stays inactive until a settings
|
|
328
|
+
// provider is composed; the settings namespace validates names against the
|
|
329
|
+
// live library at write time.
|
|
330
|
+
installSettingsSection(
|
|
331
|
+
ctx,
|
|
332
|
+
SETTINGS_NS,
|
|
333
|
+
z.object({ style: z.string().default('') }),
|
|
334
|
+
{ style: resolved.defaultStyle },
|
|
335
|
+
{
|
|
336
|
+
setSource: current => { runtime.setProjectDefault(() => current().style) },
|
|
337
|
+
onChange: () => {},
|
|
338
|
+
validate: value => {
|
|
339
|
+
if (value.style !== '' && !runtime.styles.has(value.style)) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`dsh-output-styles: settings outputStyle "${value.style}" names no style `
|
|
342
|
+
+ `(available: ${[...runtime.styles.keys()].join(', ') || 'none'})`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
ctx.systemPrompt.section({
|
|
350
|
+
name: STYLE_SECTION_NAME,
|
|
351
|
+
order: resolved.sectionOrder,
|
|
352
|
+
text: (context: AssembleContext) => {
|
|
353
|
+
const agent = context.agent
|
|
354
|
+
if (agent === undefined) return ''
|
|
355
|
+
const style = runtime.effectiveStyle(agent.session.id)
|
|
356
|
+
// A keep-coding-instructions: false style owns the whole prompt: the
|
|
357
|
+
// assembly waterfall below rebuilds the section list, so this section
|
|
358
|
+
// stays silent instead of double-injecting.
|
|
359
|
+
if (style === undefined || !style.keepCodingInstructions) return ''
|
|
360
|
+
return runtime.promptText(agent.session.id)
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// Claude Code keep-coding-instructions: false — the active style replaces
|
|
365
|
+
// the whole system prompt. The waterfall runs after downstream
|
|
366
|
+
// contributions resolve (tools, contexts, and variables still assemble),
|
|
367
|
+
// then swaps in a single style section.
|
|
368
|
+
ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context: AssembleContext, next) => {
|
|
369
|
+
const out = await next()
|
|
370
|
+
const agent = context.agent
|
|
371
|
+
if (agent === undefined) return out
|
|
372
|
+
const style = runtime.effectiveStyle(agent.session.id)
|
|
373
|
+
if (style === undefined || style.keepCodingInstructions) return out
|
|
374
|
+
return {
|
|
375
|
+
...out,
|
|
376
|
+
sections: [{ name: STYLE_SECTION_NAME, text: runtime.promptText(agent.session.id) }],
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// The /style command: the one write path a web client uses. The child
|
|
381
|
+
// activates only when a command registry is composed (headless assemblies
|
|
382
|
+
// without it stay unaffected). The registry logs `command/run` with the
|
|
383
|
+
// verbatim input, so every switch is reconstructable from the session log.
|
|
384
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
385
|
+
commandCtx.commands.register({
|
|
386
|
+
name: STYLE_COMMAND,
|
|
387
|
+
description: 'Switch the model output style for this session',
|
|
388
|
+
input: { hint: '<style | off>' },
|
|
389
|
+
handler: async ({ agent, rawInput }) => {
|
|
390
|
+
const input = parseStyleInput(rawInput)
|
|
391
|
+
if (input.kind === 'none') {
|
|
392
|
+
return { kind: 'success', text: runtime.listLine(agent.session.id) }
|
|
393
|
+
}
|
|
394
|
+
if (input.kind === 'off') {
|
|
395
|
+
await runtime.turnOff(agent.session)
|
|
396
|
+
const forced = runtime.forcedName
|
|
397
|
+
return {
|
|
398
|
+
kind: 'success',
|
|
399
|
+
text: forced === undefined
|
|
400
|
+
? 'output style off'
|
|
401
|
+
: `output style off (style "${forced}" remains in force)`,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (runtime.get(input.name) === undefined) {
|
|
405
|
+
return { kind: 'error', text: runtime.unknownStyleLine(input.name) }
|
|
406
|
+
}
|
|
407
|
+
await runtime.select(agent.session, input.name)
|
|
408
|
+
return { kind: 'success', text: `switched to ${input.name}` }
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// The `style` session projection: folds accepted `/style` switches off the
|
|
414
|
+
// session log so the Web UI can show the current style without reading the
|
|
415
|
+
// domain. Activates only when a projection registry is composed.
|
|
416
|
+
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
|
417
|
+
projectionCtx.sessionProjections.register<'style', StyleFoldState>({
|
|
418
|
+
key: 'style',
|
|
419
|
+
schema: styleSelectionViewSchema,
|
|
420
|
+
init: () => EMPTY_STYLE_STATE,
|
|
421
|
+
apply: applyStyleEvent,
|
|
422
|
+
view: state => viewStyleSelection(runtime, state),
|
|
423
|
+
stateVersion: 2,
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// The invariant companion, registered from the main plugin so its checks
|
|
428
|
+
// see the live library and domain. Activates only when an invariant
|
|
429
|
+
// registry is composed.
|
|
430
|
+
ctx.inject(['invariants'], (invariantCtx) => {
|
|
431
|
+
const registry = invariantCtx.get('invariants') as InvariantRegistry | undefined
|
|
432
|
+
if (registry === undefined) return
|
|
433
|
+
const facts: InvariantFacts = {
|
|
434
|
+
knownStyles: () => new Set(runtime.names),
|
|
435
|
+
selectionFor: sessionId => runtime.selectionFor(sessionId),
|
|
436
|
+
}
|
|
437
|
+
registry.register(PACKAGE_NAME, installInvariant(facts))
|
|
438
|
+
})
|
|
439
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict parsing of the `/style` command input and the pure projection fold
|
|
3
|
+
* over its logged lifecycle events.
|
|
4
|
+
*
|
|
5
|
+
* The handler and the session-projection unit share {@link parseStyleInput}:
|
|
6
|
+
* the projection folds exactly the inputs the handler accepts, so the
|
|
7
|
+
* displayed selection can never diverge from what the command did.
|
|
8
|
+
* @module dsh-output-styles/style-command
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
13
|
+
import { OFF } from './types.ts'
|
|
14
|
+
|
|
15
|
+
/** Command name registered on `ctx.commands`; also the log's `command/run` name. */
|
|
16
|
+
export const STYLE_COMMAND = 'style'
|
|
17
|
+
|
|
18
|
+
/** One strict parse of a `/style` input line. */
|
|
19
|
+
export type StyleInput =
|
|
20
|
+
| { kind: 'none' }
|
|
21
|
+
| { kind: 'off' }
|
|
22
|
+
| { kind: 'switch'; name: string }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse the text after `/style` into one switch decision. The empty string
|
|
26
|
+
* is `none` — the handler treats it as the listing form, and the projection
|
|
27
|
+
* fold ignores it. Anything else is a switch: `off` restores the default,
|
|
28
|
+
* and every other input is one style name taken verbatim (style names may
|
|
29
|
+
* contain spaces, so the whole remainder is the candidate). Unknown names
|
|
30
|
+
* are rejected by the handler against the library, and the fold commits only
|
|
31
|
+
* what the handler reports as successful, so both sides agree on what
|
|
32
|
+
* actually switched.
|
|
33
|
+
* @param rawInput - verbatim text after the command name.
|
|
34
|
+
* @returns the decision; non-empty inputs other than `off` name a style.
|
|
35
|
+
*/
|
|
36
|
+
export function parseStyleInput(rawInput: string): StyleInput {
|
|
37
|
+
const arg = rawInput.trim()
|
|
38
|
+
if (arg === '') return { kind: 'none' }
|
|
39
|
+
if (arg === OFF) return { kind: 'off' }
|
|
40
|
+
return { kind: 'switch', name: arg }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Projection-fold state: the last settled selection plus the in-flight switch. */
|
|
44
|
+
export interface StyleFoldState {
|
|
45
|
+
/** Accepted switch target that settled successfully, or null when the session's style was turned off. */
|
|
46
|
+
current: string | null
|
|
47
|
+
/**
|
|
48
|
+
* One `/style` run that entered its handler but has not settled yet: its
|
|
49
|
+
* `command/run` claimed this slot and only its paired `command/done`
|
|
50
|
+
* resolves it. A failed or aborted run drops the target without touching
|
|
51
|
+
* `current`, so the folded state always mirrors what the write path actually
|
|
52
|
+
* committed.
|
|
53
|
+
*/
|
|
54
|
+
pending: { commandId: string; target: { name: string } | { off: true } } | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** State for the empty log. */
|
|
58
|
+
export const EMPTY_STYLE_STATE: StyleFoldState = { current: null, pending: null }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One-event transition of the `style` projection unit. Only a successful
|
|
62
|
+
* `/style` command settles into `current`: `command/run` parks the target as
|
|
63
|
+
* `pending` and its paired `command/done` commits it on `kind: 'success'` or
|
|
64
|
+
* drops it otherwise. Every other event returns the same reference (the
|
|
65
|
+
* registry's change gate).
|
|
66
|
+
* @param state - the folded state before `event`.
|
|
67
|
+
* @param event - one committed session event.
|
|
68
|
+
* @returns the next state; the same reference when the event leaves it unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export function applyStyleEvent(state: StyleFoldState, event: SessionEvent): StyleFoldState {
|
|
71
|
+
if (event.type === 'command/run') {
|
|
72
|
+
if (event.data.name !== STYLE_COMMAND || event.data.args === undefined) return state
|
|
73
|
+
const input = parseStyleInput(event.data.args)
|
|
74
|
+
if (input.kind === 'none') return state
|
|
75
|
+
const commandId = String(event.data.commandId)
|
|
76
|
+
if (state.pending?.commandId === commandId) return state
|
|
77
|
+
const target: { name: string } | { off: true } = input.kind === 'off' ? { off: true } : { name: input.name }
|
|
78
|
+
return { ...state, pending: { commandId, target } }
|
|
79
|
+
}
|
|
80
|
+
if (event.type !== 'command/done' || state.pending === null) return state
|
|
81
|
+
if (state.pending.commandId !== String(event.data.commandId)) return state
|
|
82
|
+
if (event.data.kind !== 'success') {
|
|
83
|
+
// The handler reported failure or was aborted: the selection record was
|
|
84
|
+
// not written (or the off-delete did not land), so current stays put.
|
|
85
|
+
return { current: state.current, pending: null }
|
|
86
|
+
}
|
|
87
|
+
const current = 'off' in state.pending.target ? null : state.pending.target.name
|
|
88
|
+
return { current, pending: null }
|
|
89
|
+
}
|