@whaletalk/dsh-codex-collab 0.1.2 → 0.1.4

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.md CHANGED
@@ -202,12 +202,17 @@ dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-..."
202
202
 
203
203
  ## 已知限制
204
204
 
205
- - 网关插件需要 `webServer` 服务(由 `dsh-web-app` 提供),因此**只能挂 web / desktop profile**;headless profile 会一直 pending。session 目标还需要同 bundle 的 `sessionController`,缺失时返回 `session-controller-unavailable`
205
+ - 网关插件需要 `webServer` 服务(由 `dsh-web-app` 提供),因此**只能挂 web / desktop profile**;headless profile 会一直 pending。session 目标还需要 `sessionController`——它由组合**异步注册**(`webserver → web-runtime → connection → file-upload → session-controller`),所以插件是惰性取用,注册前调用会返回 `session-controller-unavailable`(稍后重试即可)
206
+ - **`sessionController.search` 可能被部署禁用**(`session-query` 索引 `openAt: "never"`)。此时按标题查找自动退回 `list`(读持久化 header + 标题投影,同样不激活 Agent),响应里的 `matchedBy` 标明走了哪条路
207
+ - **不能投递进子代理会话**:`resolveAgent` 对 subagent 路由拥有的会话返回 `session-busy: owned by subagent routing`;只能接普通会话
208
+ - `POST /api/dsh-bridge/sessions {"create": true, "cwd": "..."}` 可新建一条空白会话(探针/一次性任务用),它不会碰任何已有会话
206
209
  - **同一会话同时只允许一个在途 bridge 任务**:DSH 自己会排队,但无法可靠地把两次并发派活的回报各自归属,所以第二个会被明确拒绝(`session-busy`)而不是猜
207
210
  - **冷会话可能接不上**:DSH 激活 Agent 需要会话投影(`Agent activation requires a projected Session observation`),未激活的会话会返回 `session-not-activatable`;归档会话的回合会以 `blocked` 收口(`session-archived`)
208
211
  - **评审不支持会话目标**:评审员必须与编码会话隔离,"独立评审"才成立
209
212
  - session 目标不使用 `cwd`/`lane`/`model`/`--commit`(会话自带这些语义),传了会被判 `invalid-target`
210
213
  - 网关重启会丢失内存中的任务表(含 session 观察器),这是既有设计
214
+ - **DSH profile 的 pnpm 供应链冷却**:新发布的版本约 24 小时内会被 `minimumReleaseAge` 拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`);管理器会把它写进 `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 才放行。刚发版就装不上属正常,不是包的问题
215
+ - 升级已装组合包 = **卸载 → 重新安装**;卸载会把该包从 `dsh.profile.bundles` 移除,所以重装后**要确认它被重新选中**,否则插件不会挂载(表现为所有 `/api/dsh-bridge/*` 都落到网关鉴权层返回 401)
211
216
  - Codex Desktop Windows 本地市场技能注入 bug(见上,MCP 通道不受影响)
212
217
  - 动态插件环境无 `AbortSignal`,冷恢复失败时自动降级为一次性执行;宿主组合持久化版无此问题
