diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml
index ed1b3b512d..c8f260886d 100644
--- a/packages/session-query/session-log-export/README.i18n.yaml
+++ b/packages/session-query/session-log-export/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
-README.md: 455d7a14d0dd8e20347bdd7f8c76f12b88425d26
-README.zh.md: 0c2a03b3460c499192784bddcac06a6400941d44
+README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d
+README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34
diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md
index 455d7a14d0..a46bcdcd6f 100644
--- a/packages/session-query/session-log-export/README.md
+++ b/packages/session-query/session-log-export/README.md
@@ -1,5 +1,5 @@
---
-description: "Web Session-log export for users of the Web bundle: the Session Header download button and /export command, and what to expect from the download dialog."
+description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-session-log-export` gives the Web interface a way to download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. A small dialog reports preparation, download start, or failure, shared by the button and the command. The ZIP is built and streamed by `dsh-host-apiproxy`; this package adds only the browser-side button and command. The download is a browser download: the browser chooses the destination. Setup and usage come first; the implementation internals live in a collapsible developer section below.
+`dsh-session-log-export` lets the Web interface download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow.
## Table of Contents
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
## Use this package
-Use this package when the Web bundle should let users export a session log. It is mounted only by the Web bundle, beside the host API proxy, the command registry, and the conversation UI. The common path is: mount the plugin, then click `Session log` in the Session Header or type `/export` — the browser downloads `dsh-session-.zip`.
+Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then click `Session log` in the Session Header or type `/export`; the browser downloads `dsh-session-.zip`.
### When to choose it
@@ -38,7 +38,13 @@ Choose it for a Web deployment that needs user-facing session export with a visi
name: '@deepseek-ai/dsh-session-log-export'
```
-The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`.
+The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`.
+
+### Configuration
+
+| Field | Default | Meaning |
+|---|---|---|
+| `compressionLevel` | `6` | DEFLATE level from 0 through 9 for each ZIP entry. |
### Command contract
@@ -67,13 +73,13 @@ This section explains how the package wires the export control and points at the
### Design split
-The package has two halves. The host half ([`src/index.ts`](src/index.ts)) registers the `/export` command on `ctx.commands`; the browser half ([`src/client/index.ts`](src/client/index.ts)) provides a `SessionLogDownloadController`, contributes the Header button and shared modal to the `conversation.session.header.utilities` slot, and observes `command/executed` so a successful `/export` in the submitting browser starts the same download. Other tabs still render the durable command row without repeating the browser side effect.
+The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download.
### Download flow
Both entry paths issue a `HEAD` preflight to `GET /api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session.
-The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy/README.md): it flushes a live root session before `readRaw` and streams the ZIP; ZIP generation, raw JSONL/zstd reads, descendants, attachments, backpressure, and HTTP error semantics belong there.
+The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, raw artifact and attachment reads, ZIP generation, and HTTP status semantics.
@@ -84,7 +90,7 @@ The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy
Read these pages when the package-level contract is not enough. They move from the Web control to the host endpoint and the surrounding command and session surfaces.
-- [dsh-host-apiproxy](../../host/apiproxy/README.md) — the host-streamed ZIP download endpoint this package drives.
+- [dsh-client-connection](../../client/connection/README.md) — the authenticated Fetch-route carrier used by the Host endpoint.
- [Commands subsystem reference](../../../docs/subsystems/commands.md) — the human-command registry the `/export` command registers on.
- [dsh-client-ui-commands](../../client/ui-commands/README.md) — the browser command surface that renders and acknowledges `/export`.
- [Session Query package map](../README.md) — the retrieval family this package belongs to.
diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md
index 0c2a03b346..348f6c09c8 100644
--- a/packages/session-query/session-log-export/README.zh.md
+++ b/packages/session-query/session-log-export/README.zh.md
@@ -1,5 +1,5 @@
---
-description: "面向 Web bundle 用户的会话日志导出:Session Header 下载按钮与 /export 命令,以及下载弹窗的预期行为。"
+description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。一个小弹窗报告准备中、开始下载或失败,按钮与命令共用该弹窗。ZIP 由 `dsh-host-apiproxy` 生成并流式传输;本包只提供浏览器侧的按钮与命令。下载是浏览器下载:目标位置由浏览器选择。设置与用法在前;实现内部细节放在下方可折叠的开发者章节中。
+`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。
## 目录
@@ -25,7 +25,7 @@ kind: "package-reference"
## 使用本包
-当 Web bundle 需要让用户导出会话日志时使用本包。它只由 Web bundle 挂载,与 Host API 代理、命令注册表和对话 UI 并列。常用路径是:挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`——浏览器下载 `dsh-session-.zip`。
+当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。
### 何时选择
@@ -38,7 +38,13 @@ kind: "package-reference"
name: '@deepseek-ai/dsh-session-log-export'
```
-Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。
+Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。
+
+### 配置
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `compressionLevel` | `6` | 每个 ZIP 条目的 DEFLATE 级别,范围为 0 到 9。 |
### 命令约定
@@ -67,13 +73,13 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co
### 设计拆分
-本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))在 `ctx.commands` 上注册 `/export` 命令;浏览器半包([`src/client/index.ts`](src/client/index.ts))提供 `SessionLogDownloadController`,把 Header 按钮与共享弹窗贡献到 `conversation.session.header.utilities` slot,并观察 `command/executed`,使提交命令的浏览器在 `/export` 成功后启动同一下载。其他标签页仍渲染持久命令行,但不会重复浏览器副作用。
+本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。
### 下载流程
两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
-Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥有:它在 `readRaw` 前 flush 活动的根会话并流式传输 ZIP;ZIP 生成、原始 JSONL/zstd 读取、子会话、附件、背压与 HTTP 错误语义都属于那里。
+Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义。
@@ -84,7 +90,7 @@ Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥
当包级约定不够用时阅读以下页面。它们从 Web 控制逐步进入 Host 端点与周围的命令和会话表面。
-- [dsh-host-apiproxy](../../host/apiproxy/README.zh.md)——本包驱动的 Host 流式 ZIP 下载端点。
+- [dsh-client-connection](../../client/connection/README.zh.md)——Host 端点使用的认证 Fetch 路由载体。
- [命令子系统参考](../../../docs/subsystems/commands.zh.md)——`/export` 命令注册的用户命令注册表。
- [dsh-client-ui-commands](../../client/ui-commands/README.zh.md)——渲染并确认 `/export` 的浏览器命令表面。
- [会话查询包映射](../README.zh.md)——本包所属的检索能力家族。
diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json
index 8970f18b9b..9d7cd557e4 100644
--- a/packages/session-query/session-log-export/package.json
+++ b/packages/session-query/session-log-export/package.json
@@ -21,20 +21,31 @@
"files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"],
"scripts": { "bundle": "tsdown", "watch": "tsdown --watch" },
"license": "MIT",
+ "dependencies": {
+ "@deepseek-ai/schemastery": "workspace:^",
+ "fflate": "^0.8.2"
+ },
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
- "@deepseek-ai/dsh-invariants": "workspace:^"
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-session-query": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
@@ -46,6 +57,9 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-session-query": "workspace:^",
+ "@deepseek-ai/schemastery": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts
new file mode 100644
index 0000000000..2f7d1fbd11
--- /dev/null
+++ b/packages/session-query/session-log-export/src/archive.ts
@@ -0,0 +1,457 @@
+/**
+ * Host-side session-log download: streams one ZIP archive whose files are the
+ * sessions' stored artifact text verbatim plus every referenced media object.
+ * The root artifact sits under its original base name (`session.jsonl`); each
+ * subagent descendant under `subagents//`; each image referenced
+ * by any included log under `media/.` (content-addressed,
+ * so one archive never duplicates a shared image). No manifest is written —
+ * every file is byte-identical to the backend's durable artifact or attachment
+ * store and self-describing through its own header line or media type. Before
+ * each live session's artifact read, the SessionStore flush barrier makes the
+ * current in-memory log durable; cold sessions need no barrier. Request abort
+ * and response-consumer cancellation share one producer signal and terminate
+ * the active compressor.
+ * Compression runs on the host with fflate's streaming Zip API, so the archive
+ * bytes are produced incrementally and the host never holds the whole archive
+ * in one buffer; production waits for consumer pull whenever the response queue
+ * reaches its byte high-water mark, so a slow consumer bounds accumulation to
+ * the fixed 64 KiB response queue plus one synchronous fflate push.
+ * @module
+ */
+
+import { Zip, ZipDeflate } from 'fflate'
+import type { Context } from '@deepseek-ai/cordis'
+import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
+import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
+import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
+
+/** Valid fflate DEFLATE levels accepted by session-log export. */
+export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
+
+/** Balanced default used when Session export configuration omits a compression level. */
+export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
+
+/** The services a session-log export needs (the live-session store is optional). */
+export interface SessionLogExportDeps {
+ readonly sessionQuery: SessionQueryEngine | undefined
+ readonly sessionPersistence: SessionPersistence | undefined
+ readonly attachments: AttachmentStore | undefined
+ readonly sessions: SessionStore | undefined
+}
+
+/** The export services narrowed to the mounted ones streaming actually reads. */
+export interface SessionLogExportReady {
+ readonly sessionQuery: SessionQueryEngine
+ readonly sessionPersistence: SessionPersistence
+ readonly attachments: AttachmentStore
+ readonly sessions: SessionStore | undefined
+}
+
+/**
+ * Resolve the persistence, session-query, and attachment services a log export needs.
+ * @param ctx - the composed host context.
+ * @returns the export services (absent when the deployment does not mount them).
+ */
+export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
+ return {
+ sessionQuery: ctx.get('sessionQuery'),
+ sessionPersistence: ctx.get('sessionPersistence'),
+ attachments: ctx.get('attachments'),
+ sessions: ctx.get('sessions'),
+ }
+}
+
+/**
+ * Flush one currently live session through the store's authoritative durability
+ * barrier immediately before its raw artifact is read. A cold or absent id has
+ * no in-memory work to flush.
+ * @param deps - export services, including the optional live-session store.
+ * @param id - the session whose artifact is about to be read.
+ * @param signal - optional cancellation observed around the flush barrier.
+ */
+export async function flushLiveSessionLog(
+ deps: Pick,
+ id: SessionId,
+ signal?: AbortSignal,
+): Promise {
+ signal?.throwIfAborted()
+ const sessions = deps.sessions
+ if (sessions === undefined) return
+ const session = sessions.get(id)
+ if (session === undefined) return
+ await sessions.flush(session)
+ signal?.throwIfAborted()
+}
+
+/** One exported file: a stored artifact text or one referenced media object. */
+export type SessionLogZipEntry =
+ | { readonly path: string; readonly content: string }
+ | { readonly path: string; readonly data: Uint8Array }
+
+/** Zip extension for each accepted raster media type. */
+const MEDIA_TYPE_EXTENSIONS: Record = {
+ 'image/png': 'png',
+ 'image/jpeg': 'jpg',
+ 'image/webp': 'webp',
+ 'image/gif': 'gif',
+}
+
+/**
+ * The zip path for one media object: content-addressed by the opaque
+ * attachment id so shared images land once and the id in the log maps back to
+ * the archive entry without a manifest.
+ * @param ref - the durable reference from a session log.
+ * @returns the archive path.
+ */
+function mediaEntryPath(ref: ImageAttachmentRef): string {
+ return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
+}
+
+/**
+ * Collect every image reference inside one content array, descending into
+ * nested tool results the way the live attachment route does.
+ * @param content - an event content array (or nested tool-result content).
+ * @param refs - the dedupe map being filled (keyed by attachment id).
+ */
+function collectImageRefs(content: unknown, refs: Map): void {
+ if (!Array.isArray(content)) return
+ const pending: unknown[] = []
+ for (const item of content) pending.push(item)
+ while (pending.length > 0) {
+ const value = pending.pop()
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
+ const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
+ if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
+ const ref = block.attachment as ImageAttachmentRef
+ refs.set(String(ref.attachmentId), ref)
+ }
+ if (Array.isArray(block.content)) {
+ for (const item of block.content) pending.push(item)
+ }
+ }
+}
+
+/**
+ * Collect every image reference one session event carries, across the same
+ * carriers the live attachment route scans (direct content, message content,
+ * inserted messages, and completed assistant chunk blocks).
+ * @param event - one parsed JSONL event object.
+ * @param refs - the dedupe map being filled (keyed by attachment id).
+ */
+function collectEventImageRefs(event: unknown, refs: Map): void {
+ const data = (event as { data?: unknown }).data
+ if (typeof data !== 'object' || data === null) return
+ const carrier = data as {
+ content?: unknown
+ message?: { content?: unknown }
+ inserted?: Array<{ content?: unknown }>
+ chunk?: { type?: unknown; block?: unknown }
+ }
+ collectImageRefs(carrier.content, refs)
+ if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
+ if (carrier.inserted !== undefined) {
+ for (const message of carrier.inserted) collectImageRefs(message.content, refs)
+ }
+ if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
+}
+
+/**
+ * Collect the distinct media references one stored artifact text names.
+ * Lines that fail to parse cannot reference media and are skipped (the
+ * artifact text itself is exported verbatim regardless).
+ * @param content - the stored artifact text.
+ * @returns the dedupe map keyed by attachment id.
+ */
+function imageRefsInArtifact(content: string): Map {
+ const refs = new Map()
+ for (const line of content.split('\n')) {
+ if (line === '') continue
+ let event: unknown
+ try {
+ event = JSON.parse(line)
+ } catch {
+ continue
+ }
+ collectEventImageRefs(event, refs)
+ }
+ return refs
+}
+
+/**
+ * One safe zip path segment from an untrusted session id. Session ids are
+ * host-controlled, but the brand allows any non-empty string, so `../`, dot
+ * segments, and separator characters are neutralized before they can shape
+ * archive entries. Distinct ids may collapse onto one segment (id collision
+ * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
+ * @param id - the raw session id.
+ * @returns a filesystem-safe single path segment.
+ */
+function safeSessionIdSegment(id: string): string {
+ return id.replace(/[^A-Za-z0-9_-]/g, '_')
+}
+
+/**
+ * The export archive filename for one root session.
+ * @param sessionId - the root session id (sanitized to one safe path segment).
+ * @returns the attachment filename for the session's export archive.
+ */
+export function sessionLogZipFilename(sessionId: string): string {
+ return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
+}
+
+/**
+ * Yield the export entries in zip order: the preloaded root artifact first,
+ * then every subagent descendant in lineage order (each flushed when live,
+ * read from the persistence backend right before it is yielded, and dropped
+ * after the consumer moves on), then every distinct media object referenced by any of
+ * the included logs (read and verified from the attachment store, one archive
+ * entry per attachment id). The host holds at most one descendant's artifact
+ * text and one media object at a time beyond the root.
+ * @param deps - the mounted export services (the caller answered 500 before this runs).
+ * @param root - the already-read root artifact (read by the caller so the
+ * missing-session path can answer cleanly before streaming starts).
+ * @param sessionId - the root session id.
+ * @param includeDescendants - whether to include every subagent descendant.
+ * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
+ * @returns the export entries in zip order.
+ */
+export async function* sessionLogZipEntries(
+ deps: SessionLogExportReady,
+ root: SessionRawArtifact,
+ sessionId: SessionId,
+ includeDescendants: boolean,
+ signal?: AbortSignal,
+): AsyncGenerator {
+ const media = new Map()
+ const rememberMedia = (content: string): void => {
+ for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
+ }
+ rememberMedia(root.content)
+ yield { path: root.filename, content: root.content }
+ if (includeDescendants) {
+ const seen = new Set([sessionId])
+ const collect = async function* (
+ nodes: readonly SessionLineageNode[],
+ ): AsyncGenerator {
+ for (const node of nodes) {
+ signal?.throwIfAborted()
+ const id = node.session.header.id
+ if (seen.has(id)) continue
+ seen.add(id)
+ await flushLiveSessionLog(deps, id, signal)
+ const raw = await deps.sessionPersistence.readRaw(id, signal)
+ signal?.throwIfAborted()
+ if (raw === undefined) {
+ throw new Error(`subagent "${id}" has no stored log artifact`)
+ }
+ rememberMedia(raw.content)
+ yield {
+ path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
+ content: raw.content,
+ }
+ yield* collect(node.descendants)
+ }
+ }
+ const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
+ signal?.throwIfAborted()
+ yield* collect(lineage.descendants)
+ }
+ for (const ref of media.values()) {
+ signal?.throwIfAborted()
+ const stored = await deps.attachments.readImage(ref, signal)
+ signal?.throwIfAborted()
+ yield { path: mediaEntryPath(ref), data: stored.data }
+ }
+}
+
+/** How many code units of artifact text one zip push carries (bounded encode memory). */
+const PUSH_CHUNK_CODE_UNITS = 1 << 16
+
+/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
+const PUSH_CHUNK_BYTES = 1 << 16
+
+/** Byte capacity retained by the response stream before ZIP production waits for pull. */
+const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
+
+/** One producer waiter released only when ReadableStream pull restores capacity. */
+class ResponseCapacityGate {
+ private releasePending: (() => void) | undefined
+
+ /**
+ * Wait until the response queue has positive byte capacity or cancellation wins.
+ * @param controller - response controller whose desired size owns capacity.
+ * @param signal - combined request/consumer cancellation.
+ */
+ async wait(
+ controller: ReadableStreamDefaultController,
+ signal: AbortSignal,
+ ): Promise {
+ signal.throwIfAborted()
+ if (controller.desiredSize === null || controller.desiredSize > 0) return
+ await new Promise((resolve) => {
+ const release = (): void => {
+ this.releasePending = undefined
+ signal.removeEventListener('abort', release)
+ resolve()
+ }
+ this.releasePending = release
+ signal.addEventListener('abort', release, { once: true })
+ })
+ signal.throwIfAborted()
+ }
+
+ /** Release the current producer waiter after a consumer pull. */
+ pulled(): void {
+ this.releasePending?.()
+ }
+}
+
+/**
+ * Push one media object's bytes into a deflate stream in bounded chunks,
+ * waiting for consumer capacity between chunks like the artifact path does.
+ * @param deflate - the zip entry's deflate stream.
+ * @param data - the stored image bytes.
+ * @param controller - response queue controller.
+ * @param capacity - pull-driven response-capacity gate.
+ * @param signal - cancellation; throws when aborted.
+ */
+async function pushBinaryChunks(
+ deflate: ZipDeflate,
+ data: Uint8Array,
+ controller: ReadableStreamDefaultController,
+ capacity: ResponseCapacityGate,
+ signal: AbortSignal,
+): Promise {
+ let offset = 0
+ do {
+ signal.throwIfAborted()
+ const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
+ const finalChunk = end >= data.byteLength
+ deflate.push(data.subarray(offset, end), finalChunk)
+ offset = end
+ await capacity.wait(controller, signal)
+ } while (offset < data.byteLength)
+}
+
+/**
+ * Push one artifact's text into a deflate stream in bounded chunks, never
+ * splitting a surrogate pair across a chunk boundary (a lone high surrogate
+ * re-encodes as U+FFFD and would silently corrupt the exported artifact).
+ * @param deflate - the zip entry's deflate stream.
+ * @param content - the artifact text verbatim.
+ * @param controller - response queue controller.
+ * @param capacity - pull-driven response-capacity gate.
+ * @param signal - cancellation; throws when aborted.
+ */
+async function pushArtifactChunks(
+ deflate: ZipDeflate,
+ content: string,
+ controller: ReadableStreamDefaultController,
+ capacity: ResponseCapacityGate,
+ signal: AbortSignal,
+): Promise {
+ const encoder = new TextEncoder()
+ let offset = 0
+ let finalChunk: boolean
+ do {
+ signal.throwIfAborted()
+ let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
+ if (end < content.length && end - offset > 1) {
+ // Back off one code unit when the boundary lands inside a surrogate
+ // pair: the pair then starts the next chunk whole.
+ const last = content.charCodeAt(end - 1)
+ if (last >= 0xd800 && last <= 0xdbff) end -= 1
+ }
+ finalChunk = end >= content.length
+ deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
+ offset = end
+ await capacity.wait(controller, signal)
+ } while (!finalChunk)
+}
+
+/**
+ * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
+ * read and validated by the caller before this is called (missing root or
+ * missing services answer cleanly before any byte is produced); each entry is
+ * then encoded and deflated in bounded chunks as it is produced, so the
+ * archive bytes arrive incrementally. A descendant that fails to read errors
+ * the stream (fail-loud, never silent under-export).
+ * @param deps - the mounted export services (the caller answered 500 before this runs).
+ * @param root - the already-read root artifact (first zip entry).
+ * @param sessionId - the root session id.
+ * @param includeDescendants - whether to include every subagent descendant.
+ * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
+ * @param signal - request cancellation combined with response-consumer cancellation.
+ * @returns the zip byte stream.
+ */
+export function streamSessionLogZip(
+ deps: SessionLogExportReady,
+ root: SessionRawArtifact,
+ sessionId: SessionId,
+ includeDescendants: boolean,
+ compressionLevel: SessionLogCompressionLevel,
+ signal: AbortSignal,
+): ReadableStream {
+ const consumerAbort = new AbortController()
+ const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
+ let zip: Zip | undefined
+ let zipTerminated = false
+ const capacity = new ResponseCapacityGate()
+ const terminateZip = (): void => {
+ if (zip === undefined || zipTerminated) return
+ zipTerminated = true
+ zip.terminate()
+ }
+ return new ReadableStream({
+ start(controller) {
+ // fflate invokes the callback synchronously per compressed chunk, so a
+ // single push can enqueue ahead of a slow consumer; the capacity gate
+ // waits for pull between pushes once the byte queue is full, bounding
+ // accumulation to the queue high-water mark plus one synchronous push.
+ const archive = new Zip((error, data, final) => {
+ /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
+ if (error) {
+ controller.error(error)
+ return
+ }
+ /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
+ if (data.byteLength > 0) controller.enqueue(data)
+ if (final) controller.close()
+ })
+ zip = archive
+ void (async () => {
+ try {
+ for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
+ const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
+ archive.add(deflate)
+ if ('content' in entry) {
+ await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
+ } else {
+ await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
+ }
+ }
+ archive.end()
+ } catch (error) {
+ // A mid-stream failure (missing descendant, cancellation, read
+ // error) must fail the download rather than ship a truncated archive.
+ /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
+ terminateZip()
+ controller.error(error instanceof Error ? error : new Error(String(error)))
+ }
+ })()
+ },
+ pull() {
+ capacity.pulled()
+ },
+ cancel(reason) {
+ consumerAbort.abort(
+ reason instanceof Error ? reason : new Error('session log export stream cancelled'),
+ )
+ terminateZip()
+ },
+ }, {
+ highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
+ size: chunk => chunk.byteLength,
+ })
+}
diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts
index d26ffa9e77..4be729d3a0 100644
--- a/packages/session-query/session-log-export/src/index.ts
+++ b/packages/session-query/session-log-export/src/index.ts
@@ -1,10 +1,63 @@
-/** Web Session-log download command over the host endpoint owned by ApiProxy. */
+/** Session-log download command and Host-owned streaming route. */
import type { Context } from '@deepseek-ai/cordis'
+import Schema from '@deepseek-ai/schemastery'
+import type {} from '@deepseek-ai/dsh-attachment'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
+import {
+ DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
+ flushLiveSessionLog,
+ sessionLogExportDeps,
+ sessionLogZipFilename,
+ streamSessionLogZip,
+ type SessionLogCompressionLevel,
+ type SessionLogExportReady,
+} from './archive.ts'
+
+export {
+ DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
+ flushLiveSessionLog,
+ sessionLogExportDeps,
+ sessionLogZipEntries,
+ sessionLogZipFilename,
+ streamSessionLogZip,
+} from './archive.ts'
+export type {
+ SessionLogCompressionLevel,
+ SessionLogExportDeps,
+ SessionLogExportReady,
+ SessionLogZipEntry,
+} from './archive.ts'
export const name = 'session-log-download'
-export const inject = ['commands']
+export const inject = ['commands', 'connection']
+
+/** Stable browser download path retained across the transport migration. */
+export const SESSION_LOG_EXPORT_PATH = '/api/session.export'
+
+/** Session-log archive policy. */
+export interface Config {
+ /** DEFLATE level for each ZIP entry. @default 6 */
+ readonly compressionLevel?: SessionLogCompressionLevel
+}
+
+/** Validate Session-log archive configuration. */
+export const Config: Schema = Schema.object({
+ compressionLevel: Schema.number().step(1).min(0).max(9)
+ .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as Schema,
+})
+
+interface SessionLogConnection {
+ readonly fetch: {
+ register(route: {
+ readonly path: string
+ readonly methods: readonly ('GET' | 'HEAD')[]
+ readonly fetch: (request: Request) => Promise
+ }): () => Promise
+ }
+}
const REQUESTED: CommandResult = {
kind: 'success',
@@ -12,10 +65,11 @@ const REQUESTED: CommandResult = {
}
/**
- * Register the Web-only `/export` command that the browser download plugin observes.
+ * Register the Web-only `/export` command and authenticated ZIP download route.
* @param ctx - Host context carrying the human-command registry.
+ * @param config - resolved compression policy.
*/
-export function apply(ctx: Context): void {
+export function apply(ctx: Context, config: Config = {}): void {
ctx.effect(() => ctx.commands.register({
name: 'export',
description: 'Download this Session log as a ZIP archive',
@@ -23,4 +77,83 @@ export function apply(ctx: Context): void {
? REQUESTED
: { kind: 'error', text: 'The Web /export command does not accept a path.' }),
}), 'session-log-download: command')
+ connectionOf(ctx).fetch.register({
+ path: SESSION_LOG_EXPORT_PATH,
+ methods: ['GET', 'HEAD'],
+ fetch: request => sessionLogExportResponse(
+ ctx,
+ request,
+ config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
+ ),
+ })
+}
+
+function connectionOf(ctx: Context): SessionLogConnection {
+ return Reflect.get(ctx, 'connection') as SessionLogConnection
+}
+
+async function sessionLogExportResponse(
+ ctx: Context,
+ request: Request,
+ compressionLevel: SessionLogCompressionLevel,
+): Promise {
+ const url = new URL(request.url)
+ const query = Object.fromEntries(url.searchParams)
+ const sessionIdValue = query['sessionId']
+ const descendantsValue = query['includeDescendants']
+ if (sessionIdValue === undefined || sessionIdValue.length === 0
+ || (descendantsValue !== undefined && descendantsValue !== 'true' && descendantsValue !== 'false')) {
+ return new Response('missing or invalid sessionId query parameter', { status: 400 })
+ }
+ const sessionId = SessionId(sessionIdValue)
+ const deps = sessionLogExportDeps(ctx)
+ if (deps.sessionQuery === undefined
+ || deps.sessionPersistence === undefined
+ || deps.attachments === undefined) {
+ return new Response(
+ 'session log export is unavailable: missing session-query, session-persistence, or attachments service',
+ { status: 500 },
+ )
+ }
+ if (!deps.sessionPersistence.supportsRawArtifacts) {
+ return new Response(
+ 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
+ { status: 501 },
+ )
+ }
+ const ready: SessionLogExportReady = {
+ sessionQuery: deps.sessionQuery,
+ sessionPersistence: deps.sessionPersistence,
+ attachments: deps.attachments,
+ sessions: deps.sessions,
+ }
+ let root: SessionRawArtifact | undefined
+ try {
+ await flushLiveSessionLog(deps, sessionId, request.signal)
+ root = await deps.sessionPersistence.readRaw(sessionId, request.signal)
+ request.signal.throwIfAborted()
+ } catch {
+ request.signal.throwIfAborted()
+ return new Response('session log export failed to prepare the stored artifact', { status: 500 })
+ }
+ if (root === undefined) return new Response('session not found', { status: 404 })
+ const response = new Response(
+ streamSessionLogZip(
+ ready,
+ root,
+ sessionId,
+ descendantsValue === 'true',
+ compressionLevel,
+ request.signal,
+ ),
+ {
+ headers: {
+ 'content-type': 'application/zip',
+ 'content-disposition': `attachment; filename="${sessionLogZipFilename(sessionId)}"`,
+ },
+ },
+ )
+ if (request.method === 'GET') return response
+ await response.body?.cancel()
+ return new Response(null, { status: response.status, headers: response.headers })
}
diff --git a/packages/session-query/session-log-export/src/invariant.ts b/packages/session-query/session-log-export/src/invariant.ts
index d38ea70699..0af82953e1 100644
--- a/packages/session-query/session-log-export/src/invariant.ts
+++ b/packages/session-query/session-log-export/src/invariant.ts
@@ -9,7 +9,10 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-session-log-export'
export const name = 'session-export-invariant'
export const inject = ['invariants']
-/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */
+/**
+ * No runtime invariant: Connection and the command registry own both
+ * registrations, while each export reads authoritative Session services.
+ */
const install: InvariantInstaller = () => {}
/**
diff --git a/packages/session-query/session-log-export/tests/command.client.spec.ts b/packages/session-query/session-log-export/tests/command.client.spec.ts
index 3fa60909f1..16936afbe6 100644
--- a/packages/session-query/session-log-export/tests/command.client.spec.ts
+++ b/packages/session-query/session-log-export/tests/command.client.spec.ts
@@ -13,6 +13,9 @@ describe('/export Web download command', () => {
return () => { descriptor = undefined }
},
} as never)
+ ctx.provide('connection', {
+ fetch: { register: () => () => Promise.resolve() },
+ } as never)
const fiber = await ctx.plugin(SessionLogDownload)
expect(descriptor).toMatchObject({
diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts
index 2a993f52ef..a1e58b11ae 100644
--- a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts
+++ b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts
@@ -34,6 +34,9 @@ describe('session-log-download real Loader composition', () => {
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
+ context.provide('connection', {
+ fetch: { register: () => () => Promise.resolve() },
+ } as never)
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map([
diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts
new file mode 100644
index 0000000000..213ad852a6
--- /dev/null
+++ b/packages/session-query/session-log-export/tests/route.host.spec.ts
@@ -0,0 +1,105 @@
+import { Context } from '@deepseek-ai/cordis'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
+import { strFromU8, unzipSync } from 'fflate'
+import { describe, expect, it } from 'vitest'
+import {
+ Config,
+ SESSION_LOG_EXPORT_PATH,
+ apply,
+ inject,
+} from '../src/index.ts'
+
+const sid = (value: string): SessionId => value as SessionId
+
+function artifact(id: string): SessionRawArtifact {
+ const header: SessionHeader = {
+ version: 0,
+ id: sid(id),
+ createdAt: 1,
+ cwd: '/workspace',
+ delegationDepth: 0,
+ }
+ return {
+ meta: header,
+ filename: 'session.jsonl',
+ content: `${JSON.stringify({ type: 'session', ...header })}\n`,
+ }
+}
+
+async function mounted(withServices: boolean): Promise<{
+ readonly connection: HostConnectionService
+ readonly dispose: () => Promise
+}> {
+ const ctx = new Context()
+ ctx.provide('commands', { register: () => () => {} } as never)
+ if (withServices) {
+ ctx.provide('sessionQuery', {
+ traceSession: async () => ({ descendants: [] }),
+ } as never)
+ ctx.provide('sessionPersistence', {
+ supportsRawArtifacts: true,
+ readRaw: async (id: SessionId) => artifact(String(id)),
+ } as never)
+ ctx.provide('attachments', {
+ readImage: async () => { throw new Error('fixture has no images') },
+ } as never)
+ }
+ const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
+ const fiber = ctx.plugin({ inject: [...inject], apply })
+ await fiber
+ return { connection, dispose: () => fiber.dispose() }
+}
+
+describe('Session log export Fetch route', () => {
+ it('registers one GET/HEAD route and removes it with the plugin fiber', async () => {
+ const { connection, dispose } = await mounted(true)
+ const fallback = { fetch: async () => new Response('fallback', { status: 418 }) }
+ const shared = connection.createSharedFetchHandler('/api', fallback)
+
+ const response = await shared.fetch(new Request(
+ `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`,
+ ))
+ expect(response.status).toBe(200)
+ expect(response.headers.get('content-type')).toBe('application/zip')
+ const files = unzipSync(new Uint8Array(await response.arrayBuffer()))
+ expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"')
+
+ const head = await shared.fetch(new Request(
+ `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' },
+ ))
+ expect(head.status).toBe(200)
+ expect(head.body).toBeNull()
+
+ await dispose()
+ expect((await shared.fetch(new Request(
+ `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`,
+ ))).status).toBe(418)
+ })
+
+ it('validates the query before reporting missing export services', async () => {
+ const { connection, dispose } = await mounted(false)
+ const shared = connection.createSharedFetchHandler('/api', {
+ fetch: async () => new Response('fallback', { status: 418 }),
+ })
+ expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400)
+ expect((await shared.fetch(new Request(
+ `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`,
+ ))).status).toBe(400)
+ expect((await shared.fetch(new Request(
+ `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`,
+ ))).status).toBe(500)
+ await dispose()
+ })
+
+ it('validates the compression level', () => {
+ expect(Config({})).toEqual({ compressionLevel: 6 })
+ expect(Config({ compressionLevel: 0 })).toEqual({ compressionLevel: 0 })
+ expect(Config({ compressionLevel: 9 })).toEqual({ compressionLevel: 9 })
+ for (const compressionLevel of [-1, 10, 1.5]) {
+ expect(() => Config({ compressionLevel } as never)).toThrow()
+ }
+ })
+})
diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json
new file mode 100644
index 0000000000..6e3e746c4c
--- /dev/null
+++ b/packages/session-query/session-log-export/tsconfig.client.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+ },
+ "files": [
+ "src/client/Dialog.tsx",
+ "src/client/HeaderAction.tsx",
+ "src/client/controller.ts",
+ "src/client/index.ts",
+ "src/client/locales.ts",
+ "src/css-modules.d.ts"
+ ],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../client/locale" },
+ { "path": "../../client/store" },
+ { "path": "../../client/ui-commands" },
+ { "path": "../../client/ui-conversation" },
+ { "path": "../../client/ui-primitives" },
+ { "path": "../../client/ui-renderer" },
+ { "path": "../../client/ui-session" },
+ { "path": "../../client/ui-slots" },
+ { "path": "../../core/session" }
+ ]
+}
diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json
new file mode 100644
index 0000000000..982a1360cb
--- /dev/null
+++ b/packages/session-query/session-log-export/tsconfig.host.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+ },
+ "files": [
+ "src/archive.ts",
+ "src/index.ts",
+ "src/invariant.ts"
+ ],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../../vendor/schemastery" },
+ { "path": "../../attachment/attachment" },
+ { "path": "../../core/session" },
+ { "path": "../../interaction/commands" },
+ { "path": "../../runtime-diagnostics/invariants" },
+ { "path": "../../session/session-persistence" },
+ { "path": "../session-query" }
+ ]
+}
diff --git a/packages/session-query/session-log-export/tsconfig.json b/packages/session-query/session-log-export/tsconfig.json
index 46ad790e94..2a0b0e33f7 100644
--- a/packages/session-query/session-log-export/tsconfig.json
+++ b/packages/session-query/session-log-export/tsconfig.json
@@ -1,24 +1,7 @@
{
- "extends": "../../../tsconfig.base.client.json",
- "compilerOptions": {
- "rootDir": "src",
- "outDir": "lib/types"
- },
- "include": [
- "src"
- ],
+ "files": [],
"references": [
- { "path": "../../../vendor/cordis" },
- { "path": "../../interaction/commands" },
- { "path": "../../client/locale" },
- { "path": "../../client/store" },
- { "path": "../../core/session" },
- { "path": "../../client/ui-commands" },
- { "path": "../../client/ui-conversation" },
- { "path": "../../client/ui-primitives" },
- { "path": "../../client/ui-renderer" },
- { "path": "../../client/ui-session" },
- { "path": "../../client/ui-slots" },
- { "path": "../../runtime-diagnostics/invariants" }
+ { "path": "./tsconfig.host.json" },
+ { "path": "./tsconfig.client.json" }
]
}
diff --git a/packages/session-query/session-log-export/tsdown.config.ts b/packages/session-query/session-log-export/tsdown.config.ts
index dba3a1ab45..441876ff7b 100644
--- a/packages/session-query/session-log-export/tsdown.config.ts
+++ b/packages/session-query/session-log-export/tsdown.config.ts
@@ -1,3 +1,7 @@
import { clientBundle } from '../../client/tsdown.client.ts'
-export default clientBundle('@deepseek-ai/dsh-session-log-export', ['lib/types/index.js', 'lib/types/invariant.js'])
+export default clientBundle(
+ '@deepseek-ai/dsh-session-log-export',
+ ['lib/types/index.js', 'lib/types/invariant.js'],
+ { hostPhase: true },
+)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 55ffa061ae..a021582cd9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7047,6 +7047,13 @@ importers:
version: link:../../subagent/subagent
packages/session-query/session-log-export:
+ dependencies:
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
+ fflate:
+ specifier: ^0.8.2
+ version: 0.8.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -7057,6 +7064,12 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
+ '@deepseek-ai/dsh-attachment':
+ specifier: workspace:^
+ version: link:../../attachment/attachment
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:^
+ version: link:../../client/connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../../client/locale
@@ -7090,6 +7103,12 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-persistence':
+ specifier: workspace:^
+ version: link:../../session/session-persistence
+ '@deepseek-ai/dsh-session-query':
+ specifier: workspace:^
+ version: link:../session-query
'@types/react':
specifier: ~18.3.1
version: 18.3.31
diff --git a/tsconfig.client.json b/tsconfig.client.json
index bd8d98e4b8..8b70f8d2c7 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -90,7 +90,7 @@
{ "path": "./packages/client/ui-settings-plugins" },
{ "path": "./packages/client/ui-user-questions" },
{ "path": "./packages/client/ui-trajectory" },
- { "path": "./packages/session-query/session-log-export" },
+ { "path": "./packages/session-query/session-log-export/tsconfig.client.json" },
{ "path": "./packages/client/ui-theme" },
{ "path": "./packages/client/ui-settings" },
{ "path": "./packages/client/ui-settings-general" },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index d4cd66e776..a707fa8df7 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -160,6 +160,7 @@
{ "path": "./packages/session/session-stats" },
{ "path": "./packages/session-query/session-query" },
{ "path": "./packages/session-query/session-query-sqlite" },
+ { "path": "./packages/session-query/session-log-export/tsconfig.host.json" },
{ "path": "./packages/settings/settings" },
{ "path": "./packages/settings/settings-file" },
{ "path": "./packages/credentials/credentials" },