dsh-output-styles 0.3.2 → 0.4.1

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,264 @@
1
+ /**
2
+ * The `output.render.*` protocol: a presenter registry that turns raw
3
+ * model-visible text into display text. A renderer is a pure function —
4
+ * `presenter(text, meta)` maps args to presentation data and never touches
5
+ * the DOM — matched by tool name and content type, ordered by priority.
6
+ * Every rendered result carries its original text alongside, so any consumer
7
+ * (this plugin's `/export`, third-party panels) can log both and keep the
8
+ * "model-visible ⟺ reconstructable" invariant.
9
+ *
10
+ * This module is dependency-free (no DOM, no node: imports, no DSH imports)
11
+ * so the protocol vocabulary itself is portable and unit-testable anywhere.
12
+ * @module dsh-output-styles/renderers
13
+ */
14
+
15
+ /** Content-type vocabulary a renderer can match on. */
16
+ export type ContentType = 'text' | 'markdown' | 'html'
17
+
18
+ /** Match rule deciding whether a renderer applies to one render request. */
19
+ export interface RendererMatch {
20
+ /** Tool names the renderer applies to ('*' = every tool); omitted = match all. */
21
+ readonly tool?: string | readonly string[]
22
+ /** Content types the renderer applies to; omitted = match all. */
23
+ readonly contentType?: ContentType | readonly ContentType[]
24
+ }
25
+
26
+ /** Facts a render request carries (structural; the DOM never reaches this layer). */
27
+ export interface RenderContext {
28
+ /** The tool that produced the text, or '' for assistant/user prose. */
29
+ readonly tool: string
30
+ /** The declared content type of the text ('text' when unknown). */
31
+ readonly contentType: ContentType
32
+ /** The session the text belongs to (per-session rules match on it). */
33
+ readonly sessionId?: string
34
+ /** Session-scoped extras a presenter may read (workspace title etc.). */
35
+ readonly meta?: Readonly<Record<string, string>>
36
+ }
37
+
38
+ /** The renderer contract a third-party plugin registers. */
39
+ export interface OutputRenderer {
40
+ /** Unique renderer id (kebab-case); the rule field and `/export --renderer` name it. */
41
+ readonly id: string
42
+ /** Human-readable name. */
43
+ readonly name: string
44
+ /** One sentence on what the presenter does. */
45
+ readonly description: string
46
+ /** Applicability rules; an empty array matches everything. */
47
+ readonly match: readonly RendererMatch[]
48
+ /** Higher priority wins; ties break by registration order (earlier first). */
49
+ readonly priority: number
50
+ /** Pure presentation function: args in, display data out. */
51
+ readonly presenter: (text: string, context: RenderContext) => string
52
+ }
53
+
54
+ /** The auditable render result: original and rendered travel together. */
55
+ export interface RenderedText {
56
+ /** The original model-visible text (never mutated). */
57
+ readonly original: string
58
+ /** The presented text (equal to the original when nothing matched). */
59
+ readonly rendered: string
60
+ /** The renderer that applied, or undefined when no renderer matched. */
61
+ readonly rendererId?: string
62
+ /** Whether the presentation changed the text. */
63
+ readonly changed: boolean
64
+ }
65
+
66
+ /** One per-session/per-tool style rule from the configuration. */
67
+ export interface StyleRule {
68
+ /** Match facts; an empty object matches everything. */
69
+ readonly match: {
70
+ /** Tool name or '*' (omitted = any tool). */
71
+ readonly tool?: string
72
+ /** Content type (omitted = any). */
73
+ readonly contentType?: ContentType
74
+ /** Exact session id (omitted = any session) — the per-session axis. */
75
+ readonly session?: string
76
+ }
77
+ /** Renderer id to apply (built-ins mirror the style names: concise, step-by-step). */
78
+ readonly style: string
79
+ /** Higher priority wins; ties break by rule order (earlier first). */
80
+ readonly priority: number
81
+ }
82
+
83
+ /** Registry handle returned by register(). */
84
+ export type RendererDisposer = () => void
85
+
86
+ /**
87
+ * Validates a renderer before registration: id grammar, name, description,
88
+ * match shape, priority, presenter function. Throws on the first violation
89
+ * (fail-loud — a bad renderer never sits in the registry).
90
+ * @param renderer - candidate renderer.
91
+ */
92
+ export function validateRenderer(renderer: OutputRenderer): void {
93
+ if (typeof renderer !== 'object' || renderer === null) throw new Error('renderer must be an object')
94
+ if (typeof renderer.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(renderer.id)) {
95
+ throw new Error(`invalid renderer id ${JSON.stringify(renderer.id)}: use kebab-case`)
96
+ }
97
+ if (typeof renderer.name !== 'string' || renderer.name === '') throw new Error('renderer name must be a non-empty string')
98
+ if (typeof renderer.description !== 'string' || renderer.description === '') throw new Error('renderer description must be a non-empty string')
99
+ if (!Array.isArray(renderer.match)) throw new Error('renderer match must be an array')
100
+ for (const match of renderer.match) {
101
+ if (match.tool !== undefined) {
102
+ const tools = Array.isArray(match.tool) ? match.tool : [match.tool]
103
+ if (!tools.every((item: unknown) => typeof item === 'string')) {
104
+ throw new Error('renderer match.tool must be a string or string array')
105
+ }
106
+ }
107
+ if (match.contentType !== undefined) {
108
+ const types = Array.isArray(match.contentType) ? match.contentType : [match.contentType]
109
+ if (!types.every((item: unknown) => typeof item === 'string')) {
110
+ throw new Error('renderer match.contentType must be a string or string array')
111
+ }
112
+ }
113
+ }
114
+ if (typeof renderer.priority !== 'number' || !Number.isFinite(renderer.priority)) {
115
+ throw new Error('renderer priority must be a finite number')
116
+ }
117
+ if (typeof renderer.presenter !== 'function') throw new Error('renderer presenter must be a function')
118
+ }
119
+
120
+ /** Match one rule fact (a string, a string list, or undefined=any) against a value. */
121
+ function factMatches(fact: string | readonly string[] | undefined, value: string): boolean {
122
+ if (fact === undefined) return true
123
+ if (typeof fact === 'string') return fact === '*' || fact === value
124
+ return fact.includes('*') || fact.includes(value)
125
+ }
126
+
127
+ /** Whether a renderer matches a render request. */
128
+ function rendererMatches(renderer: OutputRenderer, context: RenderContext): boolean {
129
+ if (renderer.match.length === 0) return true
130
+ return renderer.match.some(match =>
131
+ factMatches(match.tool, context.tool)
132
+ && factMatches(match.contentType, context.contentType))
133
+ }
134
+
135
+ /** Whether a configured rule matches a render request. */
136
+ function ruleMatches(rule: StyleRule, context: RenderContext): boolean {
137
+ if (rule.match.tool !== undefined && rule.match.tool !== '*' && rule.match.tool !== context.tool) return false
138
+ if (rule.match.contentType !== undefined && rule.match.contentType !== context.contentType) return false
139
+ if (rule.match.session !== undefined && rule.match.session !== context.sessionId) return false
140
+ return true
141
+ }
142
+
143
+ /**
144
+ * The renderer registry: reversible registration, ordered resolution, and
145
+ * rule-driven rendering. Registration is a caller-owned effect (the runtime
146
+ * hands register()'s disposer to ctx.effect); the registry itself is pure
147
+ * state with no timers, listeners, or I/O.
148
+ */
149
+ export class RendererRegistry {
150
+ private readonly renderers = new Map<string, { renderer: OutputRenderer; order: number }>()
151
+ private order = 0
152
+
153
+ /** Register a renderer; the disposer removes exactly this registration. */
154
+ register(renderer: OutputRenderer): RendererDisposer {
155
+ validateRenderer(renderer)
156
+ if (this.renderers.has(renderer.id)) {
157
+ throw new Error(`renderer ${JSON.stringify(renderer.id)} is already registered`)
158
+ }
159
+ const order = this.order++
160
+ this.renderers.set(renderer.id, { renderer, order })
161
+ return () => {
162
+ if (this.renderers.get(renderer.id)?.order === order) this.renderers.delete(renderer.id)
163
+ }
164
+ }
165
+
166
+ /** Every registered renderer, deterministic order (priority desc, registration asc). */
167
+ list(): OutputRenderer[] {
168
+ return [...this.renderers.values()]
169
+ .sort((a, b) => b.renderer.priority - a.renderer.priority || a.order - b.order)
170
+ .map(entry => entry.renderer)
171
+ }
172
+
173
+ /** Resolve the renderers that match a request, highest priority first. */
174
+ resolve(context: RenderContext): OutputRenderer[] {
175
+ return this.list().filter(renderer => rendererMatches(renderer, context))
176
+ }
177
+
178
+ /**
179
+ * Render text through the rule table, then the matching renderer pipeline:
180
+ * the first matching rule names a renderer (applied alone, it is explicit),
181
+ * otherwise every matching renderer applies in priority order (each sees the
182
+ * previous renderer's output — composition, not competition).
183
+ * @param text - the raw model-visible text.
184
+ * @param context - tool / content-type / session facts.
185
+ * @param rules - configured style rules, highest priority first.
186
+ * @returns the auditable result (original always preserved).
187
+ */
188
+ render(text: string, context: RenderContext, rules: readonly StyleRule[] = []): RenderedText {
189
+ const sortedRules = [...rules].sort((a, b) => b.priority - a.priority)
190
+ const hit = sortedRules.find(rule => ruleMatches(rule, context))
191
+ if (hit !== undefined) {
192
+ const renderer = this.renderers.get(hit.style)
193
+ if (renderer === undefined) {
194
+ throw new Error(`rule style ${JSON.stringify(hit.style)} names no registered renderer (available: ${this.list().map(item => item.id).join(', ') || 'none'})`)
195
+ }
196
+ const rendered = renderer.renderer.presenter(text, context)
197
+ return { original: text, rendered, rendererId: renderer.renderer.id, changed: rendered !== text }
198
+ }
199
+ let rendered = text
200
+ let rendererId: string | undefined
201
+ for (const renderer of this.resolve(context)) {
202
+ rendered = renderer.presenter(rendered, context)
203
+ rendererId = renderer.id
204
+ }
205
+ return { original: text, rendered, ...rendererId === undefined ? {} : { rendererId }, changed: rendered !== text }
206
+ }
207
+ }
208
+
209
+ /** Collapse whitespace runs and blank-line stacks; trim the ends. */
210
+ function compact(text: string, maxLines: number, maxChars: number, marker: string): string {
211
+ const collapsed = text
212
+ .split(/\r?\n/)
213
+ .map(line => line.replace(/[ \t]+/g, ' ').trimEnd())
214
+ .reduce((lines: string[], line) => {
215
+ if (line === '' && lines[lines.length - 1] === '') return lines
216
+ lines.push(line)
217
+ return lines
218
+ }, [])
219
+ .join('\n')
220
+ .trim()
221
+ let out = maxLines > 0 && collapsed.split('\n').length > maxLines
222
+ ? collapsed.split('\n').slice(0, maxLines).join('\n') + marker
223
+ : collapsed
224
+ if (maxChars > 0 && out.length > maxChars) out = out.slice(0, maxChars) + marker
225
+ return out
226
+ }
227
+
228
+ /** Turn a list-shaped text into consistently numbered steps. */
229
+ function enumerate(text: string): string {
230
+ const lines = text.split(/\r?\n/)
231
+ const items = lines.filter(line => /^\s*(?:[-*•]|\d+[.)])\s+/.test(line))
232
+ if (items.length === 0) return text
233
+ let counter = 0
234
+ return lines.map((line) => {
235
+ if (!/^\s*(?:[-*•]|\d+[.)])\s+/.test(line)) return line
236
+ counter += 1
237
+ return line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, `${counter}. `)
238
+ }).join('\n')
239
+ }
240
+
241
+ /**
242
+ * The built-in style renderers, mirroring the two headline styles: `concise`
243
+ * compacts whitespace under a line/char budget, `step-by-step` numbers list
244
+ * items consistently. Their ids double as `/style`-compatible names in the
245
+ * rule table.
246
+ */
247
+ export const BUILTIN_RENDERERS: readonly OutputRenderer[] = [
248
+ {
249
+ id: 'concise',
250
+ name: 'Concise',
251
+ description: 'Compacts whitespace runs and blank lines, and caps the presented text at a budget with a truncation marker.',
252
+ match: [],
253
+ priority: 10,
254
+ presenter: text => compact(text, 40, 4000, '\n\n[truncated]'),
255
+ },
256
+ {
257
+ id: 'step-by-step',
258
+ name: 'Step-by-step',
259
+ description: 'Numbers list items (dashes, bullets, or digits) consistently from 1 so the presentation reads as ordered steps.',
260
+ match: [],
261
+ priority: 10,
262
+ presenter: text => enumerate(text),
263
+ },
264
+ ]
package/src/runtime.ts CHANGED
@@ -24,6 +24,8 @@ import { installInvariant, PACKAGE_NAME, type InvariantFacts, type InvariantRegi
24
24
  import { loadStyleLibrary, truncateStyle, type OutputStyle } from './style-library.ts'
25
25
  import { applyStyleEvent, EMPTY_STYLE_STATE, parseStyleInput, STYLE_COMMAND, type StyleFoldState } from './style-command.ts'
26
26
  import { OUTPUT_STYLE_DOMAIN, STYLE_SOURCE, styleSelectionViewSchema, type StyleSelection, type StyleSelectionView } from './types.ts'
27
+ import { BUILTIN_RENDERERS, RendererRegistry, type OutputRenderer, type RenderContext, type RenderedText, type StyleRule } from './renderers.ts'
28
+ import { conversationLines, renderExport } from './export.ts'
27
29
 
28
30
  /** Bundled style-library directory (package `styles/`), the lowest-priority `stylesDir` entry. */
29
31
  export const DEFAULT_STYLES_DIR = fileURLToPath(new URL('../styles/', import.meta.url))
@@ -426,7 +428,10 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
426
428
 
427
429
  // The invariant companion, registered from the main plugin so its checks
428
430
  // see the live library and domain. Activates only when an invariant
429
- // registry is composed.
431
+ // registry is composed. The registry binds its internal effect to the
432
+ // service context and throws on a duplicate package name, so the returned
433
+ // disposer is the only unregistration path: hold it on this inject scope's
434
+ // fiber.
430
435
  ctx.inject(['invariants'], (invariantCtx) => {
431
436
  const registry = invariantCtx.get('invariants') as InvariantRegistry | undefined
432
437
  if (registry === undefined) return
@@ -434,6 +439,114 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
434
439
  knownStyles: () => new Set(runtime.names),
435
440
  selectionFor: sessionId => runtime.selectionFor(sessionId),
436
441
  }
437
- registry.register(PACKAGE_NAME, installInvariant(facts))
442
+ invariantCtx.effect(() => registry.register(PACKAGE_NAME, installInvariant(facts)), 'dsh-output-styles: invariant companion')
438
443
  })
