dsh-mobile-plugin 0.1.96 → 0.1.97

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.
Files changed (41) hide show
  1. package/README.md +73 -50
  2. package/config/agent-presets/standard/agent.cordis.yml +13 -0
  3. package/lib/git/exec.js +129 -0
  4. package/lib/git/package.json +3 -0
  5. package/lib/git/parse.js +247 -0
  6. package/lib/git/read.js +617 -0
  7. package/lib/mobile/archive-clean.js +31 -1
  8. package/lib/mobile/channels.js +583 -0
  9. package/lib/mobile/events.js +10 -8
  10. package/lib/mobile/git-repos.js +210 -0
  11. package/lib/mobile/git.js +603 -0
  12. package/lib/mobile/history-derive.js +79 -1
  13. package/lib/mobile/host-bridge/endpoints.js +3 -0
  14. package/lib/mobile/host-bridge/event-synthesis.js +109 -1
  15. package/lib/mobile/host-bridge/shims.js +9 -2
  16. package/lib/mobile/host-settings.js +41 -0
  17. package/lib/mobile/inbox-clean.js +337 -0
  18. package/lib/mobile/pairing.js +18 -1
  19. package/lib/mobile/questions.js +31 -9
  20. package/lib/mobile/schedule.js +440 -0
  21. package/lib/mobile/session-cache.js +18 -0
  22. package/lib/mobile/session-file.js +192 -0
  23. package/lib/mobile/session-model-options.js +184 -0
  24. package/lib/mobile/session-models.js +24 -2
  25. package/lib/mobile/session-schedule-mark.js +70 -0
  26. package/lib/mobile/session-snapshot.js +12 -1
  27. package/lib/mobile/sessions.js +8 -0
  28. package/lib/mobile/stats-collect.cjs +257 -0
  29. package/lib/mobile/stats-ledger.cjs +407 -0
  30. package/lib/mobile/stats.js +38 -0
  31. package/lib/mobile/stream.js +18 -10
  32. package/lib/mobile/uploads.js +24 -6
  33. package/lib/mobile/workspaces.js +30 -6
  34. package/lib/panel/index.html +4 -4
  35. package/lib/relay/lan-proxy.js +10 -1
  36. package/lib/routes/control-plane.js +38 -0
  37. package/lib/routes/mobile-api.js +70 -1
  38. package/lib/session-cleanup.cjs +8 -0
  39. package/lib/shared/sse-write.js +17 -6
  40. package/lib/shell/updater.js +3 -3
  41. package/package.json +3 -2
@@ -0,0 +1,337 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 收件目录(`~/.dsh/mobile-inbox`)的占用统计与按龄清理。
5
+ *
6
+ * 手机分享进来的文件由 `uploads.js` 落到 `<收件根>/<日期>/<消毒名>`,那条通道**只进不出**:
7
+ * 它在盘上只认文件名,不认识会话、也不认识工作区,所以没有任何「删掉这条对话就顺带清掉
8
+ * 它传上来的文件」可言 —— 唯一的引用是会话日志正文里那句 `@路径`,那是文本,宿主不会
9
+ * 跟着它去回收。不设一个出口,这个目录只会按天堆下去。
10
+ *
11
+ * 因此这里的口径是**按龄**:只删 mtime 超过 `keepMs`(24 小时)的文件。
12
+ *
13
+ * 为什么是文件而不是日期目录:目录按「上传那天的本地日期」分堆,整段删会把昨天 23:50
14
+ * 传的、才过 20 分钟的文件一起带走;按每个文件自己的 mtime 判才和「放满一天」这个承诺
15
+ * 一致。空出来的日期目录顺手删掉。
16
+ *
17
+ * 为什么留着 24 小时而不是立刻删:助手拿到路径之后可能在同一个回合里反复读它,用户也可能
18
+ * 隔一晚上让助手接着看同一份文件。今天传的文件一开始就不在清理范围内,正在跑的那一回合
19
+ * 因此碰不到这里。
20
+ *
21
+ * 与 `session-cleanup.cjs` 的取舍刻意不同:那边进程活着时一条日志都不删(宿主随时可能去
22
+ * 打开,ENOENT 会带走整个进程);这里删的是收件目录里的普通文件,宿主只在助手明确要读
23
+ * 某个路径时才碰它,而那个路径只有在 24 小时窗口内才可能还有人要。
24
+ *
25
+ * ## 自动清理是开关,默认关(2026-10-21 用户定)
26
+ *
27
+ * 启动时那次自动清理**只有开关打开才跑**;面板上那颗按钮永远可用(那是用户自己按的,
28
+ * 不需要开关授权)。开关落盘在 `<DSH_HOME>/storages/mobile-plugin/inbox-auto-clear.json`,
29
+ * 与自动更新开关同一套写法(读不到 / 读坏了都当关)—— 自动删盘这种事,缺省必须在用户
30
+ * 这一侧,而不是「装了就替你删」。
31
+ */
32
+
33
+ const fs = require('node:fs');
34
+ const fsp = require('node:fs/promises');
35
+ const path = require('node:path');
36
+
37
+ const { dshHome } = require('../shared/paths');
38
+ const { sendJson, readJsonBody } = require('../shared/http-util');
39
+ const { inboxRoot, sweepStaging, STAGING_TTL_MS } = require('./uploads.js');
40
+
41
+ /** 保留期:文件落盘之后满这么久才进清理范围。 */
42
+ const INBOX_KEEP_MS = 24 * 60 * 60 * 1000;
43
+
44
+ /** 收件根下的暂存目录(`uploads.js` 的 `.staging`):半截上传的残片住在这里。 */
45
+ const STAGING_DIR = '.staging';
46
+
47
+ /** 日期堆的名字(`uploads.js` 的 `today()`)。不是这个形状的一律当残渣跳过。 */
48
+ const DATE_DIR_RE = /^\d{4}-\d{2}-\d{2}$/;
49
+
50
+ /** 自动清理开关的落盘文件:与自动更新开关同一个目录。 */
51
+ function autoClearFile() {
52
+ return path.join(dshHome(), 'storages', 'mobile-plugin', 'inbox-auto-clear.json');
53
+ }
54
+
55
+ /**
56
+ * 读自动清理开关。读不到 / 读坏了都当「关」—— 无人值守删盘这种事,
57
+ * 缺省必须在用户这一侧(与 `updater.js` 的 `autoUpdateEnabled` 同一条口径)。
58
+ */
59
+ function autoClearEnabled() {
60
+ try {
61
+ const raw = JSON.parse(fs.readFileSync(autoClearFile(), 'utf8'));
62
+ return raw?.enabled === true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /** 写开关。 */
69
+ function setAutoClear(enabled) {
70
+ const file = autoClearFile();
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ fs.writeFileSync(file, `${JSON.stringify({ enabled: enabled === true }, null, 2)}\n`);
73
+ return autoClearEnabled();
74
+ }
75
+
76
+ async function readEntries(dir) {
77
+ try {
78
+ return await fsp.readdir(dir, { withFileTypes: true });
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 暂存残片的占用:`.staging/*.part` 里已经超过 TTL 的那些。
86
+ *
87
+ * 单列一份是因为它们**不归日期堆那份扫描管**(文件名不是按天分堆的、也没有目录名可读),
88
+ * 但它跟落盘文件是同一个收件根、同样只进不出:`sweepStaging` 只在 `begin` 时跑,手机不再
89
+ * 上传的机器上残片就一直躺在那儿(实测一份 12MB 的 `.part` 从 10-20 睡到现在)。
90
+ * 判据与 `sweepStaging` 完全一致(同一个 `STAGING_TTL_MS`),这里只读不删。
91
+ */
92
+ async function scanStaging(now = Date.now()) {
93
+ const dir = path.join(inboxRoot(), STAGING_DIR);
94
+ let entries;
95
+ try {
96
+ entries = await fsp.readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return { files: 0, bytes: 0, deletableFiles: 0, deletableBytes: 0 };
99
+ }
100
+ const cutoff = now - STAGING_TTL_MS;
101
+ let files = 0;
102
+ let bytes = 0;
103
+ let deletableFiles = 0;
104
+ let deletableBytes = 0;
105
+ for (const entry of entries) {
106
+ if (!entry.isFile() || !entry.name.endsWith('.part')) continue;
107
+ try {
108
+ const stat = await fsp.stat(path.join(dir, entry.name));
109
+ files += 1;
110
+ bytes += stat.size;
111
+ if (stat.mtimeMs < cutoff) {
112
+ deletableFiles += 1;
113
+ deletableBytes += stat.size;
114
+ }
115
+ } catch {
116
+ continue;
117
+ }
118
+ }
119
+ return { files, bytes, deletableFiles, deletableBytes };
120
+ }
121
+
122
+ /**
123
+ * 走一遍收件目录,按 mtime 把文件分成「还留着」与「够龄可删」两堆。
124
+ *
125
+ * 只走一层日期目录、不进任何子目录:落盘路径是 `<根>/<日期>/<文件>`,客户端给不出更深的
126
+ * 路径(`sanitizeName` 把 `/` 与 `\` 都换成 `_`)。暂存残片单独算(见 `scanStaging`),
127
+ * 但**并进同一份读数**:用户问的是「这个收件目录占了多少」,分两行报只会让他去猜哪一行该点。
128
+ */
129
+ async function scanInbox(now = Date.now(), keepMs = INBOX_KEEP_MS) {
130
+ const root = inboxRoot();
131
+ const cutoff = now - keepMs;
132
+ let bytes = 0;
133
+ let files = 0;
134
+ let deletableBytes = 0;
135
+ let deletableFiles = 0;
136
+ let oldestMs = 0;
137
+ for (const dirEntry of await readEntries(root)) {
138
+ if (!dirEntry.isDirectory() || !DATE_DIR_RE.test(dirEntry.name)) continue;
139
+ const dir = path.join(root, dirEntry.name);
140
+ for (const entry of await readEntries(dir)) {
141
+ if (!entry.isFile()) continue;
142
+ const file = path.join(dir, entry.name);
143
+ let stat;
144
+ try {
145
+ stat = await fsp.stat(file);
146
+ } catch {
147
+ continue;
148
+ }
149
+ files += 1;
150
+ bytes += stat.size;
151
+ if (!oldestMs || stat.mtimeMs < oldestMs) oldestMs = stat.mtimeMs;
152
+ if (stat.mtimeMs < cutoff) {
153
+ deletableFiles += 1;
154
+ deletableBytes += stat.size;
155
+ }
156
+ }
157
+ }
158
+ // 暂存残片并进同一份读数,但**它们不是「收件文件」**:多出来的两个字段让界面能分开说。
159
+ const staging = await scanStaging(now);
160
+ return {
161
+ root,
162
+ files: files + staging.files,
163
+ bytes: bytes + staging.bytes,
164
+ deletableFiles: deletableFiles + staging.deletableFiles,
165
+ deletableBytes: deletableBytes + staging.deletableBytes,
166
+ stagingFiles: staging.files,
167
+ stagingBytes: staging.bytes,
168
+ oldestMs,
169
+ keepMs,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * 删掉够龄的文件,再把空下来的日期目录收掉。
175
+ *
176
+ * 删一个失败不算整轮失败(被别人删掉、权限、占用),照删其余:这个动作的结果本来就是
177
+ * 「这次腾了多少」,不是「盘上一定干净了」。目录只在真的空了之后才删,`rmdir` 失败同样
178
+ * 不影响结论。
179
+ */
180
+ async function clearInbox(now = Date.now(), keepMs = INBOX_KEEP_MS) {
181
+ const root = inboxRoot();
182
+ const cutoff = now - keepMs;
183
+ let deletedFiles = 0;
184
+ let freedBytes = 0;
185
+ let failedFiles = 0;
186
+ for (const dirEntry of await readEntries(root)) {
187
+ if (!dirEntry.isDirectory() || !DATE_DIR_RE.test(dirEntry.name)) continue;
188
+ const dir = path.join(root, dirEntry.name);
189
+ for (const entry of await readEntries(dir)) {
190
+ if (!entry.isFile()) continue;
191
+ const file = path.join(dir, entry.name);
192
+ let stat;
193
+ try {
194
+ stat = await fsp.stat(file);
195
+ } catch {
196
+ continue;
197
+ }
198
+ if (stat.mtimeMs >= cutoff) continue;
199
+ try {
200
+ await fsp.unlink(file);
201
+ deletedFiles += 1;
202
+ freedBytes += stat.size;
203
+ } catch {
204
+ failedFiles += 1;
205
+ }
206
+ }
207
+ // 只剩空壳的日期目录一起收掉;里面还有今天传的文件时 rmdir 会失败,正好留着。
208
+ try {
209
+ await fsp.rmdir(dir);
210
+ } catch { /* 还有文件,或者删不掉:都不是错误 */ }
211
+ }
212
+ // 暂存残片交给 `uploads.js` 自己那份判据(同一个 TTL),别在这里抄一遍:抄了就是第二份账,
213
+ // 改一处忘一处时两边会各删各的。它删不掉的项不进 `failedFiles` —— 那是「收件文件」的账。
214
+ let stagingDeleted = 0;
215
+ let stagingBytes = 0;
216
+ try {
217
+ const swept = await sweepStaging(path.join(root, STAGING_DIR));
218
+ stagingDeleted = swept.deleted;
219
+ stagingBytes = swept.bytes;
220
+ } catch { /* 残片清不动不影响这一轮:下次启动还会再试 */ }
221
+ return {
222
+ deletedFiles: deletedFiles + stagingDeleted,
223
+ freedBytes: freedBytes + stagingBytes,
224
+ failedFiles,
225
+ keepMs,
226
+ };
227
+ }
228
+
229
+ function usageView(scan) {
230
+ return {
231
+ ok: true,
232
+ root: scan.root,
233
+ files: scan.files,
234
+ bytes: scan.bytes,
235
+ deletableFiles: scan.deletableFiles,
236
+ deletableBytes: scan.deletableBytes,
237
+ /** 其中暂存残片占了多少(半截上传留下的 `.part`,与落盘文件同一份读数、但来源不同)。 */
238
+ stagingFiles: scan.stagingFiles,
239
+ stagingBytes: scan.stagingBytes,
240
+ oldestAt: scan.oldestMs || null,
241
+ keepHours: Math.round(scan.keepMs / (60 * 60 * 1000)),
242
+ /**
243
+ * 自动清理开着没有。两个界面都靠它画那颗开关 —— 不自己猜、也不各自存一份,
244
+ * 真值只在这台机器的 json 里。**默认 false**(用户 2026-10-21 定)。
245
+ */
246
+ autoClear: autoClearEnabled(),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * GET /api/mobile/v1/inbox/status(token)
252
+ *
253
+ * 只读:收件目录现在占了多少、其中多少已经够龄可删。App 与网页面板共用这一条,
254
+ * 两边都不必自己扫盘、也不会各算一套口径。
255
+ */
256
+ async function handleInboxStatus(_ctx, _req, res) {
257
+ sendJson(res, 200, usageView(await scanInbox()));
258
+ }
259
+
260
+ /**
261
+ * POST /api/mobile/v1/inbox/clear(token){ confirm: true }
262
+ *
263
+ * 与「清理已归档会话」同一条手感:不可恢复的磁盘删除必须显式带 `confirm`,
264
+ * 手机端少点一次不值得把它变成滑一下就没了的动作。
265
+ */
266
+ async function handleInboxClear(_ctx, req, res) {
267
+ const body = await readJsonBody(req);
268
+ if (!body || body.confirm !== true) {
269
+ sendJson(res, 400, { ok: false, error: { code: 'confirm-required', message: '清理收件文件不可恢复,请求需要带 confirm: true' } });
270
+ return;
271
+ }
272
+ const result = await clearInbox();
273
+ sendJson(res, 200, { ok: true, ...result });
274
+ }
275
+
276
+ /**
277
+ * POST /api/mobile/v1/inbox/auto(token){ enabled: boolean }
278
+ *
279
+ * 自动清理开关。与自动更新那条(`/api/relay/update/auto`)同一条手感:只收布尔、
280
+ * 缺省关、写失败就整次失败(回「开好了」却没落盘,下次启动又是关的,用户会以为是坏的)。
281
+ */
282
+ async function handleInboxAuto(_ctx, req, res) {
283
+ const body = await readJsonBody(req);
284
+ if (!body || typeof body.enabled !== 'boolean') {
285
+ sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
286
+ return;
287
+ }
288
+ try {
289
+ const enabled = setAutoClear(body.enabled);
290
+ sendJson(res, 200, { ok: true, autoClear: enabled });
291
+ } catch (error) {
292
+ sendJson(res, 500, { ok: false, error: { code: 'write-failed', message: `开关没写进去:${error && error.message ? error.message : error}` } });
293
+ }
294
+ }
295
+
296
+ /** 每进程只清一次:代际热替换会再跑一遍装载,但盘上已经清过了。 */
297
+ const CLEARED_ONCE = Symbol.for('dsh-mobile-plugin.inbox-cleared-once');
298
+
299
+ /**
300
+ * 宿主刚起来时顺手清一次 —— **只在自动清理开关打开时**。
301
+ *
302
+ * 与两个入口(App / 面板)调的是同一个 `clearInbox`,只是不需要确认:这里没有人在按键,
303
+ * 而够龄这个判据本身就是「这些文件已经放满一天了」。但「没人按键」也正是它必须默认关的
304
+ * 理由 —— 没有人在场时替用户删盘的,得是他明确打开过的开关。开关关着就直接返回,连盘都
305
+ * 不扫(扫一遍也是成本,而且开没开是读一个 json 的事)。
306
+ *
307
+ * 失败只记一行日志:收尾动作不该把装载拖下水。
308
+ */
309
+ async function clearInboxOnce(log) {
310
+ if (globalThis[CLEARED_ONCE]) return null;
311
+ globalThis[CLEARED_ONCE] = true;
312
+ if (!autoClearEnabled()) return null;
313
+ try {
314
+ const result = await clearInbox();
315
+ if (result.deletedFiles > 0) {
316
+ log?.(`inbox cleanup: deleted ${result.deletedFiles} files (${Math.round(result.freedBytes / 1024)} KB)`);
317
+ }
318
+ return result;
319
+ } catch (error) {
320
+ log?.(`inbox cleanup failed: ${error && error.message ? error.message : String(error)}`);
321
+ return null;
322
+ }
323
+ }
324
+
325
+ module.exports = {
326
+ scanInbox,
327
+ clearInbox,
328
+ clearInboxOnce,
329
+ autoClearEnabled,
330
+ setAutoClear,
331
+ autoClearFile,
332
+ usageView,
333
+ handleInboxStatus,
334
+ handleInboxClear,
335
+ handleInboxAuto,
336
+ INBOX_KEEP_MS,
337
+ };
@@ -114,10 +114,13 @@ function readBody(req) {
114
114
  * @param {object} deps.core relay/core.js(签 token 用)
115
115
  * @param {object} deps.credentials 设备凭据库(issue 接口)
116
116
  * @param {object} deps.pairingApproval pairing-policy.js(决定这台机器收不收新设备)
117
+ * @param {(port: number) => Array<object>} [deps.lanCandidates] 直连候选(与 `/connection-info` 同一份)
118
+ * @param {(port: number) => Array<object>} [deps.ipv6Candidates] IPv6 直连候选
119
+ * @param {() => number} [deps.lanPort] 宿主实绑的直连端口(配置端口被占时会换)
117
120
  * @param {(message: string) => void} deps.log
118
121
  */
119
122
  function createPairHandler(deps) {
120
- const { state, core, credentials, pairingApproval, log } = deps;
123
+ const { state, core, credentials, pairingApproval, lanCandidates, ipv6Candidates, lanPort, log } = deps;
121
124
 
122
125
  // ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的。
123
126
  // 写成 `(_ctx, req, res)` 会把 res 挤成 undefined,而 sendJson 的 catch 会把那个
@@ -184,6 +187,18 @@ function createPairHandler(deps) {
184
187
  const token = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_BEARER_TTL_SEC);
185
188
  const cookieValue = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
186
189
  const hostPlatform = pairingApproval.hostPlatform().platform;
190
+ // 宿主自己那批直连候选随配对一起回给手机:手输公网地址(域名 / 公网 IP)配对时没有
191
+ // 二维码,这是手机拿到内网地址的唯一机会。回家后那个公网地址若打不通(路由器不支持
192
+ // 回环 / 域名只在外网解析),手机可以靠这批候选找到这台机器,否则只能删设备重配。
193
+ // 语义与 `/connection-info` 那份一致(`advertisedCandidates`:有私网只给私网),
194
+ // 关掉局域网直连时两份都为空。App 的 `doPair` 本来就合并这两个字段。
195
+ const port = typeof lanPort === 'function' ? lanPort() : state.lanPort
196
+ const directCandidates = state.lanEnabled && typeof lanCandidates === 'function'
197
+ ? lanCandidates(port).map((candidate) => candidate.url).filter(Boolean)
198
+ : []
199
+ const v6Candidates = state.lanEnabled && typeof ipv6Candidates === 'function'
200
+ ? ipv6Candidates(port).map((candidate) => candidate.url).filter(Boolean)
201
+ : []
187
202
  sendJson(res, 200, {
188
203
  ok: true,
189
204
  instanceId,
@@ -198,6 +213,8 @@ function createPairHandler(deps) {
198
213
  // 局域网那条(lan-proxy.js)给的也是它。`hostPlatform()` 回的是 `{platform}` 对象,
199
214
  // 整块展开就变成 `platform` —— App 收不到,设备行退回通用图标(真机上少的就是企鹅)。
200
215
  ...(hostPlatform ? { hostPlatform } : {}),
216
+ ...(directCandidates.length ? { lanUrls: directCandidates } : {}),
217
+ ...(v6Candidates.length ? { ipv6Candidates: v6Candidates } : {}),
201
218
  });
202
219
  };
203
220
  }
@@ -47,24 +47,46 @@ function openEntries(sessionId) {
47
47
  return list ? list.filter((entry) => !entry.settled) : [];
48
48
  }
49
49
 
50
+ /**
51
+ * 卡片的 `intent`:只带「这是什么」的 kind 与批准按钮的标签。
52
+ *
53
+ * `dsh-plan-mode` 的 `exit_plan_mode` 也是走 `ctx.userQuestions.ask()` 提问的,问题带
54
+ * `intent:{kind:'plan-review', approve:'允许执行', callId}`。手机要靠 kind 把它渲染成
55
+ * 「计划待审」接管卡而不是一张普通选择题;callId 是宿主的内部句柄,**不许**跟着外泄。
56
+ * 没有 intent(或 kind 不合法)时返回 undefined,调用方据此不往卡里塞空对象。
57
+ */
58
+ function intentOf(question) {
59
+ const intent = question && question.intent;
60
+ if (!intent || typeof intent !== 'object' || Array.isArray(intent)) return undefined;
61
+ const kind = typeof intent.kind === 'string' ? intent.kind.trim() : '';
62
+ if (!kind) return undefined;
63
+ const approve = typeof intent.approve === 'string' ? intent.approve.trim() : '';
64
+ return { kind, ...(approve ? { approve } : {}) };
65
+ }
66
+
50
67
  /**
51
68
  * 卡片帧。形状严格照 App `streamFrame.ts` 认的那一种:**帧级 rpcId** + 顶层
52
69
  * `sessionId` + `questions`(每题至少 `id` 与 `question` 两个字符串)。
53
- * 只转发 App 认识的字段,宿主的其它内部键(如 `intent` 里的 callId)不外泄。
70
+ * 只转发 App 认识的字段;`intent` 只留 kind 与 approve,宿主的其它内部键
71
+ * (如 `intent` 里的 callId)不外泄。
54
72
  */
55
73
  function cardFrame(entry) {
56
74
  return {
57
75
  type: 'question/requested',
58
76
  sessionId: entry.sessionId,
59
77
  rpcId: entry.rpcId,
60
- questions: entry.questions.map((question) => ({
61
- id: question.id,
62
- question: question.question,
63
- ...(question.header ? { header: question.header } : {}),
64
- ...(question.detail ? { detail: question.detail } : {}),
65
- ...(question.options ? { options: question.options } : {}),
66
- ...(question.multiSelect ? { multiSelect: question.multiSelect } : {}),
67
- })),
78
+ questions: entry.questions.map((question) => {
79
+ const intent = intentOf(question);
80
+ return {
81
+ id: question.id,
82
+ question: question.question,
83
+ ...(question.header ? { header: question.header } : {}),
84
+ ...(question.detail ? { detail: question.detail } : {}),
85
+ ...(question.options ? { options: question.options } : {}),
86
+ ...(question.multiSelect ? { multiSelect: question.multiSelect } : {}),
87
+ ...(intent ? { intent } : {}),
88
+ };
89
+ }),
68
90
  };
69
91
  }
70
92