dsh-output-styles 0.4.3 → 0.6.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/src/runtime.ts CHANGED
@@ -20,12 +20,13 @@ import type { Domain, DomainFacility, KvTable } from '@deepseek-ai/dsh-storage-d
20
20
  import z from '@deepseek-ai/schemastery'
21
21
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
22
22
  import { resolveConfig, type Config } from './config.ts'
23
+ import { detectCoreOutputStyles } from './coexist.ts'
23
24
  import { installInvariant, PACKAGE_NAME, type InvariantFacts, type InvariantRegistry } from './invariant.ts'
24
25
  import { loadStyleLibrary, truncateStyle, type OutputStyle } from './style-library.ts'
25
26
  import { applyStyleEvent, EMPTY_STYLE_STATE, parseStyleInput, STYLE_COMMAND, type StyleFoldState } from './style-command.ts'
26
27
  import { OUTPUT_STYLE_DOMAIN, STYLE_SOURCE, styleFoldStateSchema, styleSelectionViewSchema, type StyleSelection, type StyleSelectionView } from './types.ts'
27
28
  import { BUILTIN_RENDERERS, RendererRegistry, type OutputRenderer, type RenderContext, type RenderedText, type StyleRule } from './renderers.ts'
28
- import { conversationLines, renderExport } from './export.ts'
29
+ import { conversationLines, renderExport, sanitizeText } from './export.ts'
29
30
 
30
31
  /** Bundled style-library directory (package `styles/`), the lowest-priority `stylesDir` entry. */
31
32
  export const DEFAULT_STYLES_DIR = fileURLToPath(new URL('../styles/', import.meta.url))
@@ -265,6 +266,14 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
265
266
  )
266
267
  }
267
268
  const resolved = resolveConfig(config, DEFAULT_STYLES_DIR)
269
+ // Coexistence with a core outputStyles capability: when it is composed (and
270
+ // respectCoreOutputStyles is true), prompt injection belongs to the core —
271
+ // this plugin keeps hot-switch / rules / export and skips the two injections
272
+ // below to avoid double-injecting a style directive.
273
+ const coreActive = resolved.respectCoreOutputStyles && detectCoreOutputStyles(ctx)
274
+ if (coreActive) {
275
+ ctx.logger.info('dsh-output-styles: core outputStyles detected — degrading to hot-switch + rules + export (no prompt injection)')
276
+ }
268
277
  const styles = loadStyleLibrary(
269
278
  resolved.stylesDirs,
270
279
  { compatJson: resolved.compatJson },
@@ -348,36 +357,38 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
348
357
  },
349
358
  )
350
359
 
351
- ctx.systemPrompt.section({
352
- name: STYLE_SECTION_NAME,
353
- order: resolved.sectionOrder,
354
- text: (context: AssembleContext) => {
360
+ if (!coreActive) {
361
+ ctx.systemPrompt.section({
362
+ name: STYLE_SECTION_NAME,
363
+ order: resolved.sectionOrder,
364
+ text: (context: AssembleContext) => {
365
+ const agent = context.agent
366
+ if (agent === undefined) return ''
367
+ const style = runtime.effectiveStyle(agent.session.id)
368
+ // A keep-coding-instructions: false style owns the whole prompt: the
369
+ // assembly waterfall below rebuilds the section list, so this section
370
+ // stays silent instead of double-injecting.
371
+ if (style === undefined || !style.keepCodingInstructions) return ''
372
+ return runtime.promptText(agent.session.id)
373
+ },
374
+ })
375
+
376
+ // Claude Code keep-coding-instructions: false — the active style replaces
377
+ // the whole system prompt. The waterfall runs after downstream
378
+ // contributions resolve (tools, contexts, and variables still assemble),
379
+ // then swaps in a single style section.
380
+ ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context: AssembleContext, next) => {
381
+ const out = await next()
355
382
  const agent = context.agent
356
- if (agent === undefined) return ''
383
+ if (agent === undefined) return out
357
384
  const style = runtime.effectiveStyle(agent.session.id)
358
- // A keep-coding-instructions: false style owns the whole prompt: the
359
- // assembly waterfall below rebuilds the section list, so this section
360
- // stays silent instead of double-injecting.
361
- if (style === undefined || !style.keepCodingInstructions) return ''
362
- return runtime.promptText(agent.session.id)
363
- },
364
- })
365
-
366
- // Claude Code keep-coding-instructions: false — the active style replaces
367
- // the whole system prompt. The waterfall runs after downstream
368
- // contributions resolve (tools, contexts, and variables still assemble),
369
- // then swaps in a single style section.
370
- ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context: AssembleContext, next) => {
371
- const out = await next()
372
- const agent = context.agent
373
- if (agent === undefined) return out
374
- const style = runtime.effectiveStyle(agent.session.id)
375
- if (style === undefined || style.keepCodingInstructions) return out
376
- return {
377
- ...out,
378
- sections: [{ name: STYLE_SECTION_NAME, text: runtime.promptText(agent.session.id) }],
379
- }
380
- })
385
+ if (style === undefined || style.keepCodingInstructions) return out
386
+ return {
387
+ ...out,
388
+ sections: [{ name: STYLE_SECTION_NAME, text: runtime.promptText(agent.session.id) }],
389
+ }
390
+ })
391
+ }
381
392
 