213
218
  - ChatGPT Plus 写入型 MCP 的开放度取决于 OpenAI 产品策略
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Codex 主导的双向编码协作插件:ChatGPT 把编码任务下发给本机 DeepSeek Harness 的持久编码子代理执行,DeepSeek 在共享工作目录 D:\\Harness 中写代码并实时回传进度与汇报,Codex 读文件验收、反馈修正,直到通过。",
5
5
  "author": {
6
6
  "name": "FuJackGao"
@@ -84,5 +84,7 @@ dsh-task --cancel <taskId> --force # 取消 session 目标任务(会打断
84
84
 
85
85
  - 连接失败:DeepSeek Harness 未运行(网关应监听本机端口,默认 3080;挂在 web/desktop profile 时也可能就是 GUI 的端口)。
86
86
  - 派 session 任务返回 `session-writer-held` / `session-busy`:那条会话被别的写入方或另一个 bridge 任务占用;稍后重试,或改用 worker 目标。
87
+ - 返回 `session-busy: owned by subagent routing`:那是子代理会话,**不能作为投递目标**;只能接普通会话。
87
88
  - 返回 `session-not-activatable` / `session-archived`:该会话当前接不了(冷会话未能激活,或已归档);请用户先在客户端打开它,或解除归档。
89
+ - 某些部署禁用了搜索(会话索引 `openAt: never`):`--sessions` 会自动退回 `list` + 标题投影匹配,响应里的 `matchedBy` 会写 `list`。
88
90
  - 读不到 `--cwd` 目录:提示用户在 Codex 设置中把该目录加入可访问/可写目录。
@@ -29,6 +29,8 @@ import {
29
29
  classifyTarget,
30
30
  selectSession,
31
31
  mapControllerError,
32
+ sessionTitleOf,
33
+ looksLikeDisabledSearch,
32
34
  taskResponse,
33
35
  } from './session-target.mjs'
34
36
 
@@ -46,9 +48,11 @@ export async function apply(ctx) {
46
48
  const sessionPersistence = ctx.get('sessionPersistence')
47
49
  const workspaceRegistry = ctx.get('workspaceRegistry')
48
50
  const llm = ctx.get('llm')
49
- // 已有会话的读写入口(由 dsh-web-app bundle 提供,与 webServer 同源)。
50
- // 不放进 inject:缺失时降级为稳定的 session-controller-unavailable,而不是整个插件挂掉。
51
- const sessionController = ctx.get('sessionController')
51
+ // 已有会话的读写入口。**必须惰性取**:@deepseek-ai/dsh-api-session-controller 的
52
+ // sessionController 由组合异步注册(DSH 启动日志里它先处于 waiting for services),
53
+ // 在 apply() 时捕获会永久拿到 undefined。每次使用时现取;缺失时返回稳定的
54
+ // session-controller-unavailable,而不是让整个插件挂掉。
55
+ const sessionControllerNow = () => ctx.get('sessionController')
52
56
 
53
57
  // 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
54
58
  try {
@@ -397,20 +401,66 @@ export async function apply(ctx) {
397
401
  const sessionTargets = new Map()
398
402
 
399
403
  const sessionSearch = async (query) => {
400
- const value = await sessionController.search({ query }, signalOf())
404
+ const controller = sessionControllerNow()
405
+ if (controller === undefined) {
406
+ throw Object.assign(new Error('sessionController service is not mounted in this profile'), { code: 'bridge/no-controller' })
407
+ }
408
+ const value = await controller.search({ query }, signalOf())
401
409
  const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
402
410
  return items.filter((it) => it !== null && typeof it === 'object' && typeof it.sessionId === 'string' && it.sessionId !== '')
403
411
  }
404
412
 
413
+ /**
414
+ * 退回路径:不打开 search 索引,直接遍历 list(读持久化 header + 投影缓存)。
415
+ * 有些部署把 session-query 索引配成 openAt: "never",此时 search 永远失败,
416
+ * 而按标题找会话仍必须可用——list 就是不激活 Agent 也能拿到标题的那条路。
417
+ */
418
+ const sessionList = async (query) => {
419
+ const controller = sessionControllerNow()
420
+ if (controller === undefined) {
421
+ throw Object.assign(new Error('sessionController service is not mounted in this profile'), { code: 'bridge/no-controller' })
422
+ }
423
+ const needle = query.toLowerCase()
424
+ const value = await controller.list({}, signalOf())
425
+ const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
426
+ const out = []
427
+ for (const summary of items) {
428
+ if (summary === null || typeof summary !== 'object' || typeof summary.sessionId !== 'string') continue
429
+ const title = sessionTitleOf(summary)
430
+ if (title !== '' && title.toLowerCase().includes(needle)) {
431
+ out.push({ sessionId: summary.sessionId, snippet: title })
432
+ continue
433
+ }
434
+ if (typeof summary.cwd === 'string' && summary.cwd !== '' && summary.cwd.toLowerCase().includes(needle)) {
435
+ out.push({ sessionId: summary.sessionId, snippet: summary.cwd })
436
+ }
437
+ }
438
+ return out
439
+ }
440
+
441
+ /** 先 search,失败就退回 list;返回 {hits, matchedBy} 让调用方看得见走了哪条路。 */
442
+ const sessionFind = async (query) => {
443
+ try {
444
+ return { hits: await sessionSearch(query), matchedBy: 'search' }
445
+ } catch (e) {
446
+ if (!looksLikeDisabledSearch(e)) throw e
447
+ return { hits: await sessionList(query), matchedBy: 'list' }
448
+ }
449
+ }
450
+
405
451
  /** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
406
452
  const resolveSessionTarget = async (target) => {
407
- if (sessionController === undefined) {
453
+ const controller = sessionControllerNow()
454
+ if (controller === undefined) {
408
455
  return { ok: false, reason: REASONS.CONTROLLER_UNAVAILABLE, message: 'sessionController service is not mounted in this profile', candidates: [] }
409
456
  }
410
457
  let hits = []
458
+ let matchedBy = 'sessionId'
411
459
  if (target.sessionId === undefined) {
412
460
  try {
413
- hits = await sessionSearch(target.query)
461
+ const found = await sessionFind(target.query)
462
+ hits = found.hits
463
+ matchedBy = found.matchedBy
414
464
  } catch (e) {
415
465
  return { ok: false, reason: REASONS.GATEWAY_INTERNAL, message: errText(e), candidates: [] }
416
466
  }
@@ -419,14 +469,14 @@ export async function apply(ctx) {
419
469
  if (!picked.ok) return picked
420
470
  let result
421
471
  try {
422
- result = await sessionController.resolveAgent(picked.sessionId)
472
+ result = await controller.resolveAgent(picked.sessionId)
423
473
  } catch (e) {
424
474
  return { ok: false, reason: mapControllerError(e), message: errText(e), candidates: picked.candidates }
425
475
  }
426
476
  if (result !== undefined && result !== null && result.error !== undefined) {
427
477
  return { ok: false, reason: mapControllerError(result.error), message: errText(result.error), candidates: picked.candidates }
428
478
  }
429
- return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates }
479
+ return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates, matchedBy }
430
480
  }
431
481
 
432
482
  const sessionPendingTask = (sessionId) => tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
@@ -478,7 +528,9 @@ export async function apply(ctx) {
478
528
  record.watcher = 'follow'
479
529
  void (async () => {
480
530
  try {
481
- const stream = sessionController.follow({ address: { kind: 'session', sessionId: record.sessionId } }, controller.signal)
531
+ const controllerRef = sessionControllerNow()
532
+ if (controllerRef === undefined) throw new Error('sessionController is not mounted')
533
+ const stream = controllerRef.follow({ address: { kind: 'session', sessionId: record.sessionId } }, controller.signal)
482
534
  for await (const frame of stream) {
483
535
  if (record.settled) break
484
536
  if (frame === null || typeof frame !== 'object') continue
@@ -523,6 +575,7 @@ export async function apply(ctx) {
523
575
  task.target.sessionId = resolved.sessionId
524
576
  if (resolved.snippet !== undefined) task.target.snippet = resolved.snippet
525
577
  if (resolved.candidates.length > 0) task.candidates = resolved.candidates
578
+ if (resolved.matchedBy !== undefined) task.matchedBy = resolved.matchedBy
526
579
 
527
580
  const inFlight = sessionPendingTask(resolved.sessionId)
528
581
  if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
@@ -552,7 +605,17 @@ export async function apply(ctx) {
552
605
  sessionTargets.set(resolved.sessionId, record)
553
606
 
554
607
  try {
555
- await sessionController.prompt({
608
+ const controllerRef = sessionControllerNow()
609
+ if (controllerRef === undefined) {
610
+ sessionTargets.delete(resolved.sessionId)
611
+ record.settled = true
612
+ task.status = 'error'
613
+ task.error = 'sessionController service is not mounted in this profile'
614
+ task.reason = REASONS.CONTROLLER_UNAVAILABLE
615
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
616
+ return { task, failed: { reason: task.reason, message: task.error, candidates: [] } }
617
+ }
618
+ await controllerRef.prompt({
556
619
  requestId: task.taskId,
557
620
  sessionId: resolved.sessionId,
558
621
  mode: deliver,
@@ -720,7 +783,8 @@ export async function apply(ctx) {
720
783
  }
721
784
 
722
785
  // 完成信号:会话从 running 回到 idle 即这一轮结束(follow 只负责流式进度)。
723
- if (sessionController !== undefined) {
786
+ // 订阅的是 ctx 上的事件,与服务何时注册无关,所以不设门禁。
787
+ {
724
788
  ctx.effect(() => {
725
789
  const off = ctx.on('api-session/status', (sessionId, running) => {
726
790
  const record = sessionTargets.get(sessionId)
@@ -819,12 +883,24 @@ export async function apply(ctx) {
819
883
  } else {
820
884
  parsed = qsOf(req)
821
885
  }
822
- if (sessionController === undefined) {
823
- sendJson(res, 503, { error: 'sessionController service is not mounted in this profile', reason: REASONS.CONTROLLER_UNAVAILABLE })
886
+ const controller = sessionControllerNow()
887
+ if (controller === undefined) {
888
+ sendJson(res, 503, { error: 'sessionController service is not mounted yet (it registers asynchronously after startup; retry shortly)', reason: REASONS.CONTROLLER_UNAVAILABLE })
824
889
  return
825
890
  }
826
891
  const query = typeof parsed.query === 'string' && parsed.query.trim() !== '' ? parsed.query.trim() : undefined
827
892
  const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.trim() !== '' ? parsed.sessionId.trim() : undefined
893
+ // create 是显式动作:为探针/一次性任务建一条空白会话,不去碰任何已有会话。
894
+ if (parsed.create === true) {
895
+ const cwd = typeof parsed.cwd === 'string' && parsed.cwd.trim() !== '' ? parsed.cwd.trim() : undefined
896
+ try {
897
+ const created = await controller.create(cwd === undefined ? {} : { cwd }, signalOf())
898
+ sendJson(res, 201, { sessionId: created !== undefined && created !== null ? created.sessionId : undefined, agentPreset: created === undefined || created === null ? undefined : created.agentPreset })
899
+ } catch (e) {
900
+ sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
901
+ }
902
+ return
903
+ }
828
904
  if (query === undefined && sessionId === undefined) {
829
905
  sendJson(res, 400, { error: 'query or sessionId required', reason: REASONS.INVALID_TARGET })
830
906
  return
@@ -840,7 +916,9 @@ export async function apply(ctx) {
840
916
  sendJson(res, 200, { items: [{ sessionId: resolved.sessionId, ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }), agentAvailable: true }], hasMore: false })
841
917
  return
842
918
  }
843
- const hits = await sessionSearch(query)
919
+ // search 失败就退回 list:有些部署把 session-query 索引配成 openAt "never"。
920
+ const found = await sessionFind(query)
921
+ const hits = found.hits
844
922
  const items = hits.slice(0, limit).map((h) => ({
845
923
  sessionId: h.sessionId,
846
924
  snippet: typeof h.snippet === 'string' ? h.snippet.slice(0, 200) : '',
@@ -849,7 +927,7 @@ export async function apply(ctx) {
849
927
  // 只对前 5 条探能力:逐条 stat 会很贵,这里保持有界。
850
928
  for (const item of items.slice(0, 5)) {
851
929
  try {
852
- const r = await sessionController.resolveAgent(item.sessionId)
930
+ const r = await controller.resolveAgent(item.sessionId)
853
931
  const failed = r !== undefined && r !== null && r.error !== undefined
854
932
  item.agentAvailable = !failed
855
933
  if (failed) item.problem = mapControllerError(r.error)
@@ -859,7 +937,7 @@ export async function apply(ctx) {
859
937
  }
860
938
  }
861
939
  }
862
- sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length })
940
+ sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length, matchedBy: found.matchedBy })
863
941
  } catch (e) {
864
942
  sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
865
943
  }
@@ -914,7 +992,9 @@ export async function apply(ctx) {
914
992
  }
915
993
  let cancelled = false
916
994
  try {
917
- await sessionController.cancel({ sessionId: task.target.sessionId }, signalOf())
995
+ const controller = sessionControllerNow()
996
+ if (controller === undefined) throw new Error('sessionController service is not mounted in this profile')
997
+ await controller.cancel({ sessionId: task.target.sessionId }, signalOf())
918
998
  cancelled = true
919
999
  } catch (e) {
920
1000
  console.error('[dsh-bridge] session cancel failed:', errText(e).slice(0, 200))
@@ -960,6 +1040,7 @@ export async function apply(ctx) {
960
1040
  taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
961
1041
  target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
962
1042
  result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
1043
+ ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
963
1044
  ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
964
1045
  ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
965
1046
  })
@@ -971,7 +1052,7 @@ export async function apply(ctx) {
971
1052
  lanes: Array.from(buckets.keys()),
972
1053
  reviewers: Array.from(reviewerBuckets.keys()),
973
1054
  sessions: Array.from(sessionTargets.keys()),
974
- sessionController: sessionController !== undefined,
1055
+ sessionController: sessionControllerNow() !== undefined,
975
1056
  tasks: tasks.slice(-20).map((t) => ({ taskId: t.taskId, kind: t.kind, status: t.status, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver })),
976
1057
  })
977
1058
  },
@@ -982,7 +1063,15 @@ export async function apply(ctx) {
982
1063
  path: '/api/dsh-bridge/debug',
983
1064
  handler: async (req, res) => {
984
1065
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
985
- const out = { pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [], sessionController: sessionController !== undefined, sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [] }
1066
+ // services 是现场探测(不是 apply 时的快照):sessionController 可能晚于本插件注册,
1067
+ // 这份清单能在"接不上会话"时直接告诉我们到底缺哪个服务。
1068
+ const probe = (name) => { try { return ctx.get(name) !== undefined } catch (e) { return false } }
1069
+ const out = {
1070
+ pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [],
1071
+ sessionController: probe('sessionController'),
1072
+ services: Object.fromEntries(['sessionController', 'connection', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry', 'agents', 'subagents', 'agentPresets', 'timer', 'llm'].map((n) => [n, probe(n)])),
1073
+ sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [],
1074
+ }
986
1075
  out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
987
1076
  try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
988
1077
  if (agentPresets !== undefined) {
@@ -1124,5 +1213,5 @@ export async function apply(ctx) {
1124
1213
  sessionTargets.clear()
1125
1214
  })
1126
1215
 
1127
- console.log('[dsh-bridge] persistent gateway ready (host composition): task(new|session) · sessions · review · tasks · cancel · status · debug · WS | sessionController=' + (sessionController !== undefined ? 'yes' : 'no'))
1216
+ console.log('[dsh-bridge] persistent gateway ready (host composition): task(new|session) · sessions · review · tasks · cancel · status · debug · WS | sessionController=' + (sessionControllerNow() !== undefined ? 'yes' : 'late-bound (registers asynchronously)'))
1128
1217
  }
@@ -190,6 +190,34 @@ export const mapControllerError = (error) => {
190
190
  }
191
191
  }
192
192
 
193
+ /**
194
+ * 从 SessionSummary 里取标题。
195
+ *
196
+ * 标题不在 summary 顶层:它来自投影缓存(`projections.values.title`),而且不同 DSH
197
+ * 版本可能给字符串或 `{ val }` 包装,两种都认。`list` 在**不激活 Agent、也不打开
198
+ * search 索引**的前提下读持久化 header 与投影缓存,因此在 search 被部署禁用
199
+ * (session-query 索引 openAt: "never")时,它是唯一还能按标题找会话的路径。
200
+ */
201
+ export const sessionTitleOf = (summary) => {
202
+ if (summary === null || typeof summary !== 'object') return ''
203
+ const projections = summary.projections
204
+ if (projections === null || typeof projections !== 'object') return ''
205
+ const values = projections.values
206
+ if (values === null || typeof values !== 'object') return ''
207
+ const title = values.title
208
+ if (typeof title === 'string') return title
209
+ if (title !== null && typeof title === 'object' && typeof title.val === 'string') return title.val
210
+ return ''
211
+ }
212
+
213
+ /** search 在本部署被禁用时的报错特征(session-query 索引 openAt: "never")。 */
214
+ export const looksLikeDisabledSearch = (error) => {
215
+ const text = error !== null && typeof error === 'object' && typeof error.message === 'string'
216
+ ? error.message
217
+ : String(error === undefined || error === null ? '' : error)
218
+ return /search is disabled/i.test(text)
219
+ }
220
+
193
221
  /** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
194
222
  export const taskResponse = (task) => {
195
223
  const out = { taskId: task.taskId, status: task.status, kind: task.kind }
package/mcp/dsh-mcp.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // 客户端(Secure MCP Tunnel / cloudflared)桥接给 ChatGPT 等远程产品。
7
7
  const GATEWAY = process.env.DSH_BRIDGE_URL || 'http://127.0.0.1:3080'
8
8
  const SERVER_NAME = 'dsh-bridge'
9
- const SERVER_VERSION = '0.1.2'
9
+ const SERVER_VERSION = '0.1.4'
10
10
  const PROTOCOL_VERSION = '2024-11-05'
11
11
 
12
12
  const log = (...args) => console.error('[dsh-mcp]', ...args)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whaletalk/dsh-codex-collab",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Codex × DeepSeek Harness 双向编码协作:Codex 主导派活与验收,DeepSeek Harness 编码子代理在共享工作目录执行并回传,独立评审子代理实际跑通构建/测试后出具报告。提供 DSH 宿主网关插件、零依赖 MCP 服务器与 Codex 技能三件套。",
5
5
  "license": "MIT",
6
6
  "type": "module",