@twinsearth/dsh-master 3.3.4 → 3.3.5
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 +1 -0
- package/client.js +83 -4
- package/cordis.patch.yml +2 -0
- package/dist-host/agents/pjm-agent.js +18 -2
- package/dist-host/agents/pm-agent.js +23 -1
- package/dist-host/agents/prompts.js +16 -6
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +36 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +153 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +86 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +114 -2
- package/dist-host/kernel.js +7 -7
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +230 -6
- package/docs/AGENTS-PM-PJM.md +90 -6
- package/docs/CHANGELOG.md +58 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +3 -1
- package/src/agents/pjm-agent.ts +18 -2
- package/src/agents/pm-agent.ts +21 -1
- package/src/agents/prompts.ts +16 -6
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +35 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +148 -1
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +84 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +105 -2
- package/src/kernel.ts +7 -7
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +211 -6
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 流式消息状态机(3.3.5)。
|
|
7
|
+
*
|
|
8
|
+
* 结构(3.3.5 性能版):**历史与当前流式分开**
|
|
9
|
+
* state.history —— 已定稿的消息数组,**引用稳定**:只有消息被定稿时才换新数组;
|
|
10
|
+
* state.active —— 当前正在流式的那条(或 null),每 chunk 只换这一个对象;
|
|
11
|
+
* state.messages —— 兼容视图(history + active),供旧代码/简单渲染使用。
|
|
12
|
+
*
|
|
13
|
+
* 为什么要拆:每 chunk 都让 UI `map` 一遍上百条历史是 O(N) 的 reconciliation 开销,
|
|
14
|
+
* `React.memo` 只能省 DOM,省不了上层遍历。消费方应当:
|
|
15
|
+
* * 历史列表订阅 `history`(引用没变就不重渲染);
|
|
16
|
+
* * 当前气泡只订阅 `active`(每次 chunk 只重渲染它)。
|
|
17
|
+
*
|
|
18
|
+
* 语义:
|
|
19
|
+
* * 幂等:重复 `stream_start` 不新建第二条;重复 `card_choice` 原地更新;
|
|
20
|
+
* * 容忍乱序:`stream_chunk` 早于 `stream_start` 只计数(orphan),不造数据;
|
|
21
|
+
* * 块级锁:`card_*` 到达时先定稿当前气泡,再插入卡片,并释放流式槽位;
|
|
22
|
+
* * 有界:历史条数、单条内容、选项数、快照大小都有上限。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const STREAM_LIMITS = Object.freeze({
|
|
26
|
+
maxMessages: 200,
|
|
27
|
+
maxContentChars: 200_000,
|
|
28
|
+
maxOptions: 20,
|
|
29
|
+
maxSnapshotBytes: 256 * 1024,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const MESSAGE_KINDS = Object.freeze(['text', 'choice', 'doc', 'status', 'error'])
|
|
33
|
+
|
|
34
|
+
function text(value) { return String(value ?? '') }
|
|
35
|
+
|
|
36
|
+
function emptyState() {
|
|
37
|
+
return {
|
|
38
|
+
messages: [],
|
|
39
|
+
history: [],
|
|
40
|
+
active: null,
|
|
41
|
+
activeStreamId: null,
|
|
42
|
+
capabilities: null,
|
|
43
|
+
snapshot: null,
|
|
44
|
+
orphanChunks: 0,
|
|
45
|
+
ignored: 0,
|
|
46
|
+
truncated: 0,
|
|
47
|
+
lastError: null,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function capHistory(history) {
|
|
52
|
+
return history.length > STREAM_LIMITS.maxMessages
|
|
53
|
+
? history.slice(history.length - STREAM_LIMITS.maxMessages)
|
|
54
|
+
: history
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function capContent(content, truncatedBefore) {
|
|
58
|
+
const value = text(content)
|
|
59
|
+
if (value.length <= STREAM_LIMITS.maxContentChars) return { content: value, truncated: truncatedBefore }
|
|
60
|
+
return { content: value.slice(0, STREAM_LIMITS.maxContentChars), truncated: truncatedBefore + 1 }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 当前正在流式的那条消息;没有则 null。 */
|
|
64
|
+
export function activeMessage(state) {
|
|
65
|
+
return state?.active ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 是否有消息仍在流式输出(UI 据此切换“发送/停止”按钮)。 */
|
|
69
|
+
export function isGenerating(state) {
|
|
70
|
+
return Boolean(state?.active && state.active.streaming === true)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 光标微状态(UI 直接消费,不要在组件里重复推导):
|
|
75
|
+
* * `thinking` —— 收到 stream_start 但还没有第一个字:显示跳动点,不显示竖线;
|
|
76
|
+
* * `streaming` —— 正在吐字:竖线紧贴最后一个字符;
|
|
77
|
+
* * `interrupted` —— 最近一条消息是被中止的:红色闪烁两次后收起;
|
|
78
|
+
* * `idle` —— 没有正在生成的内容。
|
|
79
|
+
*/
|
|
80
|
+
export function cursorState(state) {
|
|
81
|
+
if (state?.active && state.active.streaming === true) {
|
|
82
|
+
return text(state.active.content).length === 0 ? 'thinking' : 'streaming'
|
|
83
|
+
}
|
|
84
|
+
const history = Array.isArray(state?.history) ? state.history : []
|
|
85
|
+
const last = history.length > 0 ? history[history.length - 1] : null
|
|
86
|
+
if (last && last.interrupted === true) return 'interrupted'
|
|
87
|
+
return 'idle'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {{now?: () => number}} [options]
|
|
92
|
+
*/
|
|
93
|
+
export function createChatStream(options = {}) {
|
|
94
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
95
|
+
let state = emptyState()
|
|
96
|
+
const listeners = new Set()
|
|
97
|
+
|
|
98
|
+
const notify = () => { for (const listener of [...listeners]) { try { listener(state) } catch { /* 订阅者异常不影响状态机 */ } } }
|
|
99
|
+
|
|
100
|
+
/** 由 history + active 构造完整 state(messages 为兼容视图)。 */
|
|
101
|
+
function bundle(next, history, active) {
|
|
102
|
+
const trimmed = capHistory(history)
|
|
103
|
+
const activeValue = active ?? null
|
|
104
|
+
const messages = activeValue ? [...trimmed, activeValue] : trimmed
|
|
105
|
+
return {
|
|
106
|
+
...state,
|
|
107
|
+
...next,
|
|
108
|
+
history: trimmed,
|
|
109
|
+
active: activeValue,
|
|
110
|
+
activeStreamId: activeValue ? activeValue.id : null,
|
|
111
|
+
messages,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 把当前 active 定稿进 history(不改变 active 对象的 streaming 标记,调用方负责)。 */
|
|
116
|
+
function finalized(next, item) {
|
|
117
|
+
const active = state.active
|
|
118
|
+
const history = active ? [...state.history, { ...active, ...(item ?? {}) }] : state.history
|
|
119
|
+
return bundle(next, history, null)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function applyOne(message) {
|
|
123
|
+
const type = text(message?.type)
|
|
124
|
+
const id = text(message?.messageId ?? message?.id)
|
|
125
|
+
|
|
126
|
+
switch (type) {
|
|
127
|
+
case 'stream_start': {
|
|
128
|
+
if (!id) return state
|
|
129
|
+
const existingHistory = state.history.find((entry) => entry.id === id)
|
|
130
|
+
if (existingHistory) return state // 已定稿的同 id 消息不再复活
|
|
131
|
+
if (state.active && state.active.id === id) {
|
|
132
|
+
// 幂等:重复 start 保持同一条
|
|
133
|
+
return bundle({}, state.history, { ...state.active, streaming: true })
|
|
134
|
+
}
|
|
135
|
+
const role = text(message.role) === 'pjm' ? 'pjm' : 'pm'
|
|
136
|
+
const entry = { id, role, kind: 'text', content: '', streaming: true, at: now() }
|
|
137
|
+
const history = state.active ? [...state.history, { ...state.active, streaming: false }] : state.history
|
|
138
|
+
return bundle({}, history, entry)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case 'stream_chunk': {
|
|
142
|
+
if (!id) return state
|
|
143
|
+
if (!state.active || state.active.id !== id) {
|
|
144
|
+
return { ...state, orphanChunks: state.orphanChunks + 1 }
|
|
145
|
+
}
|
|
146
|
+
const content = `${text(state.active.content)}${text(message.delta)}`
|
|
147
|
+
const capped = capContent(content, state.truncated)
|
|
148
|
+
return bundle(
|
|
149
|
+
{ truncated: capped.truncated },
|
|
150
|
+
state.history,
|
|
151
|
+
{ ...state.active, content: capped.content, truncated: capped.content.length >= STREAM_LIMITS.maxContentChars },
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'stream_end': {
|
|
156
|
+
if (!id) return state
|
|
157
|
+
if (!state.active || state.active.id !== id) return { ...state, orphanChunks: state.orphanChunks + 1 }
|
|
158
|
+
const full = text(message.fullContent)
|
|
159
|
+
const current = state.active
|
|
160
|
+
const finalContent = full.length > text(current.content).length
|
|
161
|
+
? full.slice(0, STREAM_LIMITS.maxContentChars)
|
|
162
|
+
: current.content
|
|
163
|
+
const entry = {
|
|
164
|
+
...current,
|
|
165
|
+
streaming: false,
|
|
166
|
+
interrupted: message.interrupted === true ? true : current.interrupted === true,
|
|
167
|
+
content: finalContent,
|
|
168
|
+
}
|
|
169
|
+
return bundle({}, [...state.history, entry], null)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'interrupted': {
|
|
173
|
+
if (!id) return state
|
|
174
|
+
if (!state.active || state.active.id !== id) return { ...state, orphanChunks: state.orphanChunks + 1 }
|
|
175
|
+
const saved = text(message.savedContent)
|
|
176
|
+
const current = state.active
|
|
177
|
+
const entry = {
|
|
178
|
+
...current,
|
|
179
|
+
streaming: false,
|
|
180
|
+
interrupted: message.aborted !== false,
|
|
181
|
+
content: saved.length > text(current.content).length ? saved : current.content,
|
|
182
|
+
}
|
|
183
|
+
return bundle({}, [...state.history, entry], null)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'card_choice': {
|
|
187
|
+
if (!id) return state
|
|
188
|
+
const existing = state.history.findIndex((entry) => entry.id === id && entry.kind === 'choice')
|
|
189
|
+
const options = Array.isArray(message.options)
|
|
190
|
+
? message.options.slice(0, STREAM_LIMITS.maxOptions).map((option) => (
|
|
191
|
+
typeof option === 'string' ? { label: option, value: option } : { label: text(option?.label), value: text(option?.value ?? option?.label) }
|
|
192
|
+
))
|
|
193
|
+
: []
|
|
194
|
+
const card = {
|
|
195
|
+
id,
|
|
196
|
+
role: 'pm',
|
|
197
|
+
kind: 'choice',
|
|
198
|
+
content: text(message.question),
|
|
199
|
+
options,
|
|
200
|
+
answered: false,
|
|
201
|
+
answer: null,
|
|
202
|
+
streaming: false,
|
|
203
|
+
at: now(),
|
|
204
|
+
}
|
|
205
|
+
if (existing >= 0) {
|
|
206
|
+
const history = state.history.slice()
|
|
207
|
+
history[existing] = { ...history[existing], ...card }
|
|
208
|
+
return bundle({}, history, state.active)
|
|
209
|
+
}
|
|
210
|
+
const history = state.active
|
|
211
|
+
? [...state.history, { ...state.active, streaming: false }, card]
|
|
212
|
+
: [...state.history, card]
|
|
213
|
+
return bundle({}, history, null)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'card_prd': {
|
|
217
|
+
if (!id) return state
|
|
218
|
+
const doc = {
|
|
219
|
+
id,
|
|
220
|
+
role: 'pm',
|
|
221
|
+
kind: 'doc',
|
|
222
|
+
content: text(message.title),
|
|
223
|
+
summary: text(message.summary ?? message.content ?? ''),
|
|
224
|
+
status: ['draft', 'approved', 'shipped'].includes(text(message.status)) ? text(message.status) : 'draft',
|
|
225
|
+
streaming: false,
|
|
226
|
+
at: now(),
|
|
227
|
+
}
|
|
228
|
+
const history = state.active
|
|
229
|
+
? [...state.history, { ...state.active, streaming: false }, doc]
|
|
230
|
+
: [...state.history, doc]
|
|
231
|
+
return bundle({}, history, null)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case 'card_status': {
|
|
235
|
+
if (!id) return state
|
|
236
|
+
const progress = Math.max(0, Math.min(100, Number(message.progress) || 0))
|
|
237
|
+
const entry = {
|
|
238
|
+
id,
|
|
239
|
+
role: 'pjm',
|
|
240
|
+
kind: 'status',
|
|
241
|
+
content: text(message.content),
|
|
242
|
+
progress,
|
|
243
|
+
statusText: text(message.statusText),
|
|
244
|
+
streaming: false,
|
|
245
|
+
at: now(),
|
|
246
|
+
}
|
|
247
|
+
const existing = state.history.findIndex((item) => item.id === id && item.kind === 'status')
|
|
248
|
+
if (existing >= 0) {
|
|
249
|
+
const history = state.history.slice()
|
|
250
|
+
history[existing] = { ...history[existing], ...entry }
|
|
251
|
+
return bundle({}, history, state.active)
|
|
252
|
+
}
|
|
253
|
+
const history = state.active
|
|
254
|
+
? [...state.history, { ...state.active, streaming: false }, entry]
|
|
255
|
+
: [...state.history, entry]
|
|
256
|
+
return bundle({}, history, null)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case 'error': {
|
|
260
|
+
const entry = {
|
|
261
|
+
id: id || `error-${now()}`,
|
|
262
|
+
role: 'system',
|
|
263
|
+
kind: 'error',
|
|
264
|
+
content: text(message.message),
|
|
265
|
+
code: text(message.code),
|
|
266
|
+
streaming: false,
|
|
267
|
+
at: now(),
|
|
268
|
+
}
|
|
269
|
+
const history = state.active
|
|
270
|
+
? [...state.history, { ...state.active, streaming: false }, entry]
|
|
271
|
+
: [...state.history, entry]
|
|
272
|
+
return bundle({ lastError: entry }, history, null)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'dual': {
|
|
276
|
+
const snapshot = message.snapshot ?? null
|
|
277
|
+
let safeSnapshot = snapshot
|
|
278
|
+
try {
|
|
279
|
+
if (snapshot && JSON.stringify(snapshot).length > STREAM_LIMITS.maxSnapshotBytes) safeSnapshot = null
|
|
280
|
+
} catch { safeSnapshot = null }
|
|
281
|
+
return { ...state, snapshot: safeSnapshot }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'hello': {
|
|
285
|
+
return {
|
|
286
|
+
...state,
|
|
287
|
+
capabilities: {
|
|
288
|
+
protocol: Number(message.protocol) || 0,
|
|
289
|
+
dualSnapshot: message?.capabilities?.dualSnapshot === true,
|
|
290
|
+
chat: message?.capabilities?.chat === true,
|
|
291
|
+
answer: message?.capabilities?.answer === true,
|
|
292
|
+
limitations: message?.limitations ?? null,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case 'pong':
|
|
298
|
+
return state
|
|
299
|
+
|
|
300
|
+
default:
|
|
301
|
+
return { ...state, ignored: state.ignored + 1 }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
getState: () => state,
|
|
307
|
+
apply: (message) => { state = applyOne(message); notify(); return state },
|
|
308
|
+
/**
|
|
309
|
+
* 用户回答选择题(本地乐观更新):
|
|
310
|
+
* * 只把那张卡片改成 answered 并写入 answer;
|
|
311
|
+
* * 可选追加一条本地用户气泡(`local: true`);
|
|
312
|
+
* * history 换新数组,其余消息对象引用保持不变。
|
|
313
|
+
* * 重复回答同一张卡片会被拒绝。
|
|
314
|
+
*/
|
|
315
|
+
answerChoice: (messageId, value, options = {}) => {
|
|
316
|
+
const id = text(messageId)
|
|
317
|
+
const answer = text(value)
|
|
318
|
+
if (!id || !answer) return { ok: false, code: 'answer-incomplete', error: 'answerChoice requires messageId and value' }
|
|
319
|
+
const index = state.history.findIndex((entry) => entry.id === id)
|
|
320
|
+
if (index < 0) return { ok: false, code: 'choice-not-found', error: `no message with id ${id}` }
|
|
321
|
+
const current = state.history[index]
|
|
322
|
+
if (current.kind !== 'choice') return { ok: false, code: 'not-a-choice', error: `${id} is not a choice card` }
|
|
323
|
+
if (current.answered === true) return { ok: false, code: 'choice-already-answered', error: `choice ${id} was already answered` }
|
|
324
|
+
const history = state.history.slice()
|
|
325
|
+
history[index] = { ...current, answered: true, answer }
|
|
326
|
+
const echo = text(options.echo)
|
|
327
|
+
if (echo) {
|
|
328
|
+
history.push({ id: `local-${now()}`, role: 'user', kind: 'text', content: echo, local: true, streaming: false, at: now() })
|
|
329
|
+
}
|
|
330
|
+
state = bundle({}, history, state.active)
|
|
331
|
+
notify()
|
|
332
|
+
return { ok: true, state }
|
|
333
|
+
},
|
|
334
|
+
/** 批量应用,只通知一次(UI 可按帧 flush)。 */
|
|
335
|
+
applyBatch: (messages) => {
|
|
336
|
+
const list = Array.isArray(messages) ? messages : []
|
|
337
|
+
for (const message of list) state = applyOne(message)
|
|
338
|
+
if (list.length > 0) notify()
|
|
339
|
+
return state
|
|
340
|
+
},
|
|
341
|
+
/** 外部 store 订阅(配合 useSyncExternalStore)。返回取消订阅函数。 */
|
|
342
|
+
subscribe: (listener) => {
|
|
343
|
+
if (typeof listener !== 'function') return () => {}
|
|
344
|
+
listeners.add(listener)
|
|
345
|
+
return () => listeners.delete(listener)
|
|
346
|
+
},
|
|
347
|
+
reset: () => { state = emptyState(); notify(); return state },
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 最小 RFC 6455 服务端实现(不引入 `ws` 依赖)。
|
|
7
|
+
*
|
|
8
|
+
* 只覆盖本通道需要的能力:
|
|
9
|
+
* * 握手:`Sec-WebSocket-Accept` = base64(sha1(key + GUID));
|
|
10
|
+
* * 收帧:客户端帧必须带掩码(RFC 要求),支持 7/16/64 位长度、分片续帧、close/ping/pong;
|
|
11
|
+
* * 发帧:服务端文本帧不加掩码,另发 ping/close。
|
|
12
|
+
*
|
|
13
|
+
* 不做:permessage-deflate、二进制 payload 处理(收到即忽略)、扩展协商。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
export const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
19
|
+
export const WS_MAX_PAYLOAD = 256 * 1024
|
|
20
|
+
|
|
21
|
+
export const OPCODE = Object.freeze({ continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa })
|
|
22
|
+
|
|
23
|
+
/** 计算握手 accept 值(RFC 6455 §1.3)。 */
|
|
24
|
+
export function acceptKey(clientKey) {
|
|
25
|
+
return crypto.createHash('sha1').update(String(clientKey) + WS_GUID).digest('base64')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 是否是合法的 WebSocket 升级请求;返回具名原因以便拒绝时说明。 */
|
|
29
|
+
export function validateUpgradeRequest(req) {
|
|
30
|
+
const headers = req?.headers ?? {}
|
|
31
|
+
const upgrade = String(headers.upgrade ?? '').toLowerCase()
|
|
32
|
+
const connection = String(headers.connection ?? '').toLowerCase()
|
|
33
|
+
const key = String(headers['sec-websocket-key'] ?? '').trim()
|
|
34
|
+
const version = String(headers['sec-websocket-version'] ?? '').trim()
|
|
35
|
+
if (upgrade !== 'websocket') return { ok: false, code: 'not-websocket-upgrade' }
|
|
36
|
+
if (!connection.split(',').map((part) => part.trim()).includes('upgrade')) return { ok: false, code: 'missing-connection-upgrade' }
|
|
37
|
+
if (!key || Buffer.from(key, 'base64').length !== 16) return { ok: false, code: 'invalid-websocket-key' }
|
|
38
|
+
if (version !== '13') return { ok: false, code: 'unsupported-websocket-version', version }
|
|
39
|
+
return { ok: true, key, accept: acceptKey(key) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 生成 101 响应文本。 */
|
|
43
|
+
export function upgradeResponse(accept) {
|
|
44
|
+
return [
|
|
45
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
46
|
+
'Upgrade: websocket',
|
|
47
|
+
'Connection: Upgrade',
|
|
48
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
49
|
+
'', '',
|
|
50
|
+
].join('\r\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 生成拒绝响应(带状态码与具名原因)。 */
|
|
54
|
+
export function rejectionResponse(status, code, message) {
|
|
55
|
+
const body = JSON.stringify({ ok: false, code, message })
|
|
56
|
+
return [
|
|
57
|
+
`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : status === 400 ? 'Bad Request' : 'Forbidden'}`,
|
|
58
|
+
'Content-Type: application/json; charset=utf-8',
|
|
59
|
+
`Content-Length: ${Buffer.byteLength(body)}`,
|
|
60
|
+
'Connection: close',
|
|
61
|
+
'', body,
|
|
62
|
+
].join('\r\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function encodeFrame(opcode, payloadBuffer) {
|
|
66
|
+
const length = payloadBuffer.length
|
|
67
|
+
let header = null
|
|
68
|
+
if (length < 126) {
|
|
69
|
+
header = Buffer.alloc(2)
|
|
70
|
+
header[1] = length
|
|
71
|
+
} else if (length < 65536) {
|
|
72
|
+
header = Buffer.alloc(4)
|
|
73
|
+
header[1] = 126
|
|
74
|
+
header.writeUInt16BE(length, 2)
|
|
75
|
+
} else {
|
|
76
|
+
header = Buffer.alloc(10)
|
|
77
|
+
header[1] = 127
|
|
78
|
+
header.writeBigUInt64BE(BigInt(length), 2)
|
|
79
|
+
}
|
|
80
|
+
header[0] = 0x80 | opcode // FIN + opcode
|
|
81
|
+
return Buffer.concat([header, payloadBuffer])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function encodeText(value) {
|
|
85
|
+
return encodeFrame(OPCODE.text, Buffer.from(String(value), 'utf8'))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function encodeClose(code = 1000, reason = '') {
|
|
89
|
+
const reasonBuffer = Buffer.from(String(reason), 'utf8')
|
|
90
|
+
const payload = Buffer.alloc(2 + reasonBuffer.length)
|
|
91
|
+
payload.writeUInt16BE(Number(code) || 1000, 0)
|
|
92
|
+
reasonBuffer.copy(payload, 2)
|
|
93
|
+
return encodeFrame(OPCODE.close, payload)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function encodePing() {
|
|
97
|
+
return encodeFrame(OPCODE.ping, Buffer.alloc(0))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function encodePong(payload = Buffer.alloc(0)) {
|
|
101
|
+
return encodeFrame(OPCODE.pong, Buffer.from(payload))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 增量帧解析器。`push(buffer)` 返回本次解析出的完整消息与需要回应的控制帧。
|
|
106
|
+
* 超过 maxPayload 的帧返回 error 并停止后续解析(由调用方断开连接)。
|
|
107
|
+
*/
|
|
108
|
+
export function createFrameParser({ maxPayload = WS_MAX_PAYLOAD } = {}) {
|
|
109
|
+
let pending = Buffer.alloc(0)
|
|
110
|
+
let fragments = []
|
|
111
|
+
let fragmentOpcode = null
|
|
112
|
+
|
|
113
|
+
function takeMessage(opcode, payload) {
|
|
114
|
+
if (opcode === OPCODE.continuation) {
|
|
115
|
+
if (fragmentOpcode === null) return { error: 'unexpected-continuation' }
|
|
116
|
+
fragments.push(payload)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
fragments = [payload]
|
|
120
|
+
fragmentOpcode = opcode
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
push(chunk) {
|
|
126
|
+
pending = pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([pending, chunk])
|
|
127
|
+
const messages = []
|
|
128
|
+
const controls = []
|
|
129
|
+
const errors = []
|
|
130
|
+
while (pending.length >= 2) {
|
|
131
|
+
const first = pending[0]
|
|
132
|
+
const second = pending[1]
|
|
133
|
+
const fin = (first & 0x80) !== 0
|
|
134
|
+
const opcode = first & 0x0f
|
|
135
|
+
const masked = (second & 0x80) !== 0
|
|
136
|
+
let length = second & 0x7f
|
|
137
|
+
let offset = 2
|
|
138
|
+
if (length === 126) {
|
|
139
|
+
if (pending.length < offset + 2) break
|
|
140
|
+
length = pending.readUInt16BE(offset)
|
|
141
|
+
offset += 2
|
|
142
|
+
} else if (length === 127) {
|
|
143
|
+
if (pending.length < offset + 8) break
|
|
144
|
+
const big = pending.readBigUInt64BE(offset)
|
|
145
|
+
if (big > BigInt(maxPayload)) { errors.push('payload-too-large'); return { messages, controls, errors } }
|
|
146
|
+
length = Number(big)
|
|
147
|
+
offset += 8
|
|
148
|
+
}
|
|
149
|
+
if (length > maxPayload) { errors.push('payload-too-large'); return { messages, controls, errors } }
|
|
150
|
+
if (!masked) { errors.push('client-frame-must-be-masked'); return { messages, controls, errors } }
|
|
151
|
+
if (pending.length < offset + 4 + length) break
|
|
152
|
+
const mask = pending.subarray(offset, offset + 4)
|
|
153
|
+
offset += 4
|
|
154
|
+
const payload = Buffer.from(pending.subarray(offset, offset + length))
|
|
155
|
+
for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]
|
|
156
|
+
pending = pending.subarray(offset + length)
|
|
157
|
+
|
|
158
|
+
if (opcode === OPCODE.ping) { controls.push({ type: 'ping', payload }); continue }
|
|
159
|
+
if (opcode === OPCODE.pong) { controls.push({ type: 'pong', payload }); continue }
|
|
160
|
+
if (opcode === OPCODE.close) {
|
|
161
|
+
controls.push({ type: 'close', payload })
|
|
162
|
+
fragmentOpcode = null
|
|
163
|
+
fragments = []
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (opcode === OPCODE.binary) { controls.push({ type: 'binary-ignored' }); continue }
|
|
167
|
+
|
|
168
|
+
const result = takeMessage(opcode, payload)
|
|
169
|
+
if (result && result.error) { errors.push(result.error); return { messages, controls, errors } }
|
|
170
|
+
if (fin) {
|
|
171
|
+
const merged = Buffer.concat(fragments)
|
|
172
|
+
const frameOpcode = fragmentOpcode
|
|
173
|
+
fragments = []
|
|
174
|
+
fragmentOpcode = null
|
|
175
|
+
if (frameOpcode === OPCODE.text) messages.push(merged.toString('utf8'))
|
|
176
|
+
else controls.push({ type: 'binary-ignored' })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { messages, controls, errors }
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/dual/documents.ts
CHANGED
|
@@ -105,3 +105,38 @@ export function renderClosureDocument(plan) {
|
|
|
105
105
|
'#### 项目资料汇总', '', bullets(closure.artifacts), '',
|
|
106
106
|
].join('\n')
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/** PM 的复核 + 复盘 + 总结(闭环第三步)。 */
|
|
110
|
+
export function renderReviewDocument(review, { feedback = null, handoff = null } = {}) {
|
|
111
|
+
const checks = review.acceptanceChecks
|
|
112
|
+
const failed = checks.filter((check) => check.met !== true)
|
|
113
|
+
const out = [
|
|
114
|
+
`# 复核与复盘 · ${review.id}`, '',
|
|
115
|
+
`- 结论:**${review.verdict === 'accepted' ? '通过' : '退回修改'}**`,
|
|
116
|
+
`- 关联计划:${review.planId}`,
|
|
117
|
+
`- 关联 PRD:${review.prdId ?? '未关联'}`,
|
|
118
|
+
`- 反馈 ID:${review.feedbackId}`, '',
|
|
119
|
+
'## 一、复核:验收逐条核对', '',
|
|
120
|
+
'| 验收标准 | 结果 | 证据 | 备注 |',
|
|
121
|
+
'|---|---|---|---|',
|
|
122
|
+
...checks.map((check) => `| ${cell(check.criterion)} | ${check.met ? '通过' : '未通过'} | ${cell(check.evidence.join(';') || '—')} | ${cell(check.note || '—')} |`), '',
|
|
123
|
+
failed.length > 0 ? `**未通过项**:${failed.map((check) => check.criterion).join(';')}` : '**全部验收项通过。**', '',
|
|
124
|
+
]
|
|
125
|
+
if (feedback) {
|
|
126
|
+
out.push('## 二、执行回执(PJM 自反馈)', '', feedback.summary, '')
|
|
127
|
+
out.push(`- 步骤:${feedback.metrics.done}/${feedback.metrics.steps}(${feedback.metrics.percent}%)`)
|
|
128
|
+
out.push(`- 估时/实际:${feedback.metrics.estimatedMinutes} / ${feedback.metrics.actualMinutes} 分钟`)
|
|
129
|
+
if (feedback.deviations.length) out.push('- 偏差:', bullets(feedback.deviations))
|
|
130
|
+
if (feedback.openIssues.length) out.push('- 遗留问题:', bullets(feedback.openIssues))
|
|
131
|
+
out.push('')
|
|
132
|
+
}
|
|
133
|
+
if (handoff) {
|
|
134
|
+
out.push('## 三、交接对照', '', `- 交接包:${handoff.id}(${handoff.status})`, `- 验收标准数:${handoff.acceptance.length}`, '')
|
|
135
|
+
}
|
|
136
|
+
out.push('## 四、复盘', '')
|
|
137
|
+
out.push('### 保持(做对了什么)', '', bullets(review.retrospective.kept), '')
|
|
138
|
+
out.push('### 改进(哪里不顺)', '', bullets(review.retrospective.improve), '')
|
|
139
|
+
out.push('### 经验(下次怎么做)', '', bullets(review.retrospective.lessons), '')
|
|
140
|
+
out.push('## 五、总结', '', review.summary, '')
|
|
141
|
+
return out.join('\n').trim() + '\n'
|
|
142
|
+
}
|