Bladeren bron

feat(session-export): own the download route

imccyu 4 weken geleden
bovenliggende
commit
17c03bbbcc

+ 2 - 2
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

+ 13 - 7
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)
 <a id="use-this-package"></a>
 ## 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-<id>.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-<id>.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.
 
 </details>
 
@@ -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.

+ 13 - 7
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"
 <a id="use-this-package"></a>
 ## 使用本包
 
-当 Web bundle 需要让用户导出会话日志时使用本包。它只由 Web bundle 挂载,与 Host API 代理、命令注册表和对话 UI 并列。常用路径是:挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`——浏览器下载 `dsh-session-<id>.zip`。
+当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`;浏览器会下载 `dsh-session-<id>.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 状态语义。
 
 </details>
 
@@ -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)——本包所属的检索能力家族。

+ 15 - 1
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"
   },

+ 457 - 0
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/<id>/<filename>`; each image referenced
+ * by any included log under `media/<attachmentId>.<ext>` (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<SessionLogExportDeps, 'sessions'>,
+  id: SessionId,
+  signal?: AbortSignal,
+): Promise<void> {
+  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<ImageAttachmentRef['mediaType'], string> = {
+  '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<string, ImageAttachmentRef>): 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<string, ImageAttachmentRef>): 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<string, ImageAttachmentRef> {
+  const refs = new Map<string, ImageAttachmentRef>()
+  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<SessionLogZipEntry> {
+  const media = new Map<string, ImageAttachmentRef>()
+  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>([sessionId])
+    const collect = async function* (
+      nodes: readonly SessionLineageNode[],
+    ): AsyncGenerator<SessionLogZipEntry> {
+      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<Uint8Array>,
+    signal: AbortSignal,
+  ): Promise<void> {
+    signal.throwIfAborted()
+    if (controller.desiredSize === null || controller.desiredSize > 0) return
+    await new Promise<void>((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<Uint8Array>,
+  capacity: ResponseCapacityGate,
+  signal: AbortSignal,
+): Promise<void> {
+  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<Uint8Array>,
+  capacity: ResponseCapacityGate,
+  signal: AbortSignal,
+): Promise<void> {
+  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<Uint8Array> {
+  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<Uint8Array>({
+    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,
+  })
+}

+ 137 - 4
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<Config> = Schema.object({
+  compressionLevel: Schema.number().step(1).min(0).max(9)
+    .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as Schema<SessionLogCompressionLevel>,
+})
+
+interface SessionLogConnection {
+  readonly fetch: {
+    register(route: {
+      readonly path: string
+      readonly methods: readonly ('GET' | 'HEAD')[]
+      readonly fetch: (request: Request) => Promise<Response>
+    }): () => Promise<void>
+  }
+}
 
 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<Response> {
+  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 })
 }

+ 4 - 1
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 = () => {}
 
 /**

+ 3 - 0
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({

+ 3 - 0
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<string, unknown>([

+ 105 - 0
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<void>
+}> {
+  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()
+    }
+  })
+})

+ 28 - 0
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" }
+  ]
+}

+ 23 - 0
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" }
+  ]
+}

+ 3 - 20
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" }
   ]
 }

+ 5 - 1
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 },
+)

+ 19 - 0
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

+ 1 - 1
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" },

+ 1 - 0
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" },