@roaming-ai/dsh-group-chat 0.2.2 → 0.3.1

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 (103) hide show
  1. package/README.md +91 -27
  2. package/lib/client.js +1254 -598
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +573 -109
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/Bubble.d.ts +6 -3
  7. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  8. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  9. package/lib/types/client/components/FailCard.d.ts +9 -0
  10. package/lib/types/client/components/Fold.d.ts +19 -0
  11. package/lib/types/client/components/HoverTip.d.ts +22 -0
  12. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  13. package/lib/types/client/components/MsgActions.d.ts +14 -0
  14. package/lib/types/client/components/NavPanel.d.ts +9 -11
  15. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  16. package/lib/types/client/components/ToolRow.d.ts +2 -2
  17. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  18. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  19. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  20. package/lib/types/client/lib/model.d.ts +1 -7
  21. package/lib/types/client/utils/utils.d.ts +4 -4
  22. package/lib/types/core/constraints.d.ts +61 -0
  23. package/lib/types/core/errors.d.ts +54 -0
  24. package/lib/types/core/json.d.ts +6 -0
  25. package/lib/types/core/types.d.ts +76 -60
  26. package/lib/types/host/api/actions.d.ts +1 -1
  27. package/lib/types/host/engine/conversation.d.ts +5 -3
  28. package/lib/types/host/engine/defaults.d.ts +13 -0
  29. package/lib/types/host/engine/fold.d.ts +17 -0
  30. package/lib/types/host/engine/index.d.ts +1 -0
  31. package/lib/types/host/engine/retitle.d.ts +5 -5
  32. package/lib/types/host/service.d.ts +1 -1
  33. package/lib/types/host/state.d.ts +2 -0
  34. package/lib/types/index.d.ts +2 -0
  35. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  36. package/package.json +1 -1
  37. package/src/client/GroupChatPanel.tsx +25 -59
  38. package/src/client/components/AsidePanel.tsx +50 -23
  39. package/src/client/components/Bubble.tsx +45 -18
  40. package/src/client/components/ChatPanel.tsx +17 -12
  41. package/src/client/components/Composer.tsx +26 -20
  42. package/src/client/components/ConstraintList.tsx +64 -0
  43. package/src/client/components/FailCard.tsx +49 -0
  44. package/src/client/components/Fold.tsx +72 -0
  45. package/src/client/components/HoverTip.tsx +134 -0
  46. package/src/client/components/MessageFlow.tsx +78 -54
  47. package/src/client/components/MsgActions.tsx +85 -0
  48. package/src/client/components/NavPanel.tsx +102 -79
  49. package/src/client/components/RoleDrawer.tsx +29 -7
  50. package/src/client/components/ThinkRow.tsx +7 -3
  51. package/src/client/components/ToolRow.tsx +9 -5
  52. package/src/client/hooks/useComposer.ts +54 -56
  53. package/src/client/hooks/useGroupChatState.ts +7 -17
  54. package/src/client/lib/composer-draft.ts +47 -0
  55. package/src/client/lib/model.ts +1 -14
  56. package/src/client/lib/styles.ts +73 -12
  57. package/src/client/react-dom-shim.d.ts +2 -0
  58. package/src/client/utils/utils.ts +6 -6
  59. package/src/core/constraints.ts +204 -0
  60. package/src/core/errors.ts +174 -0
  61. package/src/core/json.ts +18 -0
  62. package/src/core/types.ts +52 -19
  63. package/src/host/api/actions.ts +49 -14
  64. package/src/host/broadcast.ts +3 -3
  65. package/src/host/engine/conversation.ts +104 -40
  66. package/src/host/engine/defaults.ts +28 -0
  67. package/src/host/engine/fold.ts +99 -0
  68. package/src/host/engine/index.ts +1 -0
  69. package/src/host/engine/retitle.ts +24 -41
  70. package/src/host/materials/materials.ts +2 -0
  71. package/src/host/persistence/persistence.ts +18 -1
  72. package/src/host/service.ts +1 -1
  73. package/src/host/state.ts +5 -4
  74. package/src/host/tools/tools.ts +2 -2
  75. package/src/index.ts +2 -0
  76. package/src/shared/file-mention-grammar.test.ts +1 -38
  77. package/src/shared/file-mention-grammar.ts +7 -33
  78. package/lib/types/client/Bubble.d.ts +0 -10
  79. package/lib/types/client/Glyph.d.ts +0 -12
  80. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  81. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  82. package/lib/types/client/PermissionSelect.d.ts +0 -13
  83. package/lib/types/client/RoleDrawer.d.ts +0 -17
  84. package/lib/types/client/ThinkRow.d.ts +0 -9
  85. package/lib/types/client/ToolRow.d.ts +0 -9
  86. package/lib/types/client/api.d.ts +0 -8
  87. package/lib/types/client/components.d.ts +0 -22
  88. package/lib/types/client/drawer.d.ts +0 -17
  89. package/lib/types/client/glyph.d.ts +0 -12
  90. package/lib/types/client/model.d.ts +0 -78
  91. package/lib/types/client/panel.d.ts +0 -10
  92. package/lib/types/client/settings.d.ts +0 -15
  93. package/lib/types/client/styles.d.ts +0 -10
  94. package/lib/types/client/ui.d.ts +0 -17
  95. package/lib/types/client/utils.d.ts +0 -22
  96. package/lib/types/host/actions.d.ts +0 -34
  97. package/lib/types/host/conversation.d.ts +0 -25
  98. package/lib/types/host/http.d.ts +0 -16
  99. package/lib/types/host/materials.d.ts +0 -32
  100. package/lib/types/host/persistence.d.ts +0 -31
  101. package/lib/types/host/routes.d.ts +0 -11
  102. package/lib/types/host/store.d.ts +0 -65
  103. package/lib/types/host/tools.d.ts +0 -28
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
3
- * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
3
+ * 轮次 + 工具回注循环)、多轮 runLoop(结束时后台 retitle + 窗口外约束折叠)。
4
4
  * @module dsh-group-chat/host/engine/conversation
