@deepseek-ai/dsh-session-log-export 0.1.2-alpha.5 → 0.1.3-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
5
- README.md: a096cea71b2e0bed4882a6e8d23c8f35dc3d0fb7
6
- README.zh.md: 0c2da35ae0be9420cfd6ff076dd038c3a8edf2cb
5
+ README.md: db26d9c405b3408cc24f27e5947c94ccaa2393e8
6
+ README.zh.md: 4e2eb00de94dd6520af8c1836155a00d187ef99f
package/README.md CHANGED
@@ -29,7 +29,7 @@ Use this package when the Web bundle should let users export a session log. It r
29
29
 
30
30
  ### When to choose it
31
31
 
32
- Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write, and it requires the shipped JSONL provider's per-Session raw artifact in plaintext or zstd form.
32
+ Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported.
33
33
 
34
34
  ### Composition
35
35
 
@@ -55,7 +55,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u
55
55
 
56
56
  ### What to expect
57
57
 
58
- The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush.
58
+ The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/<attachmentId>.<ext>`, and generic files use `files/<digest-prefix>/<digest>/<name>`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full.
59
59
 
60
60
  ### Failures
61
61
 
@@ -79,7 +79,7 @@ The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) regis
79
79
 
80
80
  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.
81
81
 
82
- 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.
82
+ 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, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics.
83
83
 
84
84
  </details>
85
85
 
@@ -121,7 +121,6 @@ None. The log-only command lifecycle and browser download do not change the deri
121
121
 
122
122
  These limits define when this package is a poor fit or needs special operational care. They are current package constraints, not a task backlog.
123
123
 
124
- - **Requires a per-Session raw artifact** — the download endpoint reads the shipped JSONL provider's plaintext or zstd artifact; an out-of-tree provider without a raw artifact cannot serve this route.
125
124
  - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned.
126
125
  - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog.
127
126
 
package/README.zh.md CHANGED
@@ -29,7 +29,7 @@ kind: "package-reference"
29
29
 
30
30
  ### 何时选择
31
31
 
32
- 为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入,并且它要求随产品交付的 JSONL provider 提供逐 Session 的明文或 zstd 原始产物。
32
+ 为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。
33
33
 
34
34
  ### 组合
35
35
 
@@ -55,7 +55,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
55
55
 
56
56
  ### 预期行为
57
57
 
58
- 弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush
58
+ 弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/<attachmentId>.<ext>`,通用文件使用 `files/<digest-prefix>/<digest>/<name>`。通用文件以有界分块读取并压缩,因此导出大文件时不会把它完整缓冲进内存。
59
59
 
60
60
  ### 失败
61
61
 
@@ -79,7 +79,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
79
79
 
80
80
  两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
81
81
 
82
- Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义。
82
+ Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。
83
83
 
84
84
  </details>
85
85
 
@@ -121,7 +121,6 @@ Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/
121
121
 
122
122
  这些限制说明本包何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
123
123
 
124
- - **要求逐 Session 原始产物**——下载端点读取随产品交付的 JSONL provider 所提供的明文或 zstd 产物;没有原始产物的仓库外 provider 无法服务该 route。
125
124
  - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。
126
125
  - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
127
126
 
package/lib/client.js CHANGED
@@ -169,7 +169,7 @@ window.__ModuleLoader__.load({
169
169
  }
170
170
  //#endregion
171
171
  //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/session-query/session-log-export/src/client/HeaderAction.module.css.mjs
172
- const css = ".nL4_yW_sessionLogButton{border:.5px solid var(--dsw-alias-border-l4);min-width:111px;height:32px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);cursor:pointer;background:0 0;border-radius:18px;justify-content:center;align-items:center;gap:4px;padding:6px 12px;font-size:13px;font-weight:400;line-height:20px;display:inline-flex}.nL4_yW_sessionLogButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.nL4_yW_sessionLogButton:disabled{color:var(--dsw-alias-label-dimmed);cursor:wait}.nL4_yW_sessionLogButton span,.nL4_yW_sessionLogButton svg{flex:none}.nL4_yW_sessionLogButton span{white-space:nowrap}";
172
+ const css = ".nL4_yW_sessionLogButton{border:.5px solid var(--dsw-alias-border-l4);height:26px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);cursor:pointer;background:0 0;border-radius:13px;justify-content:center;align-items:center;gap:5px;padding:5px 10px;font-size:11px;font-weight:400;line-height:16px;display:inline-flex}.nL4_yW_sessionLogButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.nL4_yW_sessionLogButton:disabled{color:var(--dsw-alias-label-dimmed);cursor:wait}.nL4_yW_sessionLogButton span,.nL4_yW_sessionLogButton svg{flex:none}.nL4_yW_sessionLogButton svg{color:var(--dsw-alias-label-secondary)}.nL4_yW_sessionLogButton span{white-space:nowrap}";
173
173
  const tagId = "@deepseek-ai/dsh-session-log-export/HeaderAction.module.css";
174
174
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
175
175
  const tag = document.createElement("style");
@@ -197,7 +197,7 @@ window.__ModuleLoader__.load({
197
197
  onClick: () => {
198
198
  request(sessionId);
199
199
  },
200
- children: [(0, react_jsx_runtime.jsx)("span", { children: t("header.action") }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDownloadOutline16, { size: 12 })]
200
+ children: [(0, react_jsx_runtime.jsx)("span", { children: t("header.action") }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDownloadOutline16, { size: 10 })]
201
201
  }), (0, react_jsx_runtime.jsx)(SessionLogDownloadDialog, { ...props })] });
202
202
  }
203
203
  //#endregion
package/lib/index.js CHANGED
@@ -1,20 +1,25 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
2
  import { brandString } from "@deepseek-ai/dsh-brand";
3
3
  import { Zip, ZipDeflate } from "fflate";
4
+ import { SESSION_FORMAT_VERSION } from "@deepseek-ai/dsh-session";
5
+ import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
6
+ import { SessionPersistenceNotFoundError } from "@deepseek-ai/dsh-session-persistence";
4
7
  //#region lib/types/archive.js
5
8
  /**
6
9
  * Host-side session-log download: streams one ZIP archive whose files are the
7
- * sessions' stored artifact text verbatim plus every referenced media object.
8
- * The root artifact sits under its original base name (`session.jsonl`); each
9
- * subagent descendant under `subagents/<id>/<filename>`; each image referenced
10
- * by any included log under `media/<attachmentId>.<ext>` (content-addressed,
11
- * so one archive never duplicates a shared image). No manifest is written —
12
- * every file is byte-identical to the backend's durable artifact or attachment
13
- * store and self-describing through its own header line or media type. Before
14
- * each live session's artifact read, the SessionStore flush barrier makes the
15
- * current in-memory log durable; cold sessions need no barrier. Request abort
16
- * and response-consumer cancellation share one producer signal and terminate
17
- * the active compressor.
10
+ * sessions' logical session logs plus every referenced attachment. Each log
11
+ * is read through a persistence read handle and serialized here as canonical
12
+ * JSONL one header line, then one line per validated event — so every
13
+ * backend (JSONL, SQLite, future) exports identically. The root log uses the
14
+ * current generation's canonical `session[.vN].jsonl` name; each subagent
15
+ * descendant uses `subagents/<id>/session[.vN].jsonl`; each image referenced by any included log
16
+ * under `media/<attachmentId>.<ext>` (content-addressed, so one archive never
17
+ * duplicates a shared image); each generic file sits under
18
+ * `files/<prefix>/<digest>/<name>` and streams from the attachment store. No
19
+ * manifest is written. Before each live session's log read, the SessionStore
20
+ * flush barrier makes the current in-memory log durable; cold sessions need no
21
+ * barrier. Request abort and response-consumer cancellation share one producer
22
+ * signal and terminate the active compressor.
18
23
  * Compression runs on the host with fflate's streaming Zip API, so the archive
19
24
  * bytes are produced incrementally and the host never holds the whole archive
20
25
  * in one buffer; production waits for consumer pull whenever the response queue
@@ -39,7 +44,7 @@ function sessionLogExportDeps(ctx) {
39
44
  }
40
45
  /**
41
46
  * Flush one currently live session through the store's authoritative durability
42
- * barrier immediately before its raw artifact is read. A cold or absent id has
47
+ * barrier immediately before its logical log is read. A cold or absent id has
43
48
  * no in-memory work to flush.
44
49
  * @param deps - export services, including the optional live-session store.
45
50
  * @param id - the session whose artifact is about to be read.
@@ -54,6 +59,57 @@ async function flushLiveSessionLog(deps, id, signal) {
54
59
  await sessions.flush(session);
55
60
  signal?.throwIfAborted();
56
61
  }
62
+ /** The current generation's canonical base filename for every exported session log. */
63
+ const SESSION_LOG_FILENAME = sessionFormatLogFilename(SESSION_FORMAT_VERSION);
64
+ /**
65
+ * Serialize one session's logical log as canonical JSONL text: the header
66
+ * line, then one line per event, with a trailing newline.
67
+ * @param header - the session's immutable header.
68
+ * @param events - the validated committed events in seq order.
69
+ * @returns the JSONL text.
70
+ */
71
+ function serializeSessionLog(header, events) {
72
+ const lines = [JSON.stringify({
73
+ type: "session",
74
+ version: header.version,
75
+ id: header.id,
76
+ createdAt: header.createdAt,
77
+ ...header.cwd !== void 0 ? { cwd: header.cwd } : {},
78
+ ...header.parentSession !== void 0 ? { parentSession: header.parentSession } : {},
79
+ isSeeded: header.isSeeded,
80
+ ...header.origin !== void 0 ? { origin: header.origin } : {},
81
+ delegationDepth: header.delegationDepth ?? 0,
82
+ ...header.agentPreset !== void 0 ? { agentPreset: header.agentPreset } : {}
83
+ })];
84
+ for (const event of events) lines.push(JSON.stringify(event));
85
+ return `${lines.join("\n")}\n`;
86
+ }
87
+ /**
88
+ * Read one session's complete logical log through a read handle and serialize
89
+ * it. The read observes the committed log only — persistence never returns a
90
+ * torn tail — and a handle read after a resolved flush observes at least the
91
+ * flushed prefix.
92
+ * @param persistence - the mounted persistence backend.
93
+ * @param id - the session to read.
94
+ * @param signal - optional cancellation forwarded to the open and read.
95
+ * @returns the serialized JSONL text, or `undefined` when the session does not exist.
96
+ */
97
+ async function readSessionLogText(persistence, id, signal) {
98
+ const options = signal === void 0 ? {} : { signal };
99
+ let handle;
100
+ try {
101
+ handle = await persistence.open(id, "read", options);
102
+ } catch (error) {
103
+ if (error instanceof SessionPersistenceNotFoundError) return void 0;
104
+ throw error;
105
+ }
106
+ try {
107
+ const { events } = await handle.read(0, void 0, options);
108
+ return serializeSessionLog(handle.header, events);
109
+ } finally {
110
+ await handle.close();
111
+ }
112
+ }
57
113
  /** Zip extension for each accepted raster media type. */
58
114
  const MEDIA_TYPE_EXTENSIONS = {
59
115
  "image/png": "png",
@@ -71,13 +127,21 @@ const MEDIA_TYPE_EXTENSIONS = {
71
127
  function mediaEntryPath(ref) {
72
128
  return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`;
73
129
  }
130
+ /** Archive path that preserves one stored file reference's digest and name. */
131
+ function fileEntryPath(ref) {
132
+ const digest = String(ref.attachmentId).replace(/^sha256:/u, "");
133
+ const name = ref.name.replace(/[\\/\u0000-\u001f\u007f]/gu, "_");
134
+ const safeName = name === "." || name === ".." || name === "" ? "file" : name;
135
+ return `files/${digest.slice(0, 2)}/${digest}/${safeName}`;
136
+ }
74
137
  /**
75
- * Collect every image reference inside one content array, descending into
138
+ * Collect every attachment reference inside one content array, descending into
76
139
  * nested tool results the way the live attachment route does.
77
140
  * @param content - an event content array (or nested tool-result content).
78
- * @param refs - the dedupe map being filled (keyed by attachment id).
141
+ * @param images - image dedupe map keyed by attachment id.
142
+ * @param files - file dedupe map keyed by attachment id and stored name.
79
143
  */
80
- function collectImageRefs(content, refs) {
144
+ function collectAttachmentRefs(content, images, files) {
81
145
  if (!Array.isArray(content)) return;
82
146
  const pending = [];
83
147
  for (const item of content) pending.push(item);
@@ -87,36 +151,44 @@ function collectImageRefs(content, refs) {
87
151
  const block = value;
88
152
  if (block.type === "image" && typeof block.attachment === "object" && block.attachment !== null) {
89
153
  const ref = block.attachment;
90
- refs.set(String(ref.attachmentId), ref);
154
+ images.set(String(ref.attachmentId), ref);
155
+ }
156
+ if (block.type === "file" && typeof block.attachment === "object" && block.attachment !== null) {
157
+ const ref = block.attachment;
158
+ files.set(`${String(ref.attachmentId)}\u0000${ref.name}`, ref);
91
159
  }
92
160
  if (Array.isArray(block.content)) for (const item of block.content) pending.push(item);
93
161
  }
94
162
  }
95
163
  /**
96
- * Collect every image reference one session event carries, across the same
164
+ * Collect every attachment reference one session event carries, across the same
97
165
  * carriers the live attachment route scans (direct content, message content,
98
- * inserted messages, and completed assistant chunk blocks).
166
+ * inserted messages, and completed blocks in embedded Assistant streams).
99
167
  * @param event - one parsed JSONL event object.
100
- * @param refs - the dedupe map being filled (keyed by attachment id).
168
+ * @param images - image dedupe map keyed by attachment id.
169
+ * @param files - file dedupe map keyed by attachment id and stored name.
101
170
  */
102
- function collectEventImageRefs(event, refs) {
171
+ function collectEventAttachmentRefs(event, images, files) {
103
172
  const data = event.data;
104
173
  if (typeof data !== "object" || data === null) return;
105
174
  const carrier = data;
106
- collectImageRefs(carrier.content, refs);
107
- if (carrier.message !== void 0) collectImageRefs(carrier.message.content, refs);
108
- if (carrier.inserted !== void 0) for (const message of carrier.inserted) collectImageRefs(message.content, refs);
109
- if (carrier.chunk?.type === "block-end") collectImageRefs([carrier.chunk.block], refs);
175
+ collectAttachmentRefs(carrier.content, images, files);
176
+ if (carrier.message !== void 0) collectAttachmentRefs(carrier.message.content, images, files);
177
+ if (carrier.inserted !== void 0) for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files);
178
+ if (carrier.stream !== void 0) {
179
+ for (const record of carrier.stream) if (record.type === "chunk" && record.chunk?.type === "block-end") collectAttachmentRefs([record.chunk.block], images, files);
180
+ }
110
181
  }
111
182
  /**
112
- * Collect the distinct media references one stored artifact text names.
113
- * Lines that fail to parse cannot reference media and are skipped (the
183
+ * Collect the distinct attachment references one stored artifact text names.
184
+ * Lines that fail to parse cannot reference attachments and are skipped (the
114
185
  * artifact text itself is exported verbatim regardless).
115
186
  * @param content - the stored artifact text.
116
- * @returns the dedupe map keyed by attachment id.
187
+ * @returns image and file dedupe maps.
117
188
  */
118
- function imageRefsInArtifact(content) {
119
- const refs = /* @__PURE__ */ new Map();
189
+ function attachmentRefsInArtifact(content) {
190
+ const images = /* @__PURE__ */ new Map();
191
+ const files = /* @__PURE__ */ new Map();
120
192
  for (const line of content.split("\n")) {
121
193
  if (line === "") continue;
122
194
  let event;
@@ -125,9 +197,12 @@ function imageRefsInArtifact(content) {
125
197
  } catch {
126
198
  continue;
127
199
  }
128
- collectEventImageRefs(event, refs);
200
+ collectEventAttachmentRefs(event, images, files);
129
201
  }
130
- return refs;
202
+ return {
203
+ images,
204
+ files
205
+ };
131
206
  }
132
207
  /**
133
208
  * One safe zip path segment from an untrusted session id. Session ids are
@@ -150,30 +225,33 @@ function sessionLogZipFilename(sessionId) {
150
225
  return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`;
151
226
  }
152
227
  /**
153
- * Yield the export entries in zip order: the preloaded root artifact first,
154
- * then every subagent descendant in lineage order (each flushed when live,
155
- * read from the persistence backend right before it is yielded, and dropped
156
- * after the consumer moves on), then every distinct media object referenced by any of
157
- * the included logs (read and verified from the attachment store, one archive
158
- * entry per attachment id). The host holds at most one descendant's artifact
159
- * text and one media object at a time beyond the root.
228
+ * Yield the export entries in zip order: the preloaded root log first, then
229
+ * every subagent descendant in lineage order (each flushed when live, read
230
+ * through a persistence read handle right before it is yielded, and dropped
231
+ * after the consumer moves on), then every distinct attachment referenced by
232
+ * the included logs. Images are read and verified as bounded stored objects;
233
+ * generic files remain streamed through the ZIP writer. The host holds at most
234
+ * one descendant log, one image, and one file chunk beyond the root.
160
235
  * @param deps - the mounted export services (the caller answered 500 before this runs).
161
- * @param root - the already-read root artifact (read by the caller so the
162
- * missing-session path can answer cleanly before streaming starts).
236
+ * @param rootContent - the already-serialized root log (read by the caller so
237
+ * the missing-session path can answer cleanly before streaming starts).
163
238
  * @param sessionId - the root session id.
164
239
  * @param includeDescendants - whether to include every subagent descendant.
165
240
  * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
166
241
  * @returns the export entries in zip order.
167
242
  */
168
- async function* sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal) {
243
+ async function* sessionLogZipEntries(deps, rootContent, sessionId, includeDescendants, signal) {
169
244
  const media = /* @__PURE__ */ new Map();
170
- const rememberMedia = (content) => {
171
- for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref);
245
+ const files = /* @__PURE__ */ new Map();
246
+ const rememberAttachments = (content) => {
247
+ const refs = attachmentRefsInArtifact(content);
248
+ for (const [id, ref] of refs.images) media.set(id, ref);
249
+ for (const [id, ref] of refs.files) files.set(id, ref);
172
250
  };
173
- rememberMedia(root.content);
251
+ rememberAttachments(rootContent);
174
252
  yield {
175
- path: root.filename,
176
- content: root.content
253
+ path: SESSION_LOG_FILENAME,
254
+ content: rootContent
177
255
  };
178
256
  if (includeDescendants) {
179
257
  const seen = new Set([sessionId]);
@@ -184,13 +262,13 @@ async function* sessionLogZipEntries(deps, root, sessionId, includeDescendants,
184
262
  if (seen.has(id)) continue;
185
263
  seen.add(id);
186
264
  await flushLiveSessionLog(deps, id, signal);
187
- const raw = await deps.sessionPersistence.readRaw(id, signal);
265
+ const content = await readSessionLogText(deps.sessionPersistence, id, signal);
188
266
  signal?.throwIfAborted();
189
- if (raw === void 0) throw new Error(`subagent "${id}" has no stored log artifact`);
190
- rememberMedia(raw.content);
267
+ if (content === void 0) throw new Error(`subagent "${id}" has no stored log`);
268
+ rememberAttachments(content);
191
269
  yield {
192
- path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
193
- content: raw.content
270
+ path: `subagents/${safeSessionIdSegment(id)}/${SESSION_LOG_FILENAME}`,
271
+ content
194
272
  };
195
273
  yield* collect(node.descendants);
196
274
  }
@@ -208,8 +286,15 @@ async function* sessionLogZipEntries(deps, root, sessionId, includeDescendants,
208
286
  data: stored.data
209
287
  };
210
288
  }
289
+ for (const ref of files.values()) {
290
+ signal?.throwIfAborted();
291
+ yield {
292
+ path: fileEntryPath(ref),
293
+ chunks: deps.attachments.readFileStream(ref, signal)
294
+ };
295
+ }
211
296
  }
212
- /** How many code units of artifact text one zip push carries (bounded encode memory). */
297
+ /** How many code units of Session-log text one zip push carries (bounded encode memory). */
213
298
  const PUSH_CHUNK_CODE_UNITS = 65536;
214
299
  /** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
215
300
  const PUSH_CHUNK_BYTES = 65536;
@@ -262,12 +347,24 @@ async function pushBinaryChunks(deflate, data, controller, capacity, signal) {
262
347
  await capacity.wait(controller, signal);
263
348
  } while (offset < data.byteLength);
264
349
  }
350
+ /** Push one streamed file entry without retaining its complete byte sequence. */
351
+ async function pushStreamChunks(deflate, chunks, controller, capacity, signal) {
352
+ for await (const chunk of chunks) {
353
+ signal.throwIfAborted();
354
+ if (chunk.byteLength === 0) continue;
355
+ deflate.push(chunk, false);
356
+ await capacity.wait(controller, signal);
357
+ }
358
+ signal.throwIfAborted();
359
+ deflate.push(new Uint8Array(), true);
360
+ await capacity.wait(controller, signal);
361
+ }
265
362
  /**
266
363
  * Push one artifact's text into a deflate stream in bounded chunks, never
267
364
  * splitting a surrogate pair across a chunk boundary (a lone high surrogate
268
365
  * re-encodes as U+FFFD and would silently corrupt the exported artifact).
269
366
  * @param deflate - the zip entry's deflate stream.
270
- * @param content - the artifact text verbatim.
367
+ * @param content - the canonical Session-log text.
271
368
  * @param controller - response queue controller.
272
369
  * @param capacity - pull-driven response-capacity gate.
273
370
  * @param signal - cancellation; throws when aborted.
@@ -290,21 +387,21 @@ async function pushArtifactChunks(deflate, content, controller, capacity, signal
290
387
  } while (!finalChunk);
291
388
  }
292
389
  /**
293
- * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
294
- * read and validated by the caller before this is called (missing root or
390
+ * Stream one session-log ZIP as a WHATWG ReadableStream. The root log is read
391
+ * and serialized by the caller before this is called (a missing root or
295
392
  * missing services answer cleanly before any byte is produced); each entry is
296
393
  * then encoded and deflated in bounded chunks as it is produced, so the
297
394
  * archive bytes arrive incrementally. A descendant that fails to read errors
298
395
  * the stream (fail-loud, never silent under-export).
299
396
  * @param deps - the mounted export services (the caller answered 500 before this runs).
300
- * @param root - the already-read root artifact (first zip entry).
397
+ * @param rootContent - the already-serialized root log (first zip entry).
301
398
  * @param sessionId - the root session id.
302
399
  * @param includeDescendants - whether to include every subagent descendant.
303
400
  * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
304
401
  * @param signal - request cancellation combined with response-consumer cancellation.
305
402
  * @returns the zip byte stream.
306
403
  */
307
- function streamSessionLogZip(deps, root, sessionId, includeDescendants, compressionLevel, signal) {
404
+ function streamSessionLogZip(deps, rootContent, sessionId, includeDescendants, compressionLevel, signal) {
308
405
  const consumerAbort = new AbortController();
309
406
  const producerSignal = AbortSignal.any([signal, consumerAbort.signal]);
310
407
  let zip;
@@ -330,11 +427,12 @@ function streamSessionLogZip(deps, root, sessionId, includeDescendants, compress
330
427
  zip = archive;
331
428
  (async () => {
332
429
  try {
333
- for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
430
+ for await (const entry of sessionLogZipEntries(deps, rootContent, sessionId, includeDescendants, producerSignal)) {
334
431
  const deflate = new ZipDeflate(entry.path, { level: compressionLevel });
335
432
  archive.add(deflate);
336
433
  if ("content" in entry) await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal);
337
- else await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal);
434
+ else if ("data" in entry) await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal);
435
+ else await pushStreamChunks(deflate, entry.chunks, controller, capacity, producerSignal);
338
436
  }
339
437
  archive.end();
340
438
  } catch (error) {
@@ -386,6 +484,7 @@ function apply(ctx, config = {}) {
386
484
  connectionOf(ctx).fetch.register({
387
485
  path: SESSION_LOG_EXPORT_PATH,
388
486
  methods: ["GET", "HEAD"],
487
+ requestBody: "buffered",
389
488
  fetch: async (request) => {
390
489
  const response = await sessionLogExportResponse(ctx, request, config.compressionLevel ?? 6);
391
490
  if (request.method === "GET") return response;
@@ -409,27 +508,26 @@ async function sessionLogExportResponse(ctx, request, compressionLevel) {
409
508
  const sessionId = brandString(sessionIdValue);
410
509
  const deps = sessionLogExportDeps(ctx);
411
510
  if (deps.sessionQuery === void 0 || deps.sessionPersistence === void 0 || deps.attachments === void 0) return new Response("session log export is unavailable: missing session-query, session-persistence, or attachments service", { status: 500 });
412
- if (!deps.sessionPersistence.supportsRawArtifacts) return new Response("session log export is unavailable: the persistence backend does not expose per-session raw artifacts", { status: 501 });
413
511
  const ready = {
414
512
  sessionQuery: deps.sessionQuery,
415
513
  sessionPersistence: deps.sessionPersistence,
416
514
  attachments: deps.attachments,
417
515
  sessions: deps.sessions
418
516
  };
419
- let root;
517
+ let rootContent;
420
518
  try {
421
519
  await flushLiveSessionLog(deps, sessionId, request.signal);
422
- root = await deps.sessionPersistence.readRaw(sessionId, request.signal);
520
+ rootContent = await readSessionLogText(deps.sessionPersistence, sessionId, request.signal);
423
521
  request.signal.throwIfAborted();
424
522
  } catch {
425
523
  request.signal.throwIfAborted();
426
- return new Response("session log export failed to prepare the stored artifact", { status: 500 });
524
+ return new Response("session log export failed to read the stored log", { status: 500 });
427
525
  }
428
- if (root === void 0) return new Response("session not found", { status: 404 });
429
- return new Response(streamSessionLogZip(ready, root, sessionId, descendantsValue === "true", compressionLevel, request.signal), { headers: {
526
+ if (rootContent === void 0) return new Response("session not found", { status: 404 });
527
+ return new Response(streamSessionLogZip(ready, rootContent, sessionId, descendantsValue === "true", compressionLevel, request.signal), { headers: {
430
528
  "content-type": "application/zip",
431
529
  "content-disposition": `attachment; filename="${sessionLogZipFilename(sessionId)}"`
432
530
  } });
433
531
  }
434
532
  //#endregion
435
- export { Config, DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, SESSION_LOG_EXPORT_PATH, apply, flushLiveSessionLog, inject, name, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip };
533
+ export { Config, DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, SESSION_LOG_EXPORT_PATH, SESSION_LOG_FILENAME, apply, flushLiveSessionLog, inject, name, readSessionLogText, serializeSessionLog, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip };
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * Host-side session-log download: streams one ZIP archive whose files are the
3
- * sessions' stored artifact text verbatim plus every referenced media object.
4
- * The root artifact sits under its original base name (`session.jsonl`); each
5
- * subagent descendant under `subagents/<id>/<filename>`; each image referenced
6
- * by any included log under `media/<attachmentId>.<ext>` (content-addressed,
7
- * so one archive never duplicates a shared image). No manifest is written —
8
- * every file is byte-identical to the backend's durable artifact or attachment
9
- * store and self-describing through its own header line or media type. Before
10
- * each live session's artifact read, the SessionStore flush barrier makes the
11
- * current in-memory log durable; cold sessions need no barrier. Request abort
12
- * and response-consumer cancellation share one producer signal and terminate
13
- * the active compressor.
3
+ * sessions' logical session logs plus every referenced attachment. Each log
4
+ * is read through a persistence read handle and serialized here as canonical
5
+ * JSONL one header line, then one line per validated event — so every
6
+ * backend (JSONL, SQLite, future) exports identically. The root log uses the
7
+ * current generation's canonical `session[.vN].jsonl` name; each subagent
8
+ * descendant uses `subagents/<id>/session[.vN].jsonl`; each image referenced by any included log
9
+ * under `media/<attachmentId>.<ext>` (content-addressed, so one archive never
10
+ * duplicates a shared image); each generic file sits under
11
+ * `files/<prefix>/<digest>/<name>` and streams from the attachment store. No
12
+ * manifest is written. Before each live session's log read, the SessionStore
13
+ * flush barrier makes the current in-memory log durable; cold sessions need no
14
+ * barrier. Request abort and response-consumer cancellation share one producer
15
+ * signal and terminate the active compressor.
14
16
  * Compression runs on the host with fflate's streaming Zip API, so the archive
15
17
  * bytes are produced incrementally and the host never holds the whole archive
16
18
  * in one buffer; production waits for consumer pull whenever the response queue
@@ -21,8 +23,8 @@
21
23
  import type { Context } from '@deepseek-ai/cordis';
22
24
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
23
25
  import type { SessionQueryEngine } from '@deepseek-ai/dsh-session-query';
24
- import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session';
25
- import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence';
26
+ import type { SessionEvent, SessionHeader, SessionId, SessionStore } from '@deepseek-ai/dsh-session';
27
+ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence';
26
28
  /** Valid fflate DEFLATE levels accepted by session-log export. */
27
29
  export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
28
30
  /** Balanced default used when Session export configuration omits a compression level. */
@@ -49,21 +51,45 @@ export interface SessionLogExportReady {
49
51
  export declare function sessionLogExportDeps(ctx: Context): SessionLogExportDeps;
50
52
  /**
51
53
  * Flush one currently live session through the store's authoritative durability
52
- * barrier immediately before its raw artifact is read. A cold or absent id has
54
+ * barrier immediately before its logical log is read. A cold or absent id has
53
55
  * no in-memory work to flush.
54
56
  * @param deps - export services, including the optional live-session store.
55
57
  * @param id - the session whose artifact is about to be read.
56
58
  * @param signal - optional cancellation observed around the flush barrier.
57
59
  */
58
60
  export declare function flushLiveSessionLog(deps: Pick<SessionLogExportDeps, 'sessions'>, id: SessionId, signal?: AbortSignal): Promise<void>;
59
- /** One exported file: a stored artifact text or one referenced media object. */
61
+ /** One exported file: a serialized session log or one referenced attachment object. */
60
62
  export type SessionLogZipEntry = {
61
63
  readonly path: string;
62
64
  readonly content: string;
63
65
  } | {
64
66
  readonly path: string;
65
67
  readonly data: Uint8Array;
68
+ } | {
69
+ readonly path: string;
70
+ readonly chunks: AsyncIterable<Uint8Array>;
66
71
  };
72
+ /** The current generation's canonical base filename for every exported session log. */
73
+ export declare const SESSION_LOG_FILENAME: string;
74
+ /**
75
+ * Serialize one session's logical log as canonical JSONL text: the header
76
+ * line, then one line per event, with a trailing newline.
77
+ * @param header - the session's immutable header.
78
+ * @param events - the validated committed events in seq order.
79
+ * @returns the JSONL text.
80
+ */
81
+ export declare function serializeSessionLog(header: SessionHeader, events: readonly SessionEvent[]): string;
82
+ /**
83
+ * Read one session's complete logical log through a read handle and serialize
84
+ * it. The read observes the committed log only — persistence never returns a
85
+ * torn tail — and a handle read after a resolved flush observes at least the
86
+ * flushed prefix.
87
+ * @param persistence - the mounted persistence backend.
88
+ * @param id - the session to read.
89
+ * @param signal - optional cancellation forwarded to the open and read.
90
+ * @returns the serialized JSONL text, or `undefined` when the session does not exist.
91
+ */
92
+ export declare function readSessionLogText(persistence: SessionPersistence, id: SessionId, signal?: AbortSignal): Promise<string | undefined>;
67
93
  /**
68
94
  * The export archive filename for one root session.
69
95
  * @param sessionId - the root session id (sanitized to one safe path segment).
@@ -71,36 +97,36 @@ export type SessionLogZipEntry = {
71
97
  */
72
98
  export declare function sessionLogZipFilename(sessionId: string): string;
73
99
  /**
74
- * Yield the export entries in zip order: the preloaded root artifact first,
75
- * then every subagent descendant in lineage order (each flushed when live,
76
- * read from the persistence backend right before it is yielded, and dropped
77
- * after the consumer moves on), then every distinct media object referenced by any of
78
- * the included logs (read and verified from the attachment store, one archive
79
- * entry per attachment id). The host holds at most one descendant's artifact
80
- * text and one media object at a time beyond the root.
100
+ * Yield the export entries in zip order: the preloaded root log first, then
101
+ * every subagent descendant in lineage order (each flushed when live, read
102
+ * through a persistence read handle right before it is yielded, and dropped
103
+ * after the consumer moves on), then every distinct attachment referenced by
104
+ * the included logs. Images are read and verified as bounded stored objects;
105
+ * generic files remain streamed through the ZIP writer. The host holds at most
106
+ * one descendant log, one image, and one file chunk beyond the root.
81
107
  * @param deps - the mounted export services (the caller answered 500 before this runs).
82
- * @param root - the already-read root artifact (read by the caller so the
83
- * missing-session path can answer cleanly before streaming starts).
108
+ * @param rootContent - the already-serialized root log (read by the caller so
109
+ * the missing-session path can answer cleanly before streaming starts).
84
110
  * @param sessionId - the root session id.
85
111
  * @param includeDescendants - whether to include every subagent descendant.
86
112
  * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
87
113
  * @returns the export entries in zip order.
88
114
  */
89
- export declare function sessionLogZipEntries(deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal): AsyncGenerator<SessionLogZipEntry>;
115
+ export declare function sessionLogZipEntries(deps: SessionLogExportReady, rootContent: string, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal): AsyncGenerator<SessionLogZipEntry>;
90
116
  /**
91
- * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
92
- * read and validated by the caller before this is called (missing root or
117
+ * Stream one session-log ZIP as a WHATWG ReadableStream. The root log is read
118
+ * and serialized by the caller before this is called (a missing root or
93
119
  * missing services answer cleanly before any byte is produced); each entry is
94
120
  * then encoded and deflated in bounded chunks as it is produced, so the
95
121
  * archive bytes arrive incrementally. A descendant that fails to read errors
96
122
  * the stream (fail-loud, never silent under-export).
97
123
  * @param deps - the mounted export services (the caller answered 500 before this runs).
98
- * @param root - the already-read root artifact (first zip entry).
124
+ * @param rootContent - the already-serialized root log (first zip entry).
99
125
  * @param sessionId - the root session id.
100
126
  * @param includeDescendants - whether to include every subagent descendant.
101
127
  * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
102
128
  * @param signal - request cancellation combined with response-consumer cancellation.
103
129
  * @returns the zip byte stream.
104
130
  */
105
- export declare function streamSessionLogZip(deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, compressionLevel: SessionLogCompressionLevel, signal: AbortSignal): ReadableStream<Uint8Array>;
131
+ export declare function streamSessionLogZip(deps: SessionLogExportReady, rootContent: string, sessionId: SessionId, includeDescendants: boolean, compressionLevel: SessionLogCompressionLevel, signal: AbortSignal): ReadableStream<Uint8Array>;
106
132
  //# sourceMappingURL=archive.d.ts.map
@@ -2,7 +2,7 @@
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import Schema from '@deepseek-ai/schemastery';
4
4
  import { type SessionLogCompressionLevel } from './archive.ts';
5
- export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip, } from './archive.ts';
5
+ export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, readSessionLogText, serializeSessionLog, SESSION_LOG_FILENAME, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip, } from './archive.ts';
6
6
  export type { SessionLogCompressionLevel, SessionLogExportDeps, SessionLogExportReady, SessionLogZipEntry, } from './archive.ts';
7
7
  export declare const name = "session-log-download";
8
8
  export declare const inject: string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-session-log-export",
3
3
  "description": "Web Session-log export command and shared download dialog",
4
- "version": "0.1.2-alpha.5",
4
+ "version": "0.1.3-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,32 +33,35 @@
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
35
  "fflate": "^0.8.2",
36
+ "@deepseek-ai/dsh-session-format": "^0.1.3-alpha.2",
36
37
  "@deepseek-ai/schemastery": "^3.18.2",
37
- "@deepseek-ai/dsh-brand": "^0.1.2-alpha.5"
38
+ "@deepseek-ai/dsh-brand": "^0.1.3-alpha.2"
38
39
  },
39
40
  "peerDependencies": {
40
- "@deepseek-ai/cordis": "^4.0.2"
41
+ "@deepseek-ai/cordis": "^4.0.2",
42
+ "@deepseek-ai/dsh-session": "^0.1.3-alpha.2",
43
+ "@deepseek-ai/dsh-session-persistence": "^0.1.3-alpha.2"
41
44
  },
42
45
  "devDependencies": {
43
46
  "@types/react": "~18.3.1",
44
47
  "react": "^18.2.0",
45
48
  "@deepseek-ai/cordis": "^4.0.2",
49
+ "@deepseek-ai/dsh-agent": "^0.1.3-alpha.2",
46
50
  "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
47
- "@deepseek-ai/dsh-agent": "^0.1.2-alpha.5",
48
- "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.5",
49
- "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.5",
50
- "@deepseek-ai/dsh-attachment": "^0.1.2-alpha.5",
51
- "@deepseek-ai/dsh-client-ui-commands": "^0.1.2-alpha.5",
52
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.5",
53
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.5",
54
- "@deepseek-ai/dsh-client-ui-session": "^0.1.2-alpha.5",
55
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.5",
56
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.5",
57
- "@deepseek-ai/dsh-commands": "^0.1.2-alpha.5",
58
- "@deepseek-ai/dsh-session-persistence": "^0.1.2-alpha.5",
59
- "@deepseek-ai/dsh-session-query": "^0.1.2-alpha.5",
60
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
61
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.5"
51
+ "@deepseek-ai/dsh-client-connection": "^0.1.3-alpha.2",
52
+ "@deepseek-ai/dsh-attachment": "^0.1.3-alpha.2",
53
+ "@deepseek-ai/dsh-client-store": "^0.1.3-alpha.2",
54
+ "@deepseek-ai/dsh-client-ui-commands": "^0.1.3-alpha.2",
55
+ "@deepseek-ai/dsh-client-locale": "^0.1.3-alpha.2",
56
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.3-alpha.2",
57
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.3-alpha.2",
58
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.3-alpha.2",
59
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.3-alpha.2",
60
+ "@deepseek-ai/dsh-commands": "^0.1.3-alpha.2",
61
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.3-alpha.2",
62
+ "@deepseek-ai/dsh-session": "^0.1.3-alpha.2",
63
+ "@deepseek-ai/dsh-session-persistence": "^0.1.3-alpha.2",
64
+ "@deepseek-ai/dsh-session-query": "^0.1.3-alpha.2"
62
65
  },
63
66
  "dsh": {
64
67
  "client": {