dsh-output-styles 0.3.2 → 0.4.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/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { watch } from "node:fs";
6
6
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
7
+ import { deriveEventMessage, foldSurface } from "@deepseek-ai/dsh-session/surface";
7
8
  //#region src/config.ts
8
9
  /**
9
10
  * Serializable configuration, schema, and direct-call defaults.
@@ -23,7 +24,21 @@ const Config = z.object({
23
24
  sectionOrder: z.number().default(90),
24
25
  truncationMarker: z.string().default("\n\n[style truncated]"),
25
26
  includeBuiltins: z.boolean().default(true),
26
- watchStyles: z.boolean().default(true)
27
+ watchStyles: z.boolean().default(true),
28
+ rules: z.array(z.object({
29
+ match: z.object({
30
+ tool: z.string().required(false),
31
+ contentType: z.union([
32
+ z.const("text"),
33
+ z.const("markdown"),
34
+ z.const("html")
35
+ ]).required(false),
36
+ session: z.string().required(false)
37
+ }).required(false),
38
+ style: z.string().min(1),
39
+ priority: z.number().required(false)
40
+ })).default([]),
41
+ enableExport: z.boolean().default(true)
27
42
  });
28
43
  /**
29
44
  * Resolve the same defaults for direct callers that bypass the Cordis Loader,
@@ -40,15 +55,344 @@ function resolveConfig(config, defaultStylesDir) {
40
55
  if (!Number.isFinite(sectionOrder)) throw new Error(`dsh-output-styles: sectionOrder must be a finite number, got ${String(config.sectionOrder)}`);
41
56
  const includeBuiltins = config.includeBuiltins ?? true;
42
57
  const customDirs = (Array.isArray(config.stylesDir) ? config.stylesDir : config.stylesDir === void 0 || config.stylesDir === "" ? [] : [config.stylesDir]).map((dir) => resolve(dir));
58
+ const stylesDirs = includeBuiltins ? [defaultStylesDir, ...customDirs] : customDirs;
59
+ for (const rule of config.rules ?? []) {
60
+ const match = rule.match ?? {};
61
+ if (match.tool !== void 0 && match.tool !== "*" && /[^a-zA-Z0-9_-]/.test(match.tool)) throw new Error(`dsh-output-styles: rule tool ${JSON.stringify(match.tool)} must be a tool name or '*'`);
62
+ if (rule.style === "" || /[^a-z0-9-]/.test(rule.style)) throw new Error(`dsh-output-styles: rule style ${JSON.stringify(rule.style)} must be a kebab-case renderer id`);
63
+ if (rule.priority !== void 0 && !Number.isFinite(rule.priority)) throw new Error(`dsh-output-styles: rule priority must be a finite number, got ${String(rule.priority)}`);
64
+ }
43
65
  return {
44
- stylesDirs: includeBuiltins ? [defaultStylesDir, ...customDirs] : customDirs,
66
+ stylesDirs,
45
67
  maxStyleChars,
46
68
  defaultStyle: config.defaultStyle ?? "",
47
69
  compatJson: config.compatJson ?? true,
48
70
  sectionOrder,
49
71
  truncationMarker: config.truncationMarker ?? "\n\n[style truncated]",
50
72
  includeBuiltins,
51
- watchStyles: config.watchStyles ?? true
73
+ watchStyles: config.watchStyles ?? true,
74
+ rules: (config.rules ?? []).map((rule) => ({
75
+ ...rule,
76
+ priority: rule.priority ?? 0
77
+ })),
78
+ enableExport: config.enableExport ?? true
79
+ };
80
+ }
81
+ //#endregion
82
+ //#region src/renderers.ts
83
+ /**
84
+ * Validates a renderer before registration: id grammar, name, description,
85
+ * match shape, priority, presenter function. Throws on the first violation
86
+ * (fail-loud — a bad renderer never sits in the registry).
87
+ * @param renderer - candidate renderer.
88
+ */
89
+ function validateRenderer(renderer) {
90
+ if (typeof renderer !== "object" || renderer === null) throw new Error("renderer must be an object");
91
+ if (typeof renderer.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(renderer.id)) throw new Error(`invalid renderer id ${JSON.stringify(renderer.id)}: use kebab-case`);
92
+ if (typeof renderer.name !== "string" || renderer.name === "") throw new Error("renderer name must be a non-empty string");
93
+ if (typeof renderer.description !== "string" || renderer.description === "") throw new Error("renderer description must be a non-empty string");
94
+ if (!Array.isArray(renderer.match)) throw new Error("renderer match must be an array");
95
+ for (const match of renderer.match) {
96
+ if (match.tool !== void 0) {
97
+ if (!(Array.isArray(match.tool) ? match.tool : [match.tool]).every((item) => typeof item === "string")) throw new Error("renderer match.tool must be a string or string array");
98
+ }
99
+ if (match.contentType !== void 0) {
100
+ if (!(Array.isArray(match.contentType) ? match.contentType : [match.contentType]).every((item) => typeof item === "string")) throw new Error("renderer match.contentType must be a string or string array");
101
+ }
102
+ }
103
+ if (typeof renderer.priority !== "number" || !Number.isFinite(renderer.priority)) throw new Error("renderer priority must be a finite number");
104
+ if (typeof renderer.presenter !== "function") throw new Error("renderer presenter must be a function");
105
+ }
106
+ /** Match one rule fact (a string, a string list, or undefined=any) against a value. */
107
+ function factMatches(fact, value) {
108
+ if (fact === void 0) return true;
109
+ if (typeof fact === "string") return fact === "*" || fact === value;
110
+ return fact.includes("*") || fact.includes(value);
111
+ }
112
+ /** Whether a renderer matches a render request. */
113
+ function rendererMatches(renderer, context) {
114
+ if (renderer.match.length === 0) return true;
115
+ return renderer.match.some((match) => factMatches(match.tool, context.tool) && factMatches(match.contentType, context.contentType));
116
+ }
117
+ /** Whether a configured rule matches a render request. */
118
+ function ruleMatches(rule, context) {
119
+ if (rule.match.tool !== void 0 && rule.match.tool !== "*" && rule.match.tool !== context.tool) return false;
120
+ if (rule.match.contentType !== void 0 && rule.match.contentType !== context.contentType) return false;
121
+ if (rule.match.session !== void 0 && rule.match.session !== context.sessionId) return false;
122
+ return true;
123
+ }
124
+ /**
125
+ * The renderer registry: reversible registration, ordered resolution, and
126
+ * rule-driven rendering. Registration is a caller-owned effect (the runtime
127
+ * hands register()'s disposer to ctx.effect); the registry itself is pure
128
+ * state with no timers, listeners, or I/O.
129
+ */
130
+ var RendererRegistry = class {
131
+ renderers = /* @__PURE__ */ new Map();
132
+ order = 0;
133
+ /** Register a renderer; the disposer removes exactly this registration. */
134
+ register(renderer) {
135
+ validateRenderer(renderer);
136
+ if (this.renderers.has(renderer.id)) throw new Error(`renderer ${JSON.stringify(renderer.id)} is already registered`);
137
+ const order = this.order++;
138
+ this.renderers.set(renderer.id, {
139
+ renderer,
140
+ order
141
+ });
142
+ return () => {
143
+ if (this.renderers.get(renderer.id)?.order === order) this.renderers.delete(renderer.id);
144
+ };
145
+ }
146
+ /** Every registered renderer, deterministic order (priority desc, registration asc). */
147
+ list() {
148
+ return [...this.renderers.values()].sort((a, b) => b.renderer.priority - a.renderer.priority || a.order - b.order).map((entry) => entry.renderer);
149
+ }
150
+ /** Resolve the renderers that match a request, highest priority first. */
151
+ resolve(context) {
152
+ return this.list().filter((renderer) => rendererMatches(renderer, context));
153
+ }
154
+ /**
155
+ * Render text through the rule table, then the matching renderer pipeline:
156
+ * the first matching rule names a renderer (applied alone, it is explicit),
157
+ * otherwise every matching renderer applies in priority order (each sees the
158
+ * previous renderer's output — composition, not competition).
159
+ * @param text - the raw model-visible text.
160
+ * @param context - tool / content-type / session facts.
161
+ * @param rules - configured style rules, highest priority first.
162
+ * @returns the auditable result (original always preserved).
163
+ */
164
+ render(text, context, rules = []) {
165
+ const hit = [...rules].sort((a, b) => b.priority - a.priority).find((rule) => ruleMatches(rule, context));
166
+ if (hit !== void 0) {
167
+ const renderer = this.renderers.get(hit.style);
168
+ if (renderer === void 0) throw new Error(`rule style ${JSON.stringify(hit.style)} names no registered renderer (available: ${this.list().map((item) => item.id).join(", ") || "none"})`);
169
+ const rendered = renderer.renderer.presenter(text, context);
170
+ return {
171
+ original: text,
172
+ rendered,
173
+ rendererId: renderer.renderer.id,
174
+ changed: rendered !== text
175
+ };
176
+ }
177
+ let rendered = text;
178
+ let rendererId;
179
+ for (const renderer of this.resolve(context)) {
180
+ rendered = renderer.presenter(rendered, context);
181
+ rendererId = renderer.id;
182
+ }
183
+ return {
184
+ original: text,
185
+ rendered,
186
+ ...rendererId === void 0 ? {} : { rendererId },
187
+ changed: rendered !== text
188
+ };
189
+ }
190
+ };
191
+ /** Collapse whitespace runs and blank-line stacks; trim the ends. */
192
+ function compact(text, maxLines, maxChars, marker) {
193
+ const collapsed = text.split(/\r?\n/).map((line) => line.replace(/[ \t]+/g, " ").trimEnd()).reduce((lines, line) => {
194
+ if (line === "" && lines[lines.length - 1] === "") return lines;
195
+ lines.push(line);
196
+ return lines;
197
+ }, []).join("\n").trim();
198
+ let out = maxLines > 0 && collapsed.split("\n").length > maxLines ? collapsed.split("\n").slice(0, maxLines).join("\n") + marker : collapsed;
199
+ if (maxChars > 0 && out.length > maxChars) out = out.slice(0, maxChars) + marker;
200
+ return out;
201
+ }
202
+ /** Turn a list-shaped text into consistently numbered steps. */
203
+ function enumerate(text) {
204
+ const lines = text.split(/\r?\n/);
205
+ if (lines.filter((line) => /^\s*(?:[-*•]|\d+[.)])\s+/.test(line)).length === 0) return text;
206
+ let counter = 0;
207
+ return lines.map((line) => {
208
+ if (!/^\s*(?:[-*•]|\d+[.)])\s+/.test(line)) return line;
209
+ counter += 1;
210
+ return line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, `${counter}. `);
211
+ }).join("\n");
212
+ }
213
+ /**
214
+ * The built-in style renderers, mirroring the two headline styles: `concise`
215
+ * compacts whitespace under a line/char budget, `step-by-step` numbers list
216
+ * items consistently. Their ids double as `/style`-compatible names in the
217
+ * rule table.
218
+ */
219
+ const BUILTIN_RENDERERS = [{
220
+ id: "concise",
221
+ name: "Concise",
222
+ description: "Compacts whitespace runs and blank lines, and caps the presented text at a budget with a truncation marker.",
223
+ match: [],
224
+ priority: 10,
225
+ presenter: (text) => compact(text, 40, 4e3, "\n\n[truncated]")
226
+ }, {
227
+ id: "step-by-step",
228
+ name: "Step-by-step",
229
+ description: "Numbers list items (dashes, bullets, or digits) consistently from 1 so the presentation reads as ordered steps.",
230
+ match: [],
231
+ priority: 10,
232
+ presenter: (text) => enumerate(text)
233
+ }];
234
+ //#endregion
235
+ //#region src/export.ts
236
+ /**
237
+ * Session export: a pure projection of the current session's message surface
238
+ * into Markdown or sanitized HTML, with renderer application on top. The
239
+ * extraction reads only the public `session.events` log through the official
240
+ * `deriveEventMessage` projection — the same rule the harness uses to build
241
+ * model requests — so the exported document is reconstructable from the log.
242
+ * Every render application preserves the original text beside the rendered
243
+ * one, keeping the auditable pair intact inside the export document.
244
+ *
245
+ * Host-side module (imports the dsh-session surface projection); the pure
246
+ * Markdown/HTML/sanitize functions stay free of both DOM and I/O.
247
+ * @module dsh-output-styles/export
248
+ */
249
+ /**
250
+ * Extract the message surface of a session log as plain lines. Tool calls and
251
+ * tool results render as labeled lines so the transcript stays readable; the
252
+ * content is never summarized — this is a projection, not an interpretation.
253
+ * @param events - the session log, in seq order.
254
+ * @returns the conversation lines in surface order.
255
+ */
256
+ function conversationLines(events) {
257
+ const { nodes } = foldSurface(events);
258
+ const lines = [];
259
+ for (const seq of nodes) {
260
+ const event = events.find((item) => item.seq === seq);
261
+ if (event === void 0) continue;
262
+ const message = deriveEventMessage(event);
263
+ if (message === null) continue;
264
+ const described = describeMessage(message);
265
+ if (described.text === "") continue;
266
+ lines.push(described);
267
+ }
268
+ return lines;
269
+ }
270
+ /**
271
+ * Project one derived message into an export line: string content becomes a
272
+ * user/assistant line; a content carrying only tool_use parts becomes a tool
273
+ * call line; only tool_result parts become a tool result line. Mixed content
274
+ * keeps the text parts and the message role.
275
+ */
276
+ function describeMessage(message) {
277
+ const content = message.content;
278
+ const baseRole = message.role === "assistant" ? "assistant" : "user";
279
+ if (typeof content === "string") return {
280
+ role: baseRole,
281
+ text: content.trim()
282
+ };
283
+ if (Array.isArray(content)) {
284
+ const textParts = [];
285
+ const toolUses = [];
286
+ const toolResults = [];
287
+ for (const part of content) {
288
+ if (part === null || typeof part !== "object") continue;
289
+ const record = part;
290
+ if (record["type"] === "text" && typeof record["text"] === "string") textParts.push(record["text"]);
291
+ else if (record["type"] === "tool_use") toolUses.push(String(record["name"] ?? "tool"));
292
+ else if (record["type"] === "tool_result") {
293
+ const raw = record["content"];
294
+ const body = typeof raw === "string" ? raw : Array.isArray(raw) ? raw.map((item) => typeof item === "object" && item !== null && typeof item["text"] === "string" ? item["text"] : "").join(" ") : "";
295
+ toolResults.push(body.trim());
296
+ }
297
+ }
298
+ if (textParts.length === 0 && toolUses.length > 0) {
299
+ const first = toolUses[0];
300
+ return first === void 0 ? {
301
+ role: "tool",
302
+ text: "[tool call]"
303
+ } : {
304
+ role: "tool",
305
+ tool: first,
306
+ text: toolUses.map((name) => `[tool call: ${name}]`).join("\n")
307
+ };
308
+ }
309
+ if (textParts.length === 0 && toolResults.length > 0) return {
310
+ role: "tool",
311
+ tool: "tool-result",
312
+ text: toolResults.join("\n")
313
+ };
314
+ return {
315
+ role: baseRole,
316
+ text: textParts.join("\n").trim()
317
+ };
318
+ }
319
+ return {
320
+ role: baseRole,
321
+ text: ""
322
+ };
323
+ }
324
+ /**
325
+ * Sanitize one text for HTML embedding: escape the five markup-significant
326
+ * characters and strip control characters except newline/tab. Pure function,
327
+ * covered by extreme-case tests (tags, null bytes, huge inputs).
328
+ * @param text - the raw text.
329
+ * @returns the HTML-safe text.
330
+ */
331
+ function sanitizeText(text) {
332
+ return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
333
+ }
334
+ /**
335
+ * Render one export line as a Markdown block: headings by role and fenced
336
+ * blocks for tool lines so multi-line content never breaks the transcript.
337
+ * @param line - the export line.
338
+ * @returns the Markdown text.
339
+ */
340
+ function lineToMarkdown(line) {
341
+ if (line.tool !== void 0) return `### \`${line.tool}\`\n\n\`\`\`text\n${line.text}\n\`\`\``;
342
+ return `${line.role === "user" ? "## User" : "## Assistant"}\n\n${line.text}`;
343
+ }
344
+ /** Build the complete Markdown document for a set of lines. */
345
+ function toMarkdown(lines) {
346
+ const body = lines.map(lineToMarkdown).join("\n\n");
347
+ return `# Session export\n\n${body === "" ? "_No messages yet._" : body}\n`;
348
+ }
349
+ /** Build the complete sanitized HTML document for a set of lines. */
350
+ function toHtml(lines) {
351
+ const body = lines.map((line) => {
352
+ const safe = sanitizeText(line.text).replace(/\n/g, "<br>");
353
+ if (line.tool !== void 0) return `<h3><code>${sanitizeText(line.tool)}</code></h3>\n<pre>${safe}</pre>`;
354
+ return `<h2>${line.role === "user" ? "User" : "Assistant"}</h2>\n<p>${safe}</p>`;
355
+ }).join("\n");
356
+ return `<!doctype html>
357
+ <html lang="en">
358
+ <head><meta charset="utf-8"><title>Session export</title></head>
359
+ <body>
360
+ <h1>Session export</h1>\n${body === "" ? "<p><em>No messages yet.</em></p>" : body}\n</body>\n</html>\n`;
361
+ }
362
+ /**
363
+ * Apply the renderer pipeline to every line of a conversation (per-tool and
364
+ * per-content-type rules decide which renderer touches which line) and build
365
+ * the export document. Both the rendered document and the original lines
366
+ * stay inside the returned document, so the render application is auditable.
367
+ * @param registry - the renderer registry.
368
+ * @param lines - the extracted conversation lines.
369
+ * @param format - export format.
370
+ * @param rules - configured style rules.
371
+ * @param now - exportedAt timestamp (injected for deterministic tests).
372
+ * @returns the complete export document.
373
+ */
374
+ function renderExport(registry, lines, format, rules, now = /* @__PURE__ */ new Date()) {
375
+ const rendered = [];
376
+ const presented = lines.map((line) => {
377
+ const result = registry.render(line.text, {
378
+ tool: line.tool ?? "",
379
+ contentType: "markdown"
380
+ }, rules);
381
+ rendered.push(result);
382
+ return {
383
+ ...line,
384
+ text: result.rendered
385
+ };
386
+ });
387
+ const document = format === "markdown" ? toMarkdown(presented) : toHtml(presented);
388
+ return {
389
+ plugin: "dsh-output-styles",
390
+ schema: "output-export-v1",
391
+ format,
392
+ exportedAt: now.toISOString(),
393
+ text: document,
394
+ lines: presented,
395
+ rendered
52
396
  };
53
397
  }