382
393
  // The /style command: the one write path a web client uses. The child
383
394
  // activates only when a command registry is composed (headless assemblies
@@ -507,23 +518,41 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
507
518
  // Markdown or sanitized HTML through the renderer pipeline. The document
508
519
  // itself is the visible artifact; the original lines are the session log
509
520
  // the export was projected from — rendered and original stay reconstructable.
521
+ // `--save <path>` additionally writes the sanitized document to that
522
+ // workspace path, gated by the approval service and the fs service (both
523
+ // optional; a missing approval service denies the write, a missing fs
524
+ // service fails loudly).
510
525
  if (resolved.enableExport) {
511
526
  ctx.inject(['commands'], (commandCtx) => {
512
527
  commandCtx.commands.register({
513
528
  name: 'export',
514
529
  description: 'Export this session as Markdown or HTML (renderer-aware)',
515
- input: { hint: '[markdown|html] [--renderer=<id>]' },
516
- handler: async ({ agent, rawInput }) => {
530
+ input: { hint: '[markdown|html] [--renderer=<id>] [--save <path>]' },
531
+ handler: async ({ agent, rawInput, signal }) => {
517
532
  const input = parseExportInput(rawInput)
518
533
  if (input.kind === 'error') {
519
- return { kind: 'error', text: 'usage: /export [markdown|html] [--renderer=<id>]' }
534
+ return { kind: 'error', text: 'usage: /export [markdown|html] [--renderer=<id>] [--save <path>]' }
520
535
  }
521
536
  const lines = conversationLines(agent.session.events)
522
537
  const rules: StyleRule[] = input.renderer === undefined
523
538
  ? [...effectiveRules]
524
539
  : [{ match: {}, style: input.renderer, priority: 0 }]
525
540
  const document = renderExport(renderers, lines, input.format, rules)
526
- return { kind: 'success', text: document.text }
541
+ if (input.save === undefined) {
542
+ return { kind: 'success', text: document.text }
543
+ }
544
+ // The disk path sanitizes the rendered document before writing and
545
+ // writes only after the approval service grants it.
546
+ const saved = await saveExportFile(
547
+ ctx.get('fs') as ExportFileSystem | undefined,
548
+ ctx.get('approval') as ExportApproval | undefined,
549
+ agent,
550
+ input.save,
551
+ sanitizeText(document.text),
552
+ signal,
553
+ )
554
+ if (saved.kind === 'error') return { kind: 'error', text: saved.text }
555
+ return { kind: 'success', text: `saved ${input.format} export to ${saved.path}` }
527
556
  },
528
557
  })
529
558
  })
@@ -531,25 +560,151 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
531
560
  }
532
561
 
533
562
  /** Parsed `/export` invocation. */
534
- type ExportInput = { kind: 'ok'; format: 'markdown' | 'html'; renderer?: string } | { kind: 'error' }
535
-
536
- /** Parse `/export [markdown|html] [--renderer=<id>]` from the raw command input. */
563
+ type ExportInput = {
564
+ kind: 'ok'
565
+ format: 'markdown' | 'html'
566
+ renderer?: string
567
+ save?: string
568
+ } | { kind: 'error' }
569
+
570
+ /** Parse `/export [markdown|html] [--renderer=<id>] [--save <path>]` from the raw command input. */
537
571
  export function parseExportInput(rawInput: unknown): ExportInput {
538
572
  const raw = String(rawInput ?? '').trim()
539
573
  const parts = raw === '' ? [] : raw.split(/\s+/)
540
574
  let format: 'markdown' | 'html' = 'markdown'
541
575
  let renderer: string | undefined
542
- for (const part of parts) {
543
- if (part === 'markdown' || part === 'html') {
544
- format = part
576
+ let save: string | undefined
577
+ for (let index = 0; index < parts.length; index += 1) {
578
+ const part = parts[index]
579
+ if (part === undefined) continue
580
+ if (part === 'markdown' || part === 'md') {
581
+ format = 'markdown'
582
+ continue
583
+ }
584
+ if (part === 'html') {
585
+ format = 'html'
545
586
  continue
546
587
  }
547
- const match = /^--renderer=([a-z0-9][a-z0-9-]*)$/.exec(part)
548
- if (match !== null) {
549
- renderer = match[1]
588
+ const rendererMatch = /^--renderer=([a-z0-9][a-z0-9-]*)$/.exec(part)
589
+ if (rendererMatch !== null) {
590
+ renderer = rendererMatch[1]
591
+ continue
592
+ }
593
+ const saveInline = /^--save=(.+)$/.exec(part)
594
+ if (saveInline !== null) {
595
+ save = saveInline[1]
596
+ continue
597
+ }
598
+ if (part === '--save') {
599
+ const next = parts[index + 1]
600
+ if (next === undefined || next === '') return { kind: 'error' }
601
+ save = next
602
+ index += 1
550
603
  continue
551
604
  }
552
605
  return { kind: 'error' }
553
606
  }
554
- return { kind: 'ok', format, ...renderer === undefined ? {} : { renderer } }
607
+ return {
608
+ kind: 'ok',
609
+ format,
610
+ ...renderer === undefined ? {} : { renderer },
611
+ ...save === undefined ? {} : { save },
612
+ }
613
+ }
614
+
615
+ /** Approval outcome vocabulary, structural (mirrors the approval seam without importing it). */
616
+ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
617
+
618
+ /** Structural slice of the DSH filesystem service the save path uses. */
619
+ export interface ExportFileSystem {
620
+ resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<unknown>
621
+ writeText(target: unknown, content: string): Promise<unknown>
622
+ }
623
+
624
+ /** Structural slice of the DSH approval service the save path uses. */
625
+ export interface ExportApproval {
626
+ request(request: { agent: unknown; toolName: string; reason: string; signal?: AbortSignal }): Promise<ApprovalOutcome>
627
+ }
628
+
629
+ /** Error codes of the `/export --save` path; each is a stable machine-readable label. */
630
+ export type ExportSaveErrorCode = 'fs-unavailable' | 'approval-unavailable' | 'approval-denied' | 'approval-cancelled'
631
+
632
+ /** Result of a `/export --save` attempt: the written path or a structured failure. */
633
+ export type ExportSaveResult =
634
+ | { readonly kind: 'written'; readonly path: string }
635
+ | { readonly kind: 'error'; readonly code: ExportSaveErrorCode; readonly text: string }
636
+
637
+ /**
638
+ * Write one rendered export document to a workspace path after user approval.
639
+ * Fail-closed: a missing approval service, a rejected/cancelled/unavailable
640
+ * decision, or a throwing approval channel all deny the write; a missing fs
641
+ * service fails loudly with a structured error. The caller already sanitized
642
+ * `content` before this write.
643
+ * @param fs - the fs service (`ctx.get('fs')`), or undefined when none is composed.
644
+ * @param approval - the approval service (`ctx.get('approval')`), or undefined when none is composed.
645
+ * @param agent - the agent whose session the export belongs to (routes the approval).
646
+ * @param path - the workspace path to write.
647
+ * @param content - the sanitized document text.
648
+ * @param signal - the command's abort signal, forwarded to approval and resolve.
649
+ * @returns the written path, or a structured failure.
650
+ */
651
+ export async function saveExportFile(
652
+ fs: ExportFileSystem | undefined,
653
+ approval: ExportApproval | undefined,
654
+ agent: unknown,
655
+ path: string,
656
+ content: string,
657
+ signal?: AbortSignal,
658
+ ): Promise<ExportSaveResult> {
659
+ if (approval === undefined) {
660
+ return {
661
+ kind: 'error',
662
+ code: 'approval-unavailable',
663
+ text: 'dsh-output-styles: /export --save requires an approval service (compose @deepseek-ai/dsh-user-approval); nothing was written',
664
+ }
665
+ }
666
+ let outcome: ApprovalOutcome
667
+ try {
668
+ outcome = await approval.request({
669
+ agent,
670
+ toolName: 'export',
671
+ reason: `write the exported document to ${path}`,
672
+ ...signal === undefined ? {} : { signal },
673
+ })
674
+ } catch {
675
+ // An approval channel that cannot answer is an unanswerable ask: fail closed.
676
+ outcome = 'unavailable'
677
+ }
678
+ switch (outcome) {
679
+ case 'allowed-once': break
680
+ case 'rejected':
681
+ return {
682
+ kind: 'error',
683
+ code: 'approval-denied',
684
+ text: 'dsh-output-styles: /export --save was rejected; nothing was written',
685
+ }
686
+ case 'cancelled':
687
+ return {
688
+ kind: 'error',
689
+ code: 'approval-cancelled',
690
+ text: 'dsh-output-styles: /export --save was cancelled; nothing was written',
691
+ }
692
+ default:
693
+ // 'unavailable' plus any out-of-vocabulary answer fail closed (never write).
694
+ return {
695
+ kind: 'error',
696
+ code: 'approval-unavailable',
697
+ text: 'dsh-output-styles: /export --save approval is unavailable; nothing was written',
698
+ }
699
+ }
700
+ if (fs === undefined) {
701
+ return {
702
+ kind: 'error',
703
+ code: 'fs-unavailable',
704
+ text: 'dsh-output-styles: /export --save requires an fs service (compose @deepseek-ai/dsh-fs); nothing was written',
705
+ }
706
+ }
707
+ const target = await fs.resolve(path, signal === undefined ? undefined : { signal })
708
+ await fs.writeText(target, content)
709
+ return { kind: 'written', path }
555
710
  }