444
+
445
+ // ── output.render.* protocol: the renderer registry service ───────────────
446
+ // Third-party plugins register presenters (id / match rules / pure
447
+ // presenter / priority) through `ctx.outputRenderers`; registration is a
448
+ // caller-owned effect (register() returns the disposer). The built-in
449
+ // concise/step-by-step renderers mirror the two headline styles. Rendering
450
+ // runs the `output.render/before` waterfall first — listeners transform the
451
+ // request and MUST call next() — then applies the rule table and matching
452
+ // renderers; every result keeps the original text beside the rendered one.
453
+ const renderers = new RendererRegistry()
454
+ for (const renderer of BUILTIN_RENDERERS) {
455
+ ctx.effect(() => renderers.register(renderer), `dsh-output-styles: renderer ${renderer.id}`)
456
+ }
457
+ let effectiveRules: readonly StyleRule[] = resolved.rules
458
+ const renderText = (text: string, context: RenderContext): Promise<RenderedText> =>
459
+ ctx.waterfall('output.render/before', { text, context }, async (request: { text: string; context: RenderContext }) =>
460
+ renderers.render(request.text, request.context, effectiveRules))
461
+ const renderService = {
462
+ register: (renderer: OutputRenderer) => renderers.register(renderer),
463
+ list: () => renderers.list(),
464
+ resolve: (context: RenderContext) => renderers.resolve(context),
465
+ renderText,
466
+ }
467
+ ctx.provide('outputRenderers', renderService)
468
+
469
+ // Per-session/per-tool rules over the settings seam: the `output-style-rules`
470
+ // namespace carries the rule table (composition `base` + user overrides);
471
+ // rules referencing an unknown renderer fail at write time, and rendering
472
+ // fails loudly at call time if a renderer left the registry.
473
+ installSettingsSection(
474
+ ctx,
475
+ settingsNamespace('output-style-rules'),
476
+ z.object({
477
+ rules: z.array(z.object({
478
+ match: z.object({
479
+ tool: z.string().required(false),
480
+ contentType: z.union([z.const('text'), z.const('markdown'), z.const('html')]).required(false),
481
+ session: z.string().required(false),
482
+ }).required(false),
483
+ style: z.string().min(1),
484
+ priority: z.number().required(false),
485
+ })).default([]),
486
+ }),
487
+ { rules: resolved.rules },
488
+ {
489
+ setSource: current => {
490
+ effectiveRules = current().rules.map(rule => ({ match: rule.match ?? {}, style: rule.style, priority: rule.priority ?? 0 }))
491
+ },
492
+ onChange: () => {},
493
+ validate: value => {
494
+ for (const rule of value.rules) {
495
+ if (rule.style === '' || /[^a-z0-9-]/.test(rule.style)) {
496
+ throw new Error(`dsh-output-styles: rule style ${JSON.stringify(rule.style)} must be a kebab-case renderer id`)
497
+ }
498
+ }
499
+ },
500
+ },
501
+ )
502
+
503
+ // The /export command: renders the current session's message surface to
504
+ // Markdown or sanitized HTML through the renderer pipeline. The document
505
+ // itself is the visible artifact; the original lines are the session log
506
+ // the export was projected from — rendered and original stay reconstructable.
507
+ if (resolved.enableExport) {
508
+ ctx.inject(['commands'], (commandCtx) => {
509
+ commandCtx.commands.register({
510
+ name: 'export',
511
+ description: 'Export this session as Markdown or HTML (renderer-aware)',
512
+ input: { hint: '[markdown|html] [--renderer=<id>]' },
513
+ handler: async ({ agent, rawInput }) => {
514
+ const input = parseExportInput(rawInput)
515
+ if (input.kind === 'error') {
516
+ return { kind: 'error', text: 'usage: /export [markdown|html] [--renderer=<id>]' }
517
+ }
518
+ const lines = conversationLines(agent.session.events)
519
+ const rules: StyleRule[] = input.renderer === undefined
520
+ ? [...effectiveRules]
521
+ : [{ match: {}, style: input.renderer, priority: 0 }]
522
+ const document = renderExport(renderers, lines, input.format, rules)
523
+ return { kind: 'success', text: document.text }
524
+ },
525
+ })
526
+ })
527
+ }
528
+ }
529
+
530
+ /** Parsed `/export` invocation. */
531
+ type ExportInput = { kind: 'ok'; format: 'markdown' | 'html'; renderer?: string } | { kind: 'error' }
532
+
533
+ /** Parse `/export [markdown|html] [--renderer=<id>]` from the raw command input. */
534
+ export function parseExportInput(rawInput: unknown): ExportInput {
535
+ const raw = String(rawInput ?? '').trim()
536
+ const parts = raw === '' ? [] : raw.split(/\s+/)
537
+ let format: 'markdown' | 'html' = 'markdown'
538
+ let renderer: string | undefined
539
+ for (const part of parts) {
540
+ if (part === 'markdown' || part === 'html') {
541
+ format = part
542
+ continue
543
+ }
544
+ const match = /^--renderer=([a-z0-9][a-z0-9-]*)$/.exec(part)
545
+ if (match !== null) {
546
+ renderer = match[1]
547
+ continue
548
+ }
549
+ return { kind: 'error' }
550
+ }
551
+ return { kind: 'ok', format, ...renderer === undefined ? {} : { renderer } }
439
552
  }