54
398
  //#endregion
@@ -390,6 +734,93 @@ async function apply(ctx, config) {
390
734
  selectionFor: (sessionId) => runtime.selectionFor(sessionId)
391
735
  }));
392
736
  });
737
+ const renderers = new RendererRegistry();
738
+ for (const renderer of BUILTIN_RENDERERS) ctx.effect(() => renderers.register(renderer), `dsh-output-styles: renderer ${renderer.id}`);
739
+ let effectiveRules = resolved.rules;
740
+ const renderText = (text, context) => ctx.waterfall("output.render/before", {
741
+ text,
742
+ context
743
+ }, async (request) => renderers.render(request.text, request.context, effectiveRules));
744
+ const renderService = {
745
+ register: (renderer) => renderers.register(renderer),
746
+ list: () => renderers.list(),
747
+ resolve: (context) => renderers.resolve(context),
748
+ renderText
749
+ };
750
+ ctx.provide("outputRenderers", renderService);
751
+ installSettingsSection(ctx, settingsNamespace("output-style-rules"), z.object({ rules: z.array(z.object({
752
+ match: z.object({
753
+ tool: z.string().required(false),
754
+ contentType: z.union([
755
+ z.const("text"),
756
+ z.const("markdown"),
757
+ z.const("html")
758
+ ]).required(false),
759
+ session: z.string().required(false)
760
+ }).required(false),
761
+ style: z.string().min(1),
762
+ priority: z.number().required(false)
763
+ })).default([]) }), { rules: resolved.rules }, {
764
+ setSource: (current) => {
765
+ effectiveRules = current().rules.map((rule) => ({
766
+ match: rule.match ?? {},
767
+ style: rule.style,
768
+ priority: rule.priority ?? 0
769
+ }));
770
+ },
771
+ onChange: () => {},
772
+ validate: (value) => {
773
+ for (const rule of value.rules) if (rule.style === "" || /[^a-z0-9-]/.test(rule.style)) throw new Error(`dsh-output-styles: rule style ${JSON.stringify(rule.style)} must be a kebab-case renderer id`);
774
+ }
775
+ });
776
+ if (resolved.enableExport) ctx.inject(["commands"], (commandCtx) => {
777
+ commandCtx.commands.register({
778
+ name: "export",
779
+ description: "Export this session as Markdown or HTML (renderer-aware)",
780
+ input: { hint: "[markdown|html] [--renderer=<id>]" },
781
+ handler: async ({ agent, rawInput }) => {
782
+ const input = parseExportInput(rawInput);
783
+ if (input.kind === "error") return {
784
+ kind: "error",
785
+ text: "usage: /export [markdown|html] [--renderer=<id>]"
786
+ };
787
+ const lines = conversationLines(agent.session.events);
788
+ const rules = input.renderer === void 0 ? [...effectiveRules] : [{
789
+ match: {},
790
+ style: input.renderer,
791
+ priority: 0
792
+ }];
793
+ return {
794
+ kind: "success",
795
+ text: renderExport(renderers, lines, input.format, rules).text
796
+ };
797
+ }
798
+ });
799
+ });
800
+ }
801
+ /** Parse `/export [markdown|html] [--renderer=<id>]` from the raw command input. */
802
+ function parseExportInput(rawInput) {
803
+ const raw = String(rawInput ?? "").trim();
804
+ const parts = raw === "" ? [] : raw.split(/\s+/);
805
+ let format = "markdown";
806
+ let renderer;
807
+ for (const part of parts) {
808
+ if (part === "markdown" || part === "html") {
809
+ format = part;
810
+ continue;
811
+ }
812
+ const match = /^--renderer=([a-z0-9][a-z0-9-]*)$/.exec(part);
813
+ if (match !== null) {
814
+ renderer = match[1];
815
+ continue;
816
+ }
817
+ return { kind: "error" };
818
+ }
819
+ return {
820
+ kind: "ok",
821
+ format,
822
+ ...renderer === void 0 ? {} : { renderer }
823
+ };
393
824
  }