5
5
  */
6
6
 
7
7
  import { realpathSync } from 'node:fs'
8
8
  import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
+ import { constraintBlock, formatTranscriptLine, prefixIds, squeezedMessages, tempTranscript, WINDOW_SIZE } from '../../core/constraints.ts'
10
+ import { unwrapSpeakFailure } from '../../core/errors.ts'
9
11
  import { TOOL_FLOOR_COST_CHARS, TOOL_REPEAT_LIMIT, TOOL_RESULTS_TOTAL_MAX, TRANSCRIPT_TOOL_SUMMARY } from '../../core/tools.ts'
10
12
  import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord, SpeakResult, ToolCallRecord } from '../../core/types.ts'
11
13
  import { asEffort } from '../../core/types.ts'
12
14
  import type { HostState } from '../state.ts'
15
+ import { speakerNameOf } from './defaults.ts'
16
+ import { createFold } from './fold.ts'
13
17
  import { createRetitle } from './retitle.ts'
14
18
  import type { Materials } from '../materials/index.ts'
15
19
  import type { Tools } from '../tools/index.ts'
@@ -30,16 +34,17 @@ interface StreamRound {
30
34
  export interface Conversation {
31
35
  /** 追加一条消息到会话(touch + 落盘调度)。 */
32
36
  appendMessage: (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>) => MessageRecord
33
- /** 多轮 round-robin 主循环(send 触发;finally 复位 run)。 */
34
- runLoop: (sess: SessionRecord) => Promise<void>
37
+ /** 多轮 round-robin 主循环(send / retrySpeak 触发;finally 复位 run)。 */
38
+ runLoop: (sess: SessionRecord, opts?: { replaceMessageId?: string }) => Promise<void>
35
39
  }
36
40
 
37
41
  /** 创建对话引擎。 */
38
42
  export function createConversation(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void, materials: Materials, tools: Tools }): Conversation {
39
43
  const { llm, groups, roles, messages, run } = core
40
44
  const { touch, schedulePersist, materials, tools } = deps
41
- // 标题/主题自动整理(独立关注点,见 retitle.ts;runLoop 每轮结束触发)
45
+ // 标题/主题自动整理 + 窗口外约束折叠(finally 之后并列后台跑,不占用 run)
42
46
  const retitle = createRetitle(core, { touch, schedulePersist })
47
+ const fold = createFold(core, { touch, schedulePersist })
43
48
 
44
49
  const appendMessage = (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>): MessageRecord => {
45
50
  const msg: MessageRecord = { id: core.nid('msg'), sessionId: sess.id, seq: sess.messageIds.length + 1, speaker, text, reasoning: undefined, model: undefined, error: undefined, ts: Date.now() }
@@ -49,6 +54,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
49
54
  if (extra.thinkingSummary !== undefined) msg.thinkingSummary = extra.thinkingSummary
50
55
  if (extra.model !== undefined) msg.model = extra.model
51
56
  if (extra.error !== undefined) msg.error = extra.error
57
+ if (extra.failedRoleId !== undefined) msg.failedRoleId = extra.failedRoleId
52
58
  if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls
53
59
  }
54
60
  messages.set(msg.id, msg)
@@ -58,38 +64,68 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
58
64
  return msg
59
65
  }
60
66
 