package/src/types.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import type { SessionId } from '@deepseek-ai/dsh-session'
12
12
  import { z as zod } from 'zod'
13
13
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
14
+ import type { OutputRenderer, RenderContext, RenderedText } from './renderers.ts'
14
15
 
15
16
  /** The reserved switch target that removes a session's selection. */
16
17
  export const OFF = 'off'
@@ -84,3 +85,30 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
84
85
  style: StyleSelectionView
85
86
  }
86
87
  }
88
+
89
+ /** The `ctx.outputRenderers` service: the output.render.* renderer registry. */
90
+ export interface OutputRenderersService {
91
+ /** Register a renderer (returns the disposer; caller owns it via ctx.effect). */
92
+ register(renderer: OutputRenderer): () => void
93
+ /** Every registered renderer, deterministic order (priority desc, registration asc). */
94
+ list(): OutputRenderer[]
95
+ /** Renderers matching a request, highest priority first. */
96
+ resolve(context: RenderContext): OutputRenderer[]
97
+ /** Render text through the `output.render/before` waterfall, then rules + matched renderers. */
98
+ renderText(text: string, context: RenderContext): Promise<RenderedText>
99
+ }
100
+
101
+ declare module '@deepseek-ai/cordis' {
102
+ interface Context {
103
+ /** The output.render.* renderer registry provided by dsh-output-styles. */
104
+ outputRenderers: OutputRenderersService
105
+ }
106
+ interface Events {
107
+ /**
108
+ * Pre-render waterfall: listeners receive `{ text, context }` and MUST
109
+ * call `next()` with their transformed request (or the unchanged one);
110
+ * returning without `next()` short-circuits the render pipeline.
111
+ */
112
+ 'output.render/before'(request: { text: string; context: RenderContext }, next: (request: { text: string; context: RenderContext }) => Promise<RenderedText>): Promise<RenderedText>
113
+ }
114
+ }
package/README.ja.md DELETED
@@ -1,202 +0,0 @@
1
- <div align="center">
2
-
3
- # 🎨 dsh-output-styles
4
-
5
- **DeepSeek Harness 向け Claude Code `outputStyles`** —— モデルの出力スタイルを実行時に、セッション単位で、永続的に切り替えます。
6
-
7
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
8
- [![CI](https://github.com/PerryLink/dsh-output-styles/actions/workflows/ci.yml/badge.svg)](https://github.com/PerryLink/dsh-output-styles/actions/workflows/ci.yml)
9
- [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-brightgreen.svg)](#)
10
- [![DSH](https://img.shields.io/badge/deepseek--harness-0.1.0--rc.6-4d6bfe.svg)](#)
11
- [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)](#)
12
-
13
- 🌐 [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Español](README.es.md)
14
-
15
- </div>
16
-
17
- ---
18
-
19
- `/style concise` —— これ以降の返信はすべて簡潔になります。`/style step-by-step` —— モデルが番号付きの手順で説明します。`/style off` —— プロジェクトのデフォルトに戻ります。セッションごとに 1 コマンド、再起動をまたいで保持され、agent loop には一切変更を加えません。
20
-
21
- ## ✨ 機能
22
-
23
- | | |
24
- |---|---|
25
- | 🗂️ **スタイルライブラリ** | スタイルごとに 1 つの Markdown ファイル(`styles/*.md`)。frontmatter にメタデータ、本文がモデルへの指示です。`name` は既定でファイル名になり、空白を含めます(`Diagrams first`)。同梱の組み込みスタイルは 6 種で、Claude Code と同等の `proactive` と `learning` を含みます。 |
26
- | ⌨️ **`/style` コマンド** | 引数なしでスタイル一覧(説明付き)と現在の選択を表示。`/style <name>` で切り替え、`/style off` でプロジェクトのデフォルトを復元します。`/style` より後の残り全体がスタイル名です。 |
27
- | 💾 **セッション単位の永続化** | 選択は `output_style` ストレージドメインに sessionId をキーとして保持され、2 つのセッションが干渉することはなく、選択は再起動後も残ります。 |
28
- | 🧩 **システムプロンプト注入** | `systemPrompt.section()` の貢献(order 90)が、毎回の組み立てで現在のセッションのスタイル本文を注入します。本文は設定可能なバジェットで切り詰められます。 |
29
- | 🎭 **Claude Code `keep-coding-instructions`** | `keep-coding-instructions: false`(Claude Code と同じ既定)のスタイルはシステムプロンプト全体を置き換えます——ソフトウェアエンジニアリングから離れるスタイル向けです。 |
30
- | 📌 **強制スタイル** | Claude Code の `force-for-plugin`(エイリアス `force`)はセッションの選択を無視して無条件にスタイルを適用します。強制スタイルが 2 つあると読み込みに失敗します。 |
31
- | 🔁 **Claude Code 互換性** | `outputStyles` JSON コレクション(`{ name, description, prompt }`)を読み込みます。単一エントリまたは `settings.json` 形式の配列に対応し、解析不能なエントリは警告付きでスキップします。 |
32
- | 📚 **ディレクトリの階層化** | `stylesDir` はリストで、後方のディレクトリが前方のものを上書きします(同梱の `styles/` が最下層で、`includeBuiltins: false` で無効化)。 |
33
- | 🔄 **ホットリロード** | スタイルファイルの変更は再起動なしで反映されます(`watchStyles: false` で無効化)。 |
34
- | ⚙️ **settings とプロジェクトデフォルト** | 一度も選択していないセッションは、DSH settings の `output-style.style`、次いで `defaultStyle` にフォールバックします。 |
35
- | 🖱️ **Web ピッカー** | `dsh.client` エントリ(`dsh-output-styles/client`)が、ホストの `/style` コマンドを投影ベースのポップアップピッカーで装飾します。 |
36
- | 📊 **セッション投影** | Web UI 向けの `style` 投影(`{ options, currentValue }`)。セッションログ内で確定したコマンドから折りたたまれます。 |
37
- | 🧯 **失敗は明示、スキップはクリーンに** | 設定ミスは読み込み時に例外を投げます。不正なスタイルファイルは警告付きでスキップされ、プロファイルを壊すことはありません。 |
38
- | 🌐 **5 言語のドキュメント** | EN · 中文 · 日本語 · 한국어 · Español。 |
39
-
40
- ## 🚀 クイックスタート
41
-
42
- ```sh
43
- # 1. インストール——このパッケージは bundle レイヤーなので、1 コマンドで
44
- # storage + storage-json + storage-domain + プラグイン行が構成されます:
45
- dsh plugin --profile <name> add dsh-output-styles
46
-
47
- # 2. 起動して切り替え
48
- dsh --profile <name>
49
- /style # → output style off、その後にスタイルごとに 1 行
50
- /style concise # → switched to concise
51
- /style Diagrams first # → 空白を含む名前も使えます
52
- /style off # → プロジェクトのデフォルトに戻る
53
- ```
54
-
55
- このレイヤーは web プロファイルに対して冪等です(id 単位の挿入が同 id の行を置き換えます)。web プロファイルには `storage` があらかじめ含まれています。Web ピッカーを使うには、プロファイルにクライアント行を追加します:
56
-
57
- ```yaml
58
- - id: output-styles-client
59
- name: 'dsh-output-styles/client'
60
- ```
61
-
62
- ## 🎬 デモ
63
-
64
- ```
65
- You > /style
66
- output style off
67
- concise — Terse, direct answers — minimal prose, no preamble. (Daily coding work, tool-heavy sessions, or when prompt length matters.)
68
- explanatory — Educational answers with short "Insights" that teach as you work. (Learning a codebase, onboarding, …)
69
- formal — Formal, precise prose with complete sentences and defined terms. (Reports, documentation, release notes, …)
70
- learning — Collaborative learn-by-doing mode with short "Insights" and small hands-on steps for the user. (Pairing, onboarding, …)
71
- proactive — Execute immediately, assume reasonable defaults, and prefer action over planning. (Routine multi-step work, …)
72
- step-by-step — Numbered reasoning steps with explicit intermediate results. (Debugging, design decisions, …)
73
-
74
- You > /style concise
75
- switched to concise
76
-
77
- You > 一文だけで自己紹介してください。
78
- AI > 私は DeepSeek Harness プラグインプラットフォーム上で動作する、deepseek-v4-pro モデルベースの AI コーディングエージェントです。
79
- ```
80
-
81
- ## 🧠 仕組み
82
-
83
- ```mermaid
84
- flowchart LR
85
- U[/style concise と入力] --> C[コマンドレジストリ]
86
- C -->|command/run を記録| L[(セッションログ)]
87
- C -->|put {style, source}| D[(output_style ドメイン)]
88
- D --> R[OutputStyleRuntime]
89
- R -->|毎回の組み立てで本文を注入| S[systemPrompt セクション order 90]
90
- S --> M[モデルリクエスト]
91
- M -->|完全なシステムプロンプト| H[request/header を記録]
92
- ```
93
-
94
- モデルが見るものはすべてセッションログから再構築できます——新しいセッションイベント型も agent-loop の変更も不要です。スタイル名は `command/run` から、注入された正確なテキストは `request/header` から得られ、出所マーカー `{ kind: 'plugin', plugin: 'dsh-output-styles' }` はドメインレコードに残ります。スタイルはメイン会話のみに適用され、サブエージェントのセッションは独自のプロンプトを保持します(Claude Code と一致)。
95
-
96
- ## ⚙️ 設定
97
-
98
- すべての調整項目は検証付きの Schemastery `Config` フィールドです(不正な値は読み込みに失敗します):
99
-
100
- | フィールド | 既定 | 意味 |
101
- |---|---|---|
102
- | `stylesDir` | `[]` | スタイルライブラリのディレクトリ。cwd を基準に解決され、後方のエントリが前方のものを上書きします。`[]` = 同梱の `styles/` のみ。裸の文字列は単一ディレクトリのリストとみなされます。 |
103
- | `maxStyleChars` | `4000` | スタイル本文のバジェット(コードポイント、≥ 1)。長い本文はマーカー付きで切り詰められます。 |
104
- | `defaultStyle` | `''` | 一度も選択していないセッション(かつ settings の既定もない)向けのスタイル。`''` = スタイルなし。 |
105
- | `compatJson` | `true` | Claude Code `outputStyles` の JSON エントリ(単一オブジェクトまたは配列)を読み込みます。 |
106
- | `sectionOrder` | `90` | 注入セクションの順序(0 = persona、100–199 = ツールガイダンス)。 |
107
- | `truncationMarker` | `"\n\n[style truncated]"` | 切り詰め位置に付加されるマーカー。 |
108
- | `includeBuiltins` | `true` | パッケージ同梱の `styles/` を最優先度の低いレイヤーとして含めます。 |
109
- | `watchStyles` | `true` | スタイルファイルがディスク上で変更されたときにライブラリを再読み込みします。 |
110
-
111
- ## 📚 スタイルライブラリ
112
-
113
- <details>
114
- <summary><code>styles/concise.md</code></summary>
115
-
116
- ```markdown
117
- ---
118
- name: concise
119
- description: Terse, direct answers — minimal prose, no preamble.
120
- whenToUse: Daily coding work, tool-heavy sessions, or when prompt length matters.
121
- keep-coding-instructions: true
122
- ---
123
-
124
- You are in the concise output style for this conversation.
125
- - Lead with the direct answer; skip preamble, restatements, and filler.
126
- - 回答语言跟随用户语言:中文提问用中文回答,英文提问用英文回答。
127
- ```
128
-
129
- </details>
130
-
131
- frontmatter フィールド:
132
-
133
- | フィールド | 既定 | 意味 |
134
- |---|---|---|
135
- | `name` | ファイル名 | 切り替え対象。英字、数字、空白、ハイフン(先頭・末尾の空白は不可。`off` は予約済み)。 |
136
- | `description` | —(必須) | 一覧とピッカーに表示される 1 文。 |
137
- | `whenToUse` | — | 一覧に追記される任意の利用ガイダンス。 |
138
- | `keep-coding-instructions` | `false` | `true` のときハーネスのプロンプト(アイデンティティ、persona、ツールガイダンス)を保持し、`false` のとき完全に置き換えます(Claude Code のセマンティクス)。 |
139
- | `force-for-plugin` | `false` | Claude Code 公式フィールド:セッションの選択を無視して無条件に適用します。`force` はエイリアスで、設定できるのは最大 1 つのスタイルです。 |
140
-
141
- <details>
142
- <summary>Claude Code <code>outputStyles</code> JSON(<code>compatJson: true</code>)</summary>
143
-
144
- ```json
145
- { "name": "explain", "description": "Explain like a teacher.", "prompt": "Teach in small steps." }
146
- ```
147
-
148
- エントリは Claude Code が書き込むとおりに `keep-coding-instructions` と `force-for-plugin` を受け付けます。レガシーの `settings.json` 配列(`[{ … }, { … }]`)はそのまま読み込まれ、不正なエントリは警告付きでスキップされます。
149
-
150
- </details>
151
-
152
- ## ⌨️ コマンドリファレンス
153
-
154
- | 入力 | 結果 |
155
- |---|---|
156
- | `/style` | 現在の選択 + スタイルごとに 1 行(name — description)を一覧表示 |
157
- | `/style concise` | 切り替え(永続書き込み)、`switched to concise` |
158
- | `/style Diagrams first` | 複数語の名前は残り全体が対象 |
159
- | `/style off` | プロジェクトのデフォルトを復元(settings の既定、次いで `defaultStyle`) |
160
- | `/style nope` | `error: unknown output style "nope" (available: …)` |
161
-
162
- ## 🖱️ Web ピッカー
163
-
164
- `dsh.client` エントリは、ホストの `/style` コマンドの引数なし呼び出しをポップアップピッカーで装飾します:「off」行 + ライブラリのスタイルごとに 1 行(`description · whenToUse`)で、アクティブな行がマークされます。選択するとコマンド Remote を通じて `/style <name>` が送信されるため、どの切り替えもホストの永続的なコマンドライフサイクルを経由し、`style` 投影が唯一の表示事実であり続けます。ピッカーの文言は Web UI が同梱する `zh`/`en` の言語ペアに従います。
165
-
166
- ## 🔍 競合チェック
167
-
168
- 開発前に DSH エコシステムを調査しました(2026-08 スナップショット):[topic:dsh-plugin](https://github.com/topics/dsh-plugin) 配下に `style`/`output-style` のリポジトリはなく、4 つの主要な [awesome リスト](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) に output-style カテゴリはなく、[dsh-hub カタログ](https://github.com/omdsh-dev/dsh-hub-workshop) にもエントリはありません。最も近い隣接プロジェクト——[dsh-soul-md](https://github.com/Scorp1o117/dsh-soul-md)(persona)と [dsh-claude-marketplace](https://github.com/ben7am1n/dsh-claude-marketplace)(出力スタイルは明示的に v0.2+ へ先送り)——は隣接していて、競合はしません。
169
-
170
- ## 🆚 Claude Code との違い
171
-
172
- | | Claude Code | dsh-output-styles |
173
- |---|---|---|
174
- | スタイルファイル | ユーザー/プロジェクト/マネージド階層の `.claude/output-styles` | `stylesDir` ディレクトリ + 同梱の `styles/`。後方のディレクトリが優先 |
175
- | カスタムスタイル | Markdown、frontmatter `name`/`description`/`keep-coding-instructions`/`force-for-plugin` | 同じフィールド(`force-for-plugin` をそのまま受け付け、`force` はエイリアス)+ `whenToUse` |
176
- | レガシー JSON | `settings.json` 内の `outputStyles` 配列 | そのまま読み込み(`compatJson: true`) |
177
- | 反映タイミング | `/clear` 後または新しいセッション | 即時——システムプロンプトはリクエストごとに再構築 |
178
- | サブエージェント | スタイルは適用されない | 同じ——サブエージェントのセッションは独自のプロンプトを保持 |
179
- | 切り替え | `/config` メニューまたは `outputStyle` 設定(`/output-style` コマンドは v2.1.91 で削除) | `/style` コマンド + Web ピッカー + settings `output-style.style` |
180
-
181
- ## 🧪 開発
182
-
183
- ```sh
184
- pnpm install
185
- pnpm run typecheck # 両方の tsc プロジェクト
186
- pnpm test # vitest — 93 テスト
187
- pnpm run verify # typecheck + テスト + 自己完結チェック(prepublishOnly ゲート)
188
- pnpm run build # lib/ 成果物(ホスト + クライアント bundle)
189
- pnpm pack # dsh plugin add 用の tarball
190
- ```
191
-
192
- リリース:`package.json` のバージョンと一致する接尾辞を持つ `v*` タグを push すると Publish ワークフローが起動します——完全検証の後に npm へ公開(provenance 付き)。あらゆる `npm publish` も `prepublishOnly` 経由で `verify` ゲートを通過します。
193
-
194
- 構成は [omdsh-dev/plugin-template](https://github.com/omdsh-dev/plugin-template) に従います:`src/index.ts`(プラグインメタデータ)、`src/config.ts`(スキーマ)、`src/runtime.ts`(ランタイムサービス + アクティベーション)、`src/invariant.ts`(不変条件)、`src/client/`(Web ピッカー)、`styles/`(組み込みスタイル)。
195
-
196
- ## 📄 ライセンス
197
-
198
- [Apache-2.0](LICENSE) © 2026 dsh-output-styles contributors
199
-
200
- ---
201
-
202
- <sub>Topics: `dsh` · `dsh-plugin` · `deepseek-harness` · `output-styles` · `claude-code`</sub>