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