394
825
  //#endregion
395
826
  //#region src/index.ts
@@ -8,6 +8,22 @@
8
8
  * @module dsh-output-styles/config
9
9
  */
10
10
  import z from '@deepseek-ai/schemastery';
11
+ /** One per-session/per-tool style rule: match facts + the renderer to apply. */
12
+ export interface StyleRuleConfig {
13
+ /** Match facts; an empty object matches every render request. */
14
+ match: {
15
+ /** Tool name or '*' (omitted = any tool). */
16
+ tool?: string;
17
+ /** Content type (omitted = any). */
18
+ contentType?: 'text' | 'markdown' | 'html';
19
+ /** Exact session id (omitted = any session) — the per-session axis. */
20
+ session?: string;
21
+ };
22
+ /** Renderer id to apply; built-in ids mirror the style names (concise, step-by-step). */
23
+ style: string;
24
+ /** Higher priority wins; ties break by rule order (earlier first). */
25
+ priority?: number;
26
+ }
11
27
  /** Plugin configuration supplied by the profile composition. */
12
28
  export interface Config {
13
29
  /**
@@ -39,6 +55,10 @@ export interface Config {
39
55
  includeBuiltins?: boolean;
40
56
  /** Reload the library when a style file changes on disk (default true). */
41
57
  watchStyles?: boolean;
58
+ /** Per-session/per-tool render rules (renderer registry); applied by `/export` and the render service. */
59
+ rules?: StyleRuleConfig[];
60
+ /** Register the `/export` command (Markdown/HTML session-export, renderer-aware). */
61
+ enableExport?: boolean;
42
62
  }