61
- /** 群聊记录 角色上下文块(最近 40 条、逐条 8k 截断、工具行压缩)。 */
62
- const transcriptBlock = (sess: SessionRecord): string => {
67
+ /** 原地覆盖既有消息(重试槽位);目标不存在或不属于本会话时返回 false,由调用方走追加。 */
68
+ const overwriteMessage = (sess: SessionRecord, replaceId: string | undefined, patch: Partial<MessageRecord>): boolean => {
69
+ if (!replaceId) return false
70
+ const existing = messages.get(replaceId)
71
+ if (!existing || existing.sessionId !== sess.id) return false
72
+ Object.assign(existing, patch, { ts: Date.now() })
73
+ touch()
74
+ schedulePersist({ session: sess.id })
75
+ return true
76
+ }
77
+
78
+ /** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
79
+ const writeFailure = (sess: SessionRecord, role: RoleRecord, err: unknown, replaceId?: string): void => {
80
+ const raw = unwrapSpeakFailure(String((err && (err as Error).message) || err))
81
+ const model = role.provider + ' / ' + role.model
82
+ const extra: Partial<MessageRecord> = { error: true, failedRoleId: role.id, model }
83
+ if (overwriteMessage(sess, replaceId, {
84
+ speaker: role.id,
85
+ text: raw,
86
+ ...extra,
87
+ reasoning: undefined,
88
+ reasoningFull: undefined,
89
+ thinkingSummary: undefined,
90
+ toolCalls: undefined,
91
+ })) return
92
+ appendMessage(sess, role.id, raw, extra)
93
+ }
94
+
95
+ /** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
96
+ const writeSuccess = (sess: SessionRecord, role: RoleRecord, out: SpeakResult, replaceId?: string): void => {
97
+ const model = role.provider + ' / ' + role.model
98
+ const toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : undefined
99
+ if (overwriteMessage(sess, replaceId, {
100
+ speaker: role.id,
101
+ text: out.text,
102
+ error: undefined,
103
+ failedRoleId: undefined,
104
+ model,
105
+ reasoning: out.reasoning,
106
+ reasoningFull: undefined,
107
+ thinkingSummary: undefined,
108
+ toolCalls,
109
+ })) return
110
+ appendMessage(sess, role.id, out.text, { model, reasoning: out.reasoning, toolCalls: out.toolCalls })
111
+ }
112
+
113
+ /** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
114
+ const transcriptBlock = (sess: SessionRecord, skipId?: string): string => {
63
115
  const out: string[] = []
64
- for (const mid of sess.messageIds) {
116
+ for (const mid of prefixIds(sess.messageIds, skipId).slice(-WINDOW_SIZE)) {
65
117
  const m = messages.get(mid)
66
- if (!m) continue
67
- const name = m.speaker === 'user' ? '用户' : m.speaker === 'system' ? '系统' : (roles.get(m.speaker) || { name: undefined }).name || '成员'
68
- let text = m.text || ''
69
- if (text.length > 8000) text = text.slice(0, 8000) + '…(已截断)'
70
- let line = '【' + name + '】' + text
71
- if (Array.isArray(m.toolCalls)) {
72
- for (const c of m.toolCalls) {
73
- if (!c || typeof c.tool !== 'string') continue
74
- let brief = ''
75
- try {
76
- brief = JSON.stringify(c.args) || ''
77
- } catch {
78
- brief = ''
79
- }
80
- if (brief.length > 60) brief = brief.slice(0, 60) + '…'
81
- const st = c.status === 'ok' ? '成功' : c.status === 'denied' ? '用户拒绝' : '失败'
82
- let ob = String(c.output || '')
83
- if (ob.length > TRANSCRIPT_TOOL_SUMMARY) ob = ob.slice(0, TRANSCRIPT_TOOL_SUMMARY) + '…'
84
- line += '\n [工具] ' + c.tool + ' ' + brief + ' → ' + st + (ob ? '(' + ob.replace(/\s+/g, ' ') + ')' : '')
85
- }
86
- }
87
- out.push(line)
118
+ if (!m || m.id === skipId || m.error) continue
119
+ out.push(formatTranscriptLine(m, speakerNameOf(roles, m.speaker)))
88
120
  }
89
- return out.slice(-40).join('\n\n')
121
+ const live = out.join('\n\n')
122
+ const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => speakerNameOf(roles, m.speaker))
123
+ if (!temp) return live
124
+ if (!live) return temp
125
+ return temp + '\n\n' + live
90
126
  }
91
127
 
92
- const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord): Promise<SpeakResult> => {
128
+ const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord, skipId?: string): Promise<SpeakResult> => {
93
129
  const ws = await materials.loadWorkspaceFiles(g)
94
130
  const parts = ws.parts
95
131
  const sys = [
@@ -99,6 +135,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
99
135
  '- 名称:' + role.name,
100
136
  '- 人设:' + (role.persona ? role.persona : '(未填写,请以积极协作者的身份参与讨论)'),
101
137
  sess.topic ? '\n# 本会话主题\n' + sess.topic : '',
138
+ constraintBlock(sess.constraints),
102
139
  materials.materialBlock(parts, ws.dir),
103
140
  ws.dir
104
141
  ? '\n# 可用工具\n你可以调用工具在群组工作区目录(' + ws.dir + ')内查看文件与目录' +
@@ -115,7 +152,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
115
152
  '- 保持简洁,通常不超过 300 字',
116
153
  ].filter((s) => s !== '').join('\n')
117
154
 
118
- const history = transcriptBlock(sess)
155
+ const history = transcriptBlock(sess, skipId)
119
156
  const intro = history
120
157
  ? '以下是本会话的群聊记录(从旧到新):\n\n' + history
121
158
  : '本会话刚刚开始,请围绕主题做简短开场发言。'
@@ -253,7 +290,10 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
253
290
  msgs.push({ id: ('g' + core.revision + '-a' + msgs.length) as Message['id'], role: 'assistant', content: assistantContent, source: { kind: 'model', provider: role.provider, model: role.model } })
254
291
  for (const tc of round.tcs) {
255
292
  if (run.stopping) break
256
- const res = await tools.executeTool(g, wsRoot!, tc)
293
+ // wsRoot null toolSchemas 必为空、不会产生 tool-call;防御性跳出,
294
+ // 不把空根传进沙箱
295
+ if (wsRoot === null) break
296
+ const res = await tools.executeTool(g, wsRoot, tc)
257
297
  const output = String(res.output || '')
258
298
  toolCalls.push({ tool: tc.name, args: res.args, status: res.status, output, durationMs: res.durationMs })
259
299
  budgetUsed += Math.max(output.length, TOOL_FLOOR_COST_CHARS)
@@ -281,11 +321,16 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
281
321
  return { text: texts.join('\n\n').trim(), reasoning: reasonings.join('\n\n').trim() || undefined, toolCalls }
282
322
  }
283
323
 
284
- const runLoop = async (sess: SessionRecord): Promise<void> => {
285
- const g = groups.get(sess.groupId)
324
+ const runLoop = async (sess: SessionRecord, opts?: { replaceMessageId?: string }): Promise<void> => {
325
+ const g = groups.get(sess.groupId) ?? null
286
326
  const startCount = sess.messageIds.length
287
327
  let failed = false // 发言失败 → 会话列表「已出错」标记
328
+ let replaceId = opts && opts.replaceMessageId
329
+ run.replaceMessageId = replaceId || null
288
330
  try {
331
+ // 群组在 run 期间不可删(send 已校验 + deleteGroup 拦截运行中群组);
332
+ // 防御性早退——置空让 finally 走统一复位路径
333
+ if (!g) return
289
334
  while (run.queue.length > 0 && !run.stopping) {
290
335
  const roleId = run.queue.shift()!
291
336
  const role = roles.get(roleId)
@@ -294,15 +339,30 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
294
339
  run.partialReasoning = ''
295
340
  touch()
296
341
  if (!role) continue
342
+ const targetId = replaceId
343
+ replaceId = undefined
297
344
  try {
298
- const out = await speak(g!, sess, role)
345
+ const out = await speak(g, sess, role, targetId)
299
346
  // 停止后不落部分消息(已有「已停止本次对话」系统消息承接)
300
347
  if (!run.stopping && (out.text || (Array.isArray(out.toolCalls) && out.toolCalls.length > 0))) {
301
- appendMessage(sess, role.id, out.text, { model: role.provider + ' / ' + role.model, reasoning: out.reasoning, toolCalls: out.toolCalls })
348
+ writeSuccess(sess, role, out, targetId)
349
+ } else if (!run.stopping && targetId) {
350
+ // 重试得到空输出:保留失败卡,避免成功覆盖成空白气泡
351
+ failed = true
352
+ run.currentRoleId = null
353
+ run.partial = ''
354
+ run.partialReasoning = ''
355
+ run.replaceMessageId = null
356
+ writeFailure(sess, role, '模型没有返回内容', targetId)
357
+ break
302
358
  }
303
359
  } catch (e) {
304
360
  failed = true
305
- appendMessage(sess, 'system', '角色「' + role.name + '」发言失败:' + String((e && (e as Error).message) || e), { error: true })
361
+ run.currentRoleId = null
362
+ run.partial = ''
363
+ run.partialReasoning = ''
364
+ run.replaceMessageId = null
365
+ writeFailure(sess, role, e, targetId)
306
366
  break
307
367
  }
308
368
  }
@@ -321,10 +381,14 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
321
381
  run.confirmSignal = null
322
382
  run.childProc = null
323
383
  run.stopping = false
384
+ run.replaceMessageId = null
324
385
  touch()
325
386
  }
326
- // 本轮有新消息落盘后台整理标题/主题(fire-and-forget,不产生消息、静默失败)
327
- if (sess.messageIds.length > startCount) void retitle(sess)
387
+ // 本轮有新消息落盘,或原地重试覆盖了失败卡后台整理标题/主题 + 窗口外约束
388
+ if (sess.messageIds.length > startCount || (opts && opts.replaceMessageId)) {
389
+ void retitle(sess)
390
+ void fold(sess)
391
+ }
328
392
  }
329
393
 
330
394
  return { appendMessage, runLoop }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 引擎共享小件:DSH 默认模型读取 + 发言人展示名(retitle / fold / conversation 共用)。
3
+ * @module dsh-group-chat/host/engine/defaults
4
+ */
5
+
6
+ import { speakerLabel } from '../../core/constraints.ts'
7
+ import type { RoleRecord } from '../../core/types.ts'
8
+ import type { HostState } from '../state.ts'
9
+
10
+ /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
11
+ export const defaultModel = (core: HostState): { provider: string, model: string } | null => {
12
+ try {
13
+ // cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
14
+ // inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
15
+ // undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
16
+ const svc = core.ctx.reflect.get('agentDefaultModel')
17
+ const sel = svc ? svc.currentSelection() : null
18
+ return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
19
+ ? { provider: sel.provider, model: sel.model }
20
+ : null
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ /** 发言人展示名(roles 表内查角色名;user/system 固定文案,见 speakerLabel)。 */
27
+ export const speakerNameOf = (roles: Map<string, RoleRecord>, speaker: string): string =>
28
+ speakerLabel(speaker, roles.get(speaker)?.name)
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 窗口外结论/约束备忘:整次 runLoop 结束后后台折叠(仿 retitle)。
3
+ * 立刻 idle;未折完用水位 + 临时原文表达。v1 复用 purpose session-title 关思考。
4
+ * @module dsh-group-chat/host/engine/fold
5
+ */
6
+
7
+ import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
8
+ import {
9
+ CONSTRAINT_MAX_CHARS,
10
+ CONSTRAINT_MAX_ITEMS,
11
+ KIND_LABEL,
12
+ downgradeWithoutUser,
13
+ parseConstraints,
14
+ squeezedMaxSeq,
15
+ squeezedMessages,
16
+ takeFoldBatch,
17
+ } from '../../core/constraints.ts'
18
+ import type { SessionRecord } from '../../core/types.ts'
19
+ import type { HostState } from '../state.ts'
20
+ import { defaultModel, speakerNameOf } from './defaults.ts'
21
+
22
+ /**
23
+ * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
24
+ * 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
25
+ */
26
+ export function createFold(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
27
+ const { llm, messages, roles } = core
28
+ const { touch, schedulePersist } = deps
29
+ const folding = new Set<string>()
30
+
31
+ return async (sess: SessionRecord): Promise<void> => {
32
+ if (folding.has(sess.id)) return
33
+ const squeezed = squeezedMessages(messages, sess)
34
+ if (!squeezed.length) return
35
+ const input = takeFoldBatch(squeezed, (m) => speakerNameOf(roles, m.speaker))
36
+ const watermark = squeezedMaxSeq(input.consumed)
37
+ if (watermark <= 0) return
38
+
39
+ const commit = (next: SessionRecord['constraints'] | undefined): void => {
40
+ const live = core.sessions.get(sess.id)
41
+ if (!live || live.messageIds.length === 0) return
42
+ if (next && next.length) live.constraints = next
43
+ live.constraintsUpToSeq = watermark
44
+ schedulePersist({ session: live.id })
45
+ touch()
46
+ }
47
+
48
+ if (input.allSystem) {
49
+ commit(undefined)
50
+ return
51
+ }
52
+
53
+ const dm = defaultModel(core)
54
+ if (!dm) return
55
+
56
+ folding.add(sess.id)
57
+ try {
58
+ const existing = (sess.constraints || []).map((c) => '- ' + KIND_LABEL[c.kind] + ':' + c.text)
59
+ const sys = [
60
+ '你是群聊会话的约束整理助手。把已经离开最近对话窗口的旧消息压成无主结论/约束备忘。',
61
+ '',
62
+ '# 规则',
63
+ '- 只输出一行 JSON:{"constraints":[{"kind":"decided|rejected|open","text":"…"}, ...]}',
64
+ '- kind 只能是 decided(已定)/ rejected(否决)/ open(未决)',
65
+ '- 无主:条目不写说话人。8–12 条、合计不超过 ' + CONSTRAINT_MAX_CHARS + ' 字,最多 ' + CONSTRAINT_MAX_ITEMS + ' 条',
66
+ '- 已定/否决只能依据【用户】原文;角色对打一律标 open(未决)',
67
+ '- 同主题:新已定覆盖旧未决;旧已定不能因角色反对改写,除非【用户】改口',
68
+ '- 没有新结论时输出 {"constraints":[]}(保留旧备忘)',
69
+ '- 超预算时按 已定 > 未决 > 过程叙述 取舍;语言跟随记录',
70
+ '',
71
+ '当前备忘:',
72
+ existing.length ? existing.join('\n') : '(空)',
73
+ ].join('\n')
74
+ const user = '旧消息(从旧到新,含工具一行摘要):\n\n' + input.lines.join('\n\n')
75
+ let acc = ''
76
+ for await (const chunk of llm.stream({
77
+ provider: dm.provider,
78
+ model: dm.model,
79
+ system: sys,
80
+ purpose: 'session-title',
81
+ messages: [{ id: ('g' + core.revision + '-c0') as Message['id'], role: 'user', content: [{ type: 'text', text: user }], source: { kind: 'user' } }],
82
+ } as GenerateOptions)) {
83
+ if (chunk.type === 'text-delta') {
84
+ acc += chunk.text
85
+ if (acc.length > 4000) break
86
+ } else if (chunk.type === 'finish') {
87
+ break
88
+ }
89
+ }
90
+ const parsed = parseConstraints(acc)
91
+ if (parsed === null) return
92
+ commit(parsed.length ? downgradeWithoutUser(parsed, input.hasUser) : undefined)
93
+ } catch (e) {
94
+ console.error('[dsh-group-chat] 会话约束折叠失败(跳过,不影响对话):', e)
95
+ } finally {
96
+ folding.delete(sess.id)
97
+ }
98
+ }
99
+ }
@@ -5,4 +5,5 @@
5
5
 
6
6
  export { createConversation } from './conversation.ts'
7
7
  export type { Conversation } from './conversation.ts'
8
+ export { createFold } from './fold.ts'
8
9
  export { createRetitle } from './retitle.ts'
@@ -1,63 +1,45 @@
1
1
  /**
2
2
  * 会话标题/主题自动整理(参照 oil-codex-title):每轮结束后用 DSH 默认模型
3
- * 后台生成「类别 emoji + 对象|目标」名称与演进式主题;手动编辑过的字段
3
+ * 后台生成。名称只在仍为默认占位时写一次;主题每轮演进。手动编辑过的字段
4
4
  * 永久跳过(隐式固定)。
5
5
  * @module dsh-group-chat/host/engine/retitle
6
6
  */
7
7
 
8
8
  import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
+ import { looseJson } from '../../core/json.ts'
9
10
  import type { SessionRecord } from '../../core/types.ts'
10
- import type { HostState } from '../state.ts'
11
+ import { DEFAULT_SESSION_NAME, type HostState } from '../state.ts'
12
+ import { defaultModel, speakerNameOf } from './defaults.ts'
11
13
 
12
- /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
13
- const defaultModel = (core: HostState): { provider: string, model: string } | null => {
14
- try {
15
- // cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
16
- // inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
17
- // undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
18
- const svc = core.ctx.reflect.get('agentDefaultModel')
19
- const sel = svc ? svc.currentSelection() : null
20
- return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
21
- ? { provider: sel.provider, model: sel.model }
22
- : null
23
- } catch {
24
- return null
25
- }
26
- }
14
+ /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
15
+ const isPlaceholderName = (name: string): boolean =>
16
+ name === DEFAULT_SESSION_NAME || /^会话 \d+$/.test(name)
27
17
 
28
18
  /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
29
19
  const titleTranscript = (core: HostState, sess: SessionRecord): string => {
30
20
  const out: string[] = []
31
21
  for (const mid of sess.messageIds.slice(-40)) {
32
22
  const m = core.messages.get(mid)
33
- if (!m || m.speaker === 'system' || !m.text) continue
34
- const name = m.speaker === 'user' ? '用户' : (core.roles.get(m.speaker) || { name: undefined }).name || '成员'
35
- out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
23
+ if (!m || m.speaker === 'system' || m.error || !m.text) continue
24
+ out.push('' + speakerNameOf(core.roles, m.speaker) + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
36
25
  }
37
26
  return out.join('\n')
38
27
  }
39
28
 
40
- /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
29
+ /** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
41
30
  const parseRetitle = (raw: string): { name?: string, topic?: string } => {
42
- const body = raw.replace(/```(?:json)?/g, '')
43
- const l = body.indexOf('{')
44
- const r = body.lastIndexOf('}')
45
- if (l < 0 || r <= l) return {}
46
- try {
47
- const o = JSON.parse(body.slice(l, r + 1)) as { name?: unknown, topic?: unknown }
48
- const name = typeof o.name === 'string' ? o.name.trim() : ''
49
- const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
50
- return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
51
- } catch {
52
- return {}
53
- }
31
+ const o = looseJson(raw.replace(/```(?:json)?/g, ''))
32
+ if (!o) return {}
33
+ const name = typeof o.name === 'string' ? o.name.trim() : ''
34
+ const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
35
+ return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
54
36
  }
55
37
 
56
38
  /**
57
39
  * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
58
- * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
59
- * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
60
- * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
40
+ * 消息、静默失败。名称只在仍为默认占位时生成一次(「类别 emoji + 对象|目标」);
41
+ * 主题 = 演进式一句话摘要(对象+目标+当前焦点),每轮更新,注入后续角色上下文。
42
+ * 手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
61
43
  */
62
44
  export function createRetitle(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
63
45
  const { llm } = core
@@ -69,7 +51,8 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
69
51
  return async (sess: SessionRecord): Promise<void> => {
70
52
  if (retitling.has(sess.id)) return
71
53
  const dm = defaultModel(core)
72
- if (!dm || (sess.namePinned && sess.topicPinned)) return
54
+ const nameFrozen = !!sess.namePinned || !isPlaceholderName(sess.name)
55
+ if (!dm || (nameFrozen && sess.topicPinned)) return
73
56
  const transcript = titleTranscript(core, sess)
74
57
  if (!transcript) return
75
58
  retitling.add(sess.id)
@@ -80,9 +63,9 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
80
63
  '# 名称规则',
81
64
  '- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」',
82
65
  '- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)',
83
- '- 对象在前且稳定:把辨识度最高的讨论对象放最前;省略群组名(外层已展示);除非讨论对象实质变化,沿用当前名称里的对象名;「继续」「追问」等不改变主线',
66
+ '- 对象在前:把辨识度最高的讨论对象放最前;省略群组名(外层已展示)',
84
67
  '- 目标 = 当前正在做的事,动宾短语,保持简洁',
85
- '- 名称总长不超过 16 个字',
68
+ '- 名称总长不超过 16 个字;名称只生成一次,不要为了追问/继续而改名',
86
69
  '',
87
70
  '# 主题规则',
88
71
  '- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点',
@@ -92,7 +75,7 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
92
75
  '- 语言跟随用户消息的主要语言;保留产品名与技术名词',
93
76
  '- 只输出一行 JSON:{"name": "…", "topic": "…"},不要输出其他内容',
94
77
  '',
95
- '当前名称:' + (sess.namePinned ? '(已手动固定,本次不要输出 name 字段)' : sess.name),
78
+ '当前名称:' + (nameFrozen ? '(已固定,本次不要输出 name 字段)' : sess.name),
96
79
  '当前主题:' + (sess.topicPinned ? '(已手动固定,本次不要输出 topic 字段)' : (sess.topic || '(空)')),
97
80
  ].join('\n')
98
81
  let acc = ''
@@ -120,7 +103,7 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
120
103
  }
121
104
  const parsed = parseRetitle(acc)
122
105
  let changed = false
123
- if (parsed.name && !sess.namePinned) { sess.name = parsed.name; changed = true }
106
+ if (parsed.name && !sess.namePinned && isPlaceholderName(sess.name)) { sess.name = parsed.name; changed = true }
124
107
  if (parsed.topic && !sess.topicPinned) { sess.topic = parsed.topic; changed = true }
125
108
  if (changed) {
126
109
  schedulePersist({ session: sess.id })
@@ -64,6 +64,8 @@ export function createMaterials(core: HostState): Materials {
64
64
 
65
65
  /** 逐候选 stat,返回第一个存在的目标;都不存在时返回首候选与全部尝试。 */
66
66
  const resolveMaterialTarget = async (raw: unknown) => {
67
+ // candidatePaths 构造上恒返回非空候选数组('' → [HOME],其余分支均单值或
68
+ // 非空 map),first 必在;! 仅为取首候选,无空指针风险
67
69
  const candidates = await candidatePaths(raw)
68
70
  let first: { target: import('@deepseek-ai/dsh-fs').FsTarget, path: string } | undefined
69
71
  for (const c of candidates) {
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from 'node:fs'
8
+ import { repairFailedMessage } from '../../core/errors.ts'
8
9
  import { messageJson, roleJson } from '../../core/json.ts'
10
+ import { sanitizeConstraints } from '../../core/constraints.ts'
9
11
  import { asNumber, migrateTier } from '../../core/types.ts'
10
12
  import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord } from '../../core/types.ts'
11
13
  import { emptyGroup, LedgerDocument, scanGroupIds, scanSessionIds, STORE_DIR, Store } from './store.ts'
@@ -17,6 +19,8 @@ interface SessionDocument {
17
19
  topic?: string
18
20
  namePinned?: boolean
19
21
  topicPinned?: boolean
22
+ constraints?: unknown
23
+ constraintsUpToSeq?: number
20
24
  createdAt?: number
21
25
  messages?: Partial<MessageRecord>[]
22
26
  }
@@ -74,6 +78,8 @@ export function createPersistence(core: HostState): Persistence {
74
78
  topic: s.topic,
75
79
  ...(s.namePinned ? { namePinned: true } : {}),
76
80
  ...(s.topicPinned ? { topicPinned: true } : {}),
81
+ ...(s.constraints && s.constraints.length ? { constraints: s.constraints } : {}),
82
+ ...(typeof s.constraintsUpToSeq === 'number' && s.constraintsUpToSeq > 0 ? { constraintsUpToSeq: s.constraintsUpToSeq } : {}),
77
83
  createdAt: s.createdAt,
78
84
  messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean),
79
85
  })
@@ -178,6 +184,9 @@ export function createPersistence(core: HostState): Persistence {
178
184
  if (typeof doc.topic === 'string') sess.topic = doc.topic
179
185
  if (doc.namePinned === true) sess.namePinned = true
180
186
  if (doc.topicPinned === true) sess.topicPinned = true
187
+ const constraints = sanitizeConstraints(doc.constraints)
188
+ if (constraints.length) sess.constraints = constraints
189
+ if (typeof doc.constraintsUpToSeq === 'number' && doc.constraintsUpToSeq > 0) sess.constraintsUpToSeq = doc.constraintsUpToSeq
181
190
  if (typeof doc.createdAt === 'number') sess.createdAt = doc.createdAt
182
191
  let fallbackSeq = 0
183
192
  for (const m of (Array.isArray(doc.messages) ? doc.messages : [])) {
@@ -194,11 +203,19 @@ export function createPersistence(core: HostState): Persistence {
194
203
  thinkingSummary: m.thinkingSummary !== undefined ? String(m.thinkingSummary) : undefined,
195
204
  model: m.model,
196
205
  error: m.error,
206
+ failedRoleId: typeof m.failedRoleId === 'string' && m.failedRoleId ? m.failedRoleId : undefined,
197
207
  toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : undefined,
198
208
  ts: typeof m.ts === 'number' ? m.ts : Date.now(),
199
209
  })
200
210
  sess.messageIds.push(m.id)
201
211
  }
212
+ const groupRoles = g.roleIds.map((rid) => core.roles.get(rid)).filter((r): r is RoleRecord => Boolean(r))
213
+ let repaired = false
214
+ for (const mid of sess.messageIds) {
215
+ const rec = core.messages.get(mid)
216
+ if (rec && repairFailedMessage(rec, groupRoles)) repaired = true
217
+ }
218
+ if (repaired) schedulePersist({ session: sid })
202
219
  }
203
220
  core.sessions.set(sid, sess)
204
221
  g.sessionIds.push(sid)
@@ -274,7 +291,7 @@ export function createPersistence(core: HostState): Persistence {
274
291
  if (core.groups.size === 0) {
275
292
  const g: GroupRecord = { id: core.nid('grp'), name: '默认群组', workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
276
293
  core.groups.set(g.id, g)
277
- const sess = core.newSession(g.id, '会话 1')
294
+ const sess = core.newSession(g.id)
278
295
  g.sessionIds.push(sess.id)
279
296
  autoSessions.push(sess.id)
280
297
  }
@@ -6,7 +6,7 @@
6
6
  * persistence/ 持久化(store 文件原语 + 脏标记合并落盘 + 启动恢复)
7
7
  * materials/ 资料读取与路径解析 + 目录浏览器
8
8
  * tools/ 工具执行(沙箱 + 确认闸门)
9
- * engine/ 对话引擎(conversation:speak/runLoop;retitle:标题整理)
9
+ * engine/ 对话引擎(conversation:speak/runLoop;retitle:标题整理;fold:窗口外约束)
10
10
  * api/ HTTP 传输(http 护栏 + routes 路由)与动作分发(actions)
11
11
  * @module dsh-group-chat/host/service
12
12
  */
package/src/host/state.ts CHANGED
@@ -28,6 +28,9 @@ declare module '@deepseek-ai/cordis' {
28
28
  /** 角色标识色板(新增角色依序取色)。 */
29
29
  export const PALETTE = ['#5b8def', '#22a06b', '#e8912d', '#c678dd', '#e05661', '#56b6c2', '#98c379', '#d19a66']
30
30
 
31
+ /** 新建会话默认名称(自动标题只在仍为此占位名时生成一次)。 */
32
+ export const DEFAULT_SESSION_NAME = '新会话'
33
+
31
34
  /** 宿主服务共享状态容器(见模块注释;可变原始值一律经 core.* 访问)。 */
32
35
  export interface HostState {
33
36
  ctx: Context
@@ -60,19 +63,17 @@ export function createHostState(ctx: Context): HostState {
60
63
  sessions: new Map(),
61
64
  roles: new Map(),
62
65
  messages: new Map(),
63
- run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null },
66
+ run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null, replaceMessageId: null },
64
67
  store: null,
65
68
  revision: 1,
66
69
  idSeq: 1,
67
70
  nid: (p: string): string => p + '-' + (core.idSeq++),
68
71
  lastCreated: null,
69
72
  newSession: (groupId: string, name?: string): SessionRecord => {
70
- let n = 0
71
- for (const s of core.sessions.values()) if (s.groupId === groupId) n++
72
73
  const s: SessionRecord = {
73
74
  id: randomUUID(),
74
75
  groupId,
75
- name: name || '会话 ' + (n + 1),
76
+ name: name || DEFAULT_SESSION_NAME,
76
77
  topic: '',
77
78
  messageIds: [],
78
79
  createdAt: Date.now(),
@@ -9,7 +9,7 @@ import { spawn, type ChildProcess } from 'node:child_process'
9
9
  import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'
10
10
  import { isAbsolute, join, sep } from 'node:path'
11
11
  import { CMD_CAPTURE_MAX_BYTES, CMD_OUTPUT_MAX_CHARS, READ_FILE_MAX_BYTES, RUN_CMD_TIMEOUT_MS, TOOL_SCHEMAS } from '../../core/tools.ts'
12
- import type { GroupRecord, RunState, ToolExecution } from '../../core/types.ts'
12
+ import type { GroupRecord, PendingConfirm, ToolExecution } from '../../core/types.ts'
13
13
  import type { HostState } from '../state.ts'
14
14
 
15
15
  /** 工具面。 */
@@ -134,7 +134,7 @@ export function createTools(core: HostState, touch: () => void): Tools {
134
134
 
135
135
  /** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
136
136
  const requestConfirmation = (toolCallId: string, args: Record<string, unknown>): Promise<boolean> => new Promise((resolve) => {
137
- run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as RunState['pendingConfirm'] extends null ? never : NonNullable<RunState['pendingConfirm']>['args'] }
137
+ run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as PendingConfirm['args'] }
138
138
  run.confirmSignal = { resolve }
139
139
  touch()
140
140
  })
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * 数据模型:Group 1..N Session,消息挂在会话上;角色与工作区目录挂在群组上
11
11
  * - 群组与会话的增删改;群组/会话检索由客户端在快照上过滤
12
12
  * - 角色发言经 `llm` 服务流式生成,按角色绑定的 provider/model 路由
13
+ * - 整次 runLoop 结束后后台折叠被 40 条窗口挤出的消息为会话级约束备忘
14
+ * (engine/fold.ts;立刻 idle、fire-and-forget,不产生消息)
13
15
  * - 群组工作区目录经 `fs` 服务读取(根下一层文本文件,最多 20 个),
14
16
  * 以「共享资料」块注入每个角色的 system 提示词;无独立笔记/文件清单
15
17
  * - 经 `webServer` 暴露 HTTP API: