@modesto_ma/dsh-channels 0.3.0 → 0.4.0

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.
@@ -29,6 +29,7 @@ import { appendSessionHistory, sessionDirOf } from '../domain.ts'
29
29
  import type { RouteRecord } from '../domain.ts'
30
30
  import { mountChannelGuidance } from '../feishu/agent-context.ts'
31
31
  import { attachmentsDir } from '../feishu/inbound-attachments.ts'
32
+ import { effectiveAttachmentMode, resolveAttachmentService } from '../feishu/native-attachments.ts'
32
33
  import { installRequestPolicy } from './request-policy.ts'
33
34
  import type { RequestPolicyController } from './request-policy.ts'
34
35
  import { placementServices, restoreTopicDir } from './isolation.ts'
@@ -65,6 +66,14 @@ export interface SessionManagerDeps {
65
66
  started(): boolean
66
67
  /** Drop streamer state for a session whose owner handle is being released. */
67
68
  detachStream(sessionId: string): void
69
+ /**
70
+ * Register this channel's model tools inside one Agent scope. Called from
71
+ * the agent-preset `setup` after the preset is mounted (tools live inside
72
+ * presets in web-profile deployments), and once more for a BORROWED live
73
+ * agent as a best-effort race guard (D7). Optional: unit tests that do not
74
+ * compose model tools omit it.
75
+ */
76
+ mountTools?(agentCtx: AgentContext, sessionId: string): Promise<void> | void
68
77
  warn(message: string, ...args: unknown[]): void
69
78
  }
70
79
 
@@ -82,6 +91,8 @@ export class SessionManager {
82
91
  private readonly live = new Map<string, LiveAgent>()
83
92
  private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
84
93
  private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
94
+ /** Agent scopes this plugin already attempted to mount model tools into. */
95
+ private readonly toolMounts = new WeakSet<AgentContext>()
85
96
 
86
97
  constructor(private readonly deps: SessionManagerDeps) {}
87
98
 
@@ -116,7 +127,7 @@ export class SessionManager {
116
127
  cwd: string,
117
128
  agentOptions: Record<string, unknown> = this.agentOptions(),
118
129
  ): Promise<AgentHandle> {
119
- const composition = await this.presetComposition(undefined, true)
130
+ const composition = await this.presetComposition(undefined, true, sessionId)
120
131
  const handle = await this.deps.ctx.agents.create({
121
132
  sessionId,
122
133
  meta: {
@@ -132,15 +143,31 @@ export class SessionManager {
132
143
  }
133
144
 
134
145
  /**
135
- * Register the attachment-directory guidance for one Session. Called after
136
- * the effective working directory is known (a worktree checkout path is
137
- * only returned once the worktree exists), so the prompt section can name
138
- * the absolute directory. Best-effort: a missing system-prompt service
139
- * (headless) is a no-op and a late registration never blocks the turn.
146
+ * Register the attachment guidance for one Session. Called after the
147
+ * effective working directory is known (a worktree checkout path is only
148
+ * returned once the worktree exists), so the archive guidance can name the
149
+ * absolute directory. The guidance flavor follows the *effective* attachment
150
+ * backend: an explicitly configured archive deployment, or a native
151
+ * deployment whose host does not mount `ctx.attachments`, keeps the legacy
152
+ * workspace-directory instructions. Best-effort: a missing system-prompt
153
+ * service (headless) is a no-op and a late registration never blocks the turn.
140
154
  */
141
155
  async mountGuidance(agent: Agent, directory: string): Promise<void> {
156
+ // With inbound attachments disabled the Agent has no attachment workflow to
157
+ // discover (and the legacy guidance would wrongly claim files are archived).
158
+ if (!this.deps.config.acceptAttachments) return
159
+ const mode = effectiveAttachmentMode(
160
+ this.deps.config.attachmentStore,
161
+ resolveAttachmentService(this.deps.ctx),
162
+ this.deps.config.feishuTools.attachmentFetch,
163
+ this.deps.config.acceptAttachments,
164
+ )
142
165
  try {
143
- await mountChannelGuidance(agent.ctx, attachmentsDir(directory))
166
+ await mountChannelGuidance(
167
+ agent.ctx,
168
+ mode,
169
+ mode === 'archive' ? attachmentsDir(directory) : undefined,
170
+ )
144
171
  } catch (error) {
145
172
  this.deps.warn(
146
173
  'dsh-channels: mounting attachment guidance for session %s failed: %s',
@@ -166,7 +193,7 @@ export class SessionManager {
166
193
  if (own !== undefined) return own
167
194
 
168
195
  const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
169
- if (foreign !== undefined) return this.borrow(sessionId, foreign)
196
+ if (foreign !== undefined) return await this.borrow(sessionId, foreign)
170
197
 
171
198
  let pending = this.pendingResume.get(sessionId)
172
199
  if (pending === undefined) {
@@ -179,9 +206,14 @@ export class SessionManager {
179
206
  }
180
207
 
181
208
  /** Borrow one foreign live agent and mount this channel's scoped policy. */
182
- private borrow(sessionId: string, agent: Agent): LiveAgent {
209
+ private async borrow(sessionId: string, agent: Agent): Promise<LiveAgent> {
183
210
  const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
184
211
  this.live.set(sessionId, borrowed)
212
+ // Best-effort tool backfill (D7): the foreign owner may have composed the
213
+ // session without this plugin's setup (web UI resolver, previous plugin
214
+ // instance). mountToolsFor swallows failures, so the borrow itself never
215
+ // fails; awaiting it makes the tool available before the first turn.
216
+ await this.mountToolsFor(agent.ctx, sessionId)
185
217
  return borrowed
186
218
  }
187
219
 
@@ -193,7 +225,7 @@ export class SessionManager {
193
225
  // (config override or roster default). A session persisted before the
194
226
  // preset fix has no preset recorded on its header; re-resolving here is
195
227
  // what repairs it on the first resume after a restart.
196
- const composition = await this.presetComposition(undefined, true)
228
+ const composition = await this.presetComposition(undefined, true, sessionId)
197
229
  const handle = await this.deps.ctx.agents.resume({
198
230
  resumeSessionId: sessionId,
199
231
  agentOptions: this.agentOptions(),
@@ -215,7 +247,7 @@ export class SessionManager {
215
247
  // Lost a cold-resume race: another owner published the session while our
216
248
  // resume was in flight. Borrow the winner instead of failing the turn.
217
249
  const winner = this.deps.ctx.agents.get(sessionId)
218
- if (winner !== undefined) return this.borrow(sessionId, winner)
250
+ if (winner !== undefined) return await this.borrow(sessionId, winner)
219
251
  throw error
220
252
  }
221
253
  }
@@ -255,7 +287,7 @@ export class SessionManager {
255
287
  async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
256
288
  const sessionId = randomUUID() as SessionId
257
289
  const preset = this.effectivePreset(oldLive.agent.session)
258
- const composition = await this.presetComposition(preset, false)
290
+ const composition = await this.presetComposition(preset, false, sessionId)
259
291
  const services: PlacementServices = placementServices(this.deps.ctx)
260
292
  const switchable = services.workingDirectory !== undefined
261
293
  const workspacePath = record.workspacePath
@@ -358,6 +390,7 @@ export class SessionManager {
358
390
  private async presetComposition(
359
391
  requestedPreset: string | undefined = this.deps.config.agentPreset,
360
392
  useRosterDefault = true,
393
+ sessionId?: string,
361
394
  ): Promise<PresetComposition> {
362
395
  // Read through ctx.get (safe lookup): only the web profile ships a roster,
363
396
  // and direct property access on a cordis context throws for services the
@@ -369,6 +402,7 @@ export class SessionManager {
369
402
  // cannot guarantee that the explicit channel value wins.
370
403
  this.mountRequestPolicy(agentCtx)
371
404
  await mountChannelCommands(agentCtx)
405
+ if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
372
406
  }
373
407
  if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
374
408
  return { setup: channelCommandsOnly }
@@ -380,10 +414,30 @@ export class SessionManager {
380
414
  this.mountRequestPolicy(agentCtx)
381
415
  await presets.mount(agentCtx, resolved.id)
382
416
  await mountChannelCommands(agentCtx)
417
+ if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
383
418
  },
384
419
  }
385
420
  }
386
421
 
422
+ /**
423
+ * Mount the channel model tools into one Agent scope, at most once per
424
+ * scope. A failure is logged and swallowed: missing tools never fail a
425
+ * session (attachment fetch degrades to its structured failures).
426
+ */
427
+ private async mountToolsFor(agentCtx: AgentContext, sessionId: string): Promise<void> {
428
+ if (this.deps.mountTools === undefined) return
429
+ if (this.toolMounts.has(agentCtx)) return
430
+ try {
431
+ await this.deps.mountTools(agentCtx, sessionId)
432
+ this.toolMounts.add(agentCtx)
433
+ } catch (error) {
434
+ this.deps.warn(
435
+ 'dsh-channels: mounting channel tools for session %s failed: %s',
436
+ sessionId, String(error),
437
+ )
438
+ }
439
+ }
440
+
387
441
  /** Effective preset after folding any durable runtime preset switch. */
388
442
  private effectivePreset(session: Session): string | undefined {
389
443
  let selected = session.header.agentPreset
@@ -40,9 +40,10 @@ export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
40
40
  export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
41
41
 
42
42
  /**
43
- * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
44
- * 覆盖值写进 `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile
45
- * 的 `cordis.patch.yml`(见 README「Web 设置面板」)。
43
+ * 可由 Web 面板覆盖的配置项。顶层键对应 `src/config.ts` 的字段;`feishuTools.*`
44
+ * 是嵌套字段(覆盖层里仍写成 `{ "feishuTools": { ... } }` 对象)。覆盖值写进
45
+ * `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile 的
46
+ * `cordis.patch.yml`(见 README「Web 设置面板」)。
46
47
  *
47
48
  * 刻意不包含 `appId` / `appSecret`:它们是**凭据引用名**,面板改为直接
48
49
  * 编辑引用背后的值(`PUT /dsh-channels/credentials`,经 DSH 凭据服务写入),
@@ -67,8 +68,24 @@ export const EDITABLE_KEYS = [
67
68
  'adminOpenId',
68
69
  'accessMode',
69
70
  'authRequestTtlDays',
70
- // 内容
71
+ // 内容与附件(v2 管线;`feishuTools.*` 是嵌套配置,覆盖层仍写成嵌套对象)
71
72
  'acceptAttachments',
73
+ 'attachmentStore',
74
+ 'prefetchImages',
75
+ 'prefetchTimeoutMs',
76
+ 'prefetchImageMaxBytes',
77
+ 'prefetchTotalMaxBytes',
78
+ 'historyBootstrapLimit',
79
+ 'pendingAttachmentTtlMs',
80
+ 'pendingAttachmentLimit',
81
+ // 模型工具(feishuTools 子项)
82
+ 'feishuTools.attachmentFetch',
83
+ 'feishuTools.attachmentFetchMaxResources',
84
+ 'feishuTools.attachmentFetchMaxBytes',
85
+ 'feishuTools.attachmentFetchTimeoutMs',
86
+ 'feishuTools.topicMessages',
87
+ 'feishuTools.topicMessagesTimeoutMs',
88
+ 'promptContextTimeoutMs',
72
89
  // 模型
73
90
  'provider',
74
91
  'model',
@@ -104,7 +121,7 @@ export type FieldKind = 'boolean' | 'number' | 'text' | 'select'
104
121
  * panel tab (see {@link TAB_FIELD_GROUPS}); the Host is the source of truth for
105
122
  * which key belongs to which group.
106
123
  */
107
- export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'model' | 'streaming' | 'access'
124
+ export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'tools' | 'model' | 'streaming' | 'access'
108
125
 
109
126
  /** One selectable value rendered by the generic form. */
110
127
  export interface FieldOptionWire {
@@ -486,7 +503,7 @@ export type PanelTabId = (typeof PANEL_TABS)[number]['id']
486
503
  /** Which editable field groups each tab renders (the tabs own one or two). */
487
504
  export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]>> = {
488
505
  connection: [],
489
- sessions: ['behavior', 'content'],
506
+ sessions: ['behavior', 'content', 'tools'],
490
507
  model: ['model'],
491
508
  workspace: ['workspace'],
492
509
  access: ['access'],
@@ -498,6 +515,7 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
498
515
  workspace: '工作区与文件',
499
516
  behavior: '行为',
500
517
  content: '内容与附件',
518
+ tools: '模型工具',
501
519
  model: '模型与推理',
502
520
  streaming: '流式卡片与日志',
503
521
  access: '准入与授权',
@@ -570,9 +588,9 @@ export const PANEL_COPY = {
570
588
  credentialsPendingHint: '填写后点「保存凭据」;两项齐全时插件会自动连接飞书。',
571
589
  // ---- tabs / forms -------------------------------------------------------
572
590
  connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
573
- sessionsTabHelp: '私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。',
591
+ sessionsTabHelp: '私聊、群聊触发方式与入站内容:附件存储/预取、静默登记保留、模型工具开关与读取预算;「需重连」项在下次连接时读取。',
574
592
  modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
575
- workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。',
593
+ workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录,也不会被这里的目录清理删除。',
576
594
  accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
577
595
  advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
578
596
  save: '保存更改',
@@ -621,7 +639,7 @@ export const PANEL_COPY = {
621
639
  // ---- model --------------------------------------------------------------
622
640
  // ---- cleanup ------------------------------------------------------------
623
641
  cleanupCard: '目录清理',
624
- cleanupCardHelp: '删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
642
+ cleanupCardHelp: '只清理工作区与 Agent 产物:删除超过保留期且不再活跃的飞书话题目录、旧版本按话题建立的工作区目录,以及这些目录内 Agent 产生的文件;不影响 Session 附件——入站图片/文件默认存于 DSH 原生附件库,不随目录清理删除。用户工作区本身与 DSH 会话日志也不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
625
643
  cleanupRetention: '保留期',
626
644
  cleanupAutoLabel: '自动清理',
627
645
  cleanupCandidates: '可清理目录',
@@ -633,7 +651,7 @@ export const PANEL_COPY = {
633
651
  cleanupAutoInactive: '未执行(保留期为关闭)',
634
652
  cleanupNow: '立即清理',
635
653
  cleanupRunning: '清理中…',
636
- cleanupConfirm: '确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。',
654
+ cleanupConfirm: '确定清理过期目录吗?只清理工作区与 Agent 产物,不影响 Session 附件;但这些目录内未提交的文件会被删除,且操作不可恢复。',
637
655
  cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
638
656
  cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
639
657
  cleanupDisabledEmpty: '没有超过保留期的过期目录。',
@@ -20,10 +20,89 @@ import { configSchema } from '../config.ts'
20
20
  import type { FeishuConfig } from '../config.ts'
21
21
  import { normalizeWorkspaceBaseDir } from '../workspaces.ts'
22
22
  import { EDITABLE_KEYS } from './contract.ts'
23
- import type { ConfigValue, EditableKey } from './contract.ts'
23
+ import type { ConfigValue, EditableKey, EditableValues } from './contract.ts'
24
24
 
25
- /** The subset of `FeishuConfig` the Web panel may override. */
26
- export type OverlayPatch = Partial<Pick<FeishuConfig, EditableKey>>
25
+ /** Prefix of a nested panel key that addresses one `feishuTools` field. */
26
+ const FEISHU_TOOLS_PREFIX = 'feishuTools.'
27
+
28
+ /** Panel keys that address a nested `feishuTools` field (e.g. `feishuTools.topicMessages`). */
29
+ export type NestedEditableKey = Extract<EditableKey, `feishuTools.${string}`>
30
+
31
+ /** Panel keys that address a top-level config field. */
32
+ export type TopLevelEditableKey = Exclude<EditableKey, NestedEditableKey>
33
+
34
+ /** One partial `feishuTools` object inside the overlay layer. */
35
+ export type FeishuToolsOverlay = Partial<FeishuConfig['feishuTools']>
36
+
37
+ /**
38
+ * The subset of `FeishuConfig` the Web panel may override. `feishuTools` stays
39
+ * partial: the overlay only records the sub-fields the operator actually
40
+ * changed, and the merge keeps every other value from the profile layer.
41
+ */
42
+ export type OverlayPatch = Partial<Pick<FeishuConfig, TopLevelEditableKey>> & {
43
+ feishuTools?: FeishuToolsOverlay
44
+ }
45
+
46
+ /** Whether one editable key addresses a nested `feishuTools` field. */
47
+ export function isNestedEditableKey(key: EditableKey): key is NestedEditableKey {
48
+ return key.startsWith(FEISHU_TOOLS_PREFIX)
49
+ }
50
+
51
+ /** `attachmentFetch` for the panel key `feishuTools.attachmentFetch`. */
52
+ function nestedFieldOf(key: NestedEditableKey): keyof FeishuConfig['feishuTools'] {
53
+ return key.slice(FEISHU_TOOLS_PREFIX.length) as keyof FeishuConfig['feishuTools']
54
+ }
55
+
56
+ /** The `feishuTools` object of an untyped overlay/config record, when usable. */
57
+ function toolsRecordOf(container: Record<string, unknown>): Record<string, unknown> | undefined {
58
+ const tools = container.feishuTools
59
+ if (typeof tools !== 'object' || tools === null || Array.isArray(tools)) return undefined
60
+ return tools as Record<string, unknown>
61
+ }
62
+
63
+ /** Read one editable value out of an untyped overlay record (nested-aware). */
64
+ export function overlayValueOf(source: Record<string, unknown>, key: EditableKey): unknown {
65
+ if (!isNestedEditableKey(key)) return source[key]
66
+ return toolsRecordOf(source)?.[nestedFieldOf(key)]
67
+ }
68
+
69
+ /** Write one editable value into an untyped overlay record (nested-aware). */
70
+ export function setOverlayValue(target: Record<string, unknown>, key: EditableKey, value: unknown): void {
71
+ if (!isNestedEditableKey(key)) {
72
+ target[key] = value
73
+ return
74
+ }
75
+ const tools = toolsRecordOf(target) ?? {}
76
+ tools[nestedFieldOf(key)] = value
77
+ target.feishuTools = tools
78
+ }
79
+
80
+ /** Read one editable value out of a resolved config (nested-aware). */
81
+ export function configValueOf(config: FeishuConfig, key: EditableKey): ConfigValue | undefined {
82
+ if (!isNestedEditableKey(key)) return config[key] as ConfigValue | undefined
83
+ return config.feishuTools[nestedFieldOf(key)] as ConfigValue | undefined
84
+ }
85
+
86
+ /** Deep-merge one overlay patch over another (only `feishuTools` is nested). */
87
+ export function mergeOverlayPatch(base: OverlayPatch, addition: OverlayPatch): OverlayPatch {
88
+ const merged: OverlayPatch = { ...base, ...addition }
89
+ if (base.feishuTools !== undefined || addition.feishuTools !== undefined) {
90
+ merged.feishuTools = { ...base.feishuTools, ...addition.feishuTools }
91
+ }
92
+ return merged
93
+ }
94
+
95
+ /** Remove one editable key from an overlay patch (nested-aware). */
96
+ export function deleteOverlayKey(patch: OverlayPatch, key: EditableKey): void {
97
+ if (!isNestedEditableKey(key)) {
98
+ delete patch[key as TopLevelEditableKey]
99
+ return
100
+ }
101
+ const tools = patch.feishuTools
102
+ if (tools === undefined) return
103
+ delete tools[nestedFieldOf(key)]
104
+ if (Object.keys(tools).length === 0) delete patch.feishuTools
105
+ }
27
106
 
28
107
  /** Fields whose empty string means "clear the override" (optional text config). */
29
108
  const OPTIONAL_TEXT_KEYS = new Set<EditableKey>(['provider', 'model', 'reasoningEffort', 'agentPreset'])
@@ -43,24 +122,38 @@ export function parseOverlay(raw: unknown): { ok: true; patch: OverlayPatch } |
43
122
  const issues: Array<{ path: string; message: string }> = []
44
123
  const patch: Record<string, unknown> = {}
45
124
  const shape = configSchema.shape as unknown as Record<string, z.ZodType>
125
+ const toolsShape = configSchema.shape.feishuTools
126
+ if (Object.hasOwn(source, 'feishuTools') && source.feishuTools !== undefined
127
+ && toolsRecordOf(source) === undefined) {
128
+ issues.push({ path: 'feishuTools', message: '必须是 JSON 对象' })
129
+ }
46
130
  for (const key of EDITABLE_KEYS) {
47
- if (!Object.hasOwn(source, key)) continue
48
- const value = source[key]
131
+ // Nested keys have no own property on the root object, so read through the
132
+ // shared accessor instead of `Object.hasOwn`.
133
+ const value = overlayValueOf(source, key)
49
134
  if (value === undefined) continue
50
135
  // `baseDir` needs location semantics the shared string schema cannot
51
136
  // express (absolute only, `~` expansion, no filesystem root).
52
137
  if (key === 'baseDir') {
53
138
  const checked = normalizeWorkspaceBaseDir(value)
54
139
  if (!checked.ok) issues.push({ path: key, message: checked.message })
55
- else patch[key] = checked.value
140
+ else setOverlayValue(patch, key, checked.value)
56
141
  continue
57
142
  }
58
- const parsed = shape[key]!.safeParse(value)
143
+ // Nested `feishuTools` values validate against the canonical sub-object
144
+ // schema; only the edited sub-field is copied out of the parsed result so
145
+ // schema defaults never leak into the overlay.
146
+ const parsed = isNestedEditableKey(key)
147
+ ? toolsShape.safeParse({ [nestedFieldOf(key)]: value })
148
+ : shape[key]!.safeParse(value)
59
149
  if (!parsed.success) {
60
150
  issues.push({ path: key, message: parsed.error.issues[0]?.message ?? '非法值' })
61
151
  continue
62
152
  }
63
- patch[key] = parsed.data
153
+ const normalizedValue = isNestedEditableKey(key)
154
+ ? (parsed.data as FeishuConfig['feishuTools'])[nestedFieldOf(key)]
155
+ : parsed.data
156
+ setOverlayValue(patch, key, normalizedValue)
64
157
  }
65
158
  if (issues.length > 0) return { ok: false, issues }
66
159
  return { ok: true, patch: normalizeOverlay(patch) }
@@ -86,26 +179,44 @@ export interface OverlayLogger {
86
179
  export function normalizeOverlay(patch: Record<string, unknown>): OverlayPatch {
87
180
  const normalized: Record<string, unknown> = {}
88
181
  for (const key of EDITABLE_KEYS) {
89
- const value = patch[key]
182
+ const value = overlayValueOf(patch, key)
90
183
  if (value === undefined) continue
91
184
  if (OPTIONAL_TEXT_KEYS.has(key) && value === '') continue
92
- normalized[key] = value
185
+ setOverlayValue(normalized, key, value)
93
186
  }
94
187
  return normalized as OverlayPatch
95
188
  }
96
189
 
97
190
  /** Merge the Web overlay over the profile-provided base config. */
98
191
  export function mergeOverlay(base: FeishuConfig, overlay: OverlayPatch): FeishuConfig {
99
- return { ...base, ...overlay }
192
+ const { feishuTools, ...topLevel } = overlay
193
+ const merged: FeishuConfig = { ...base, ...topLevel }
194
+ if (feishuTools !== undefined) merged.feishuTools = { ...base.feishuTools, ...feishuTools }
195
+ return merged
100
196
  }
101
197
 
102
- /** Project one config onto the editable-key subset (wire shape). */
198
+ /** Project one config onto the editable-key subset (wire shape, nested keys dotted). */
103
199
  export function pickEditable(config: FeishuConfig): Record<EditableKey, ConfigValue | undefined> {
104
200
  const picked: Record<string, ConfigValue | undefined> = {}
105
- for (const key of EDITABLE_KEYS) picked[key] = config[key] as ConfigValue | undefined
201
+ for (const key of EDITABLE_KEYS) picked[key] = configValueOf(config, key)
106
202
  return picked as Record<EditableKey, ConfigValue | undefined>
107
203
  }
108
204
 
205
+ /**
206
+ * Project an overlay patch onto flat editable keys (nested `feishuTools.*`
207
+ * values become dotted keys). The panel wire uses the same key space as
208
+ * `fields` / `overriddenKeys`, so the client never has to know the nested
209
+ * storage layout.
210
+ */
211
+ export function overlayValues(patch: OverlayPatch): EditableValues {
212
+ const values: Record<string, ConfigValue> = {}
213
+ for (const key of EDITABLE_KEYS) {
214
+ const value = overlayValueOf(patch as Record<string, unknown>, key)
215
+ if (value !== undefined) values[key] = value as ConfigValue
216
+ }
217
+ return values as EditableValues
218
+ }
219
+
109
220
  /**
110
221
  * One JSON overlay file. Reads degrade to `{}` with a warning; writes are
111
222
  * atomic and owner-readable only.