43
63
  /** Configuration after defaults have been resolved. */
44
64
  export interface ResolvedConfig {
@@ -58,6 +78,18 @@ export interface ResolvedConfig {
58
78
  includeBuiltins: boolean;
59
79
  /** Whether the library reloads on style-file changes. */
60
80
  watchStyles: boolean;
81
+ /** Per-session/per-tool render rules with resolved priorities. */
82
+ rules: Array<{
83
+ match: {
84
+ tool?: string;
85
+ contentType?: 'text' | 'markdown' | 'html';
86
+ session?: string;
87
+ };
88
+ style: string;
89
+ priority: number;
90
+ }>;
91
+ /** Whether the `/export` command registers. */
92
+ enableExport: boolean;
61
93
  }
62
94
  /** Loader-visible configuration schema and defaults. */
63
95
  export declare const Config: z<Config>;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAGxC,gEAAgE;AAChE,MAAM,WAAW,MAAM;IACrB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC7B,gGAAgG;IAChG,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6GAA6G;IAC7G,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,4GAA4G;IAC5G,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,sGAAsG;IACtG,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,wEAAwE;IACxE,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,2EAA2E;IAC3E,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB;AAED,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B,sGAAsG;IACtG,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,mDAAmD;IACnD,aAAa,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,UAAU,EAAE,OAAO,CAAA;IACnB,oEAAoE;IACpE,YAAY,EAAE,MAAM,CAAA;IACpB,+CAA+C;IAC/C,gBAAgB,EAAE,MAAM,CAAA;IACxB,4DAA4D;IAC5D,eAAe,EAAE,OAAO,CAAA;IACxB,yDAAyD;IACzD,WAAW,EAAE,OAAO,CAAA;CACrB;AAED,wDAAwD;AACxD,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAS3B,CAAA;AAEF;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,cAAc,CAuBtF"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAGxC,gFAAgF;AAChF,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,KAAK,EAAE;QACL,6CAA6C;QAC7C,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,oCAAoC;QACpC,WAAW,CAAC,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAA;QAC1C,uEAAuE;QACvE,OAAO,CAAC,EAAE,MAAM,CAAA;KACjB,CAAA;IACD,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAA;IACb,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,gEAAgE;AAChE,MAAM,WAAW,MAAM;IACrB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC7B,gGAAgG;IAChG,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6GAA6G;IAC7G,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,4GAA4G;IAC5G,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,sGAAsG;IACtG,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,wEAAwE;IACxE,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,2EAA2E;IAC3E,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,0GAA0G;IAC1G,KAAK,CAAC,EAAE,eAAe,EAAE,CAAA;IACzB,qFAAqF;IACrF,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B,sGAAsG;IACtG,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,mDAAmD;IACnD,aAAa,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,UAAU,EAAE,OAAO,CAAA;IACnB,oEAAoE;IACpE,YAAY,EAAE,MAAM,CAAA;IACpB,+CAA+C;IAC/C,gBAAgB,EAAE,MAAM,CAAA;IACxB,4DAA4D;IAC5D,eAAe,EAAE,OAAO,CAAA;IACxB,yDAAyD;IACzD,WAAW,EAAE,OAAO,CAAA;IACpB,kEAAkE;IAClE,KAAK,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,WAAW,CAAC,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACzI,+CAA+C;IAC/C,YAAY,EAAE,OAAO,CAAA;CACtB;AAED,wDAAwD;AACxD,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAmB3B,CAAA;AAEF;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,cAAc,CAqCtF"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Session export: a pure projection of the current session's message surface
3
+ * into Markdown or sanitized HTML, with renderer application on top. The
4
+ * extraction reads only the public `session.events` log through the official
5
+ * `deriveEventMessage` projection — the same rule the harness uses to build
6
+ * model requests — so the exported document is reconstructable from the log.
7
+ * Every render application preserves the original text beside the rendered
8
+ * one, keeping the auditable pair intact inside the export document.
9
+ *
10
+ * Host-side module (imports the dsh-session surface projection); the pure
11
+ * Markdown/HTML/sanitize functions stay free of both DOM and I/O.
12
+ * @module dsh-output-styles/export
13
+ */
14
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
15
+ import type { RendererRegistry, RenderedText } from './renderers.ts';
16
+ /** One exported conversation line. */
17
+ export interface ExportLine {
18
+ /** Speaker role: 'user', 'assistant', or 'tool'. */
19
+ readonly role: 'user' | 'assistant' | 'tool';
20
+ /** The original message text (or tool-call/tool-result rendering). */
21
+ readonly text: string;
22
+ /** Tool name for tool lines, otherwise undefined. */
23
+ readonly tool?: string;
24
+ }
25
+ /** A completed export document (renderer application included). */
26
+ export interface ExportDocument {
27
+ readonly plugin: 'dsh-output-styles';
28
+ readonly schema: 'output-export-v1';
29
+ readonly format: 'markdown' | 'html';
30
+ readonly exportedAt: string;
31
+ /** The rendered document text (what the user copies/downloads). */
32
+ readonly text: string;
33
+ readonly lines: readonly ExportLine[];
34
+ readonly rendered: readonly RenderedText[];
35
+ }
36
+ /**
37
+ * Extract the message surface of a session log as plain lines. Tool calls and
38
+ * tool results render as labeled lines so the transcript stays readable; the
39
+ * content is never summarized — this is a projection, not an interpretation.
40
+ * @param events - the session log, in seq order.
41
+ * @returns the conversation lines in surface order.
42
+ */
43
+ export declare function conversationLines(events: readonly SessionEvent[]): ExportLine[];
44
+ /**
45
+ * Sanitize one text for HTML embedding: escape the five markup-significant
46
+ * characters and strip control characters except newline/tab. Pure function,
47
+ * covered by extreme-case tests (tags, null bytes, huge inputs).
48
+ * @param text - the raw text.
49
+ * @returns the HTML-safe text.
50
+ */
51
+ export declare function sanitizeText(text: string): string;
52
+ /**
53
+ * Render one export line as a Markdown block: headings by role and fenced
54
+ * blocks for tool lines so multi-line content never breaks the transcript.
55
+ * @param line - the export line.
56
+ * @returns the Markdown text.
57
+ */
58
+ export declare function lineToMarkdown(line: ExportLine): string;
59
+ /** Build the complete Markdown document for a set of lines. */
60
+ export declare function toMarkdown(lines: readonly ExportLine[]): string;
61
+ /** Build the complete sanitized HTML document for a set of lines. */
62
+ export declare function toHtml(lines: readonly ExportLine[]): string;
63
+ /**
64
+ * Apply the renderer pipeline to every line of a conversation (per-tool and
65
+ * per-content-type rules decide which renderer touches which line) and build
66
+ * the export document. Both the rendered document and the original lines
67
+ * stay inside the returned document, so the render application is auditable.
68
+ * @param registry - the renderer registry.
69
+ * @param lines - the extracted conversation lines.
70
+ * @param format - export format.
71
+ * @param rules - configured style rules.
72
+ * @param now - exportedAt timestamp (injected for deterministic tests).
73
+ * @returns the complete export document.
74
+ */
75
+ export declare function renderExport(registry: RendererRegistry, lines: readonly ExportLine[], format: 'markdown' | 'html', rules: readonly import('./renderers.ts').StyleRule[], now?: Date): ExportDocument;
76
+ //# sourceMappingURL=export.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"export.d.ts","sourceRoot":"","sources":["../../src/export.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAEpE,sCAAsC;AACtC,MAAM,WAAW,UAAU;IACzB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,CAAA;IAC5C,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,mEAAmE;AACnE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAA;IACpC,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAA;IACnC,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAAA;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAA;IACrC,QAAQ,CAAC,QAAQ,EAAE,SAAS,YAAY,EAAE,CAAA;CAC3C;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,GAAG,UAAU,EAAE,CAa/E;AAiDD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQjD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAMvD;AAED,+DAA+D;AAC/D,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAG/D;AAED,qEAAqE;AACrE,wBAAgB,MAAM,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAW3D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,SAAS,UAAU,EAAE,EAC5B,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,KAAK,EAAE,SAAS,OAAO,gBAAgB,EAAE,SAAS,EAAE,EACpD,GAAG,GAAE,IAAiB,GACrB,cAAc,CAoBhB"}