@mzzsfy/dsh-context-manager 0.1.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.
- package/README.md +61 -0
- package/cordis.patch.yml +12 -0
- package/package.json +49 -0
- package/src/client.js +1362 -0
- package/src/core.mjs +200 -0
- package/src/history-cache.mjs +128 -0
- package/src/index.js +578 -0
- package/test/client-fork-enhance.test.mjs +60 -0
- package/test/client-fork-family.test.mjs +48 -0
- package/test/client-fork.test.mjs +251 -0
- package/test/client-help.test.mjs +58 -0
- package/test/client-id.test.mjs +24 -0
- package/test/client-scope.test.mjs +54 -0
- package/test/client-steer.test.mjs +265 -0
- package/test/client-switch-guard.test.mjs +33 -0
- package/test/core-family.test.mjs +122 -0
- package/test/core.test.mjs +223 -0
- package/test/history-cache.test.mjs +135 -0
- package/test/index.test.mjs +790 -0
- package/test/parity.test.mjs +94 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// dsh-context-manager Host 半区:历史输入工作区缓存对齐 + 五条路由
|
|
2
|
+
// (inputs / prompts/toggle / history-enabled / steer-recall-enabled / fork-enabled)。
|
|
3
|
+
// 浮层请求直接读缓存文件立即返回;对齐在后台解压范围内会话产物与缓存合并写回,
|
|
4
|
+
// 运行中会话也参与(实时追加其新输入)。fork 分叉动作走 client 服务面,
|
|
5
|
+
// host 侧只有其启停路由。
|
|
6
|
+
// 插话撤回启停与历史浮层启停读走 settings(原生设置页与本分区开关同一存储)。
|
|
7
|
+
|
|
8
|
+
import { stat } from 'node:fs/promises'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import schemastery from '@deepseek-ai/schemastery'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
HISTORY_ALIGN_SLICE_MS,
|
|
16
|
+
HISTORY_ALIGN_THROTTLE_MS,
|
|
17
|
+
HISTORY_ALIGN_YIELD_MS,
|
|
18
|
+
HISTORY_FOCUS_WAIT_MS,
|
|
19
|
+
HISTORY_INPUT_LIMIT,
|
|
20
|
+
HISTORY_INPUT_MAX_CHARS,
|
|
21
|
+
HISTORY_PROMPTS_MAX,
|
|
22
|
+
HISTORY_SCOPES,
|
|
23
|
+
HISTORY_SESSION_SCAN_LIMIT,
|
|
24
|
+
HISTORY_STARTUP_DELAY_MS,
|
|
25
|
+
HISTORY_STARTUP_SCAN_LIMIT,
|
|
26
|
+
aggregateInputs,
|
|
27
|
+
extractUserInputs,
|
|
28
|
+
isSessionRunning,
|
|
29
|
+
maxArtifactBytesForHost,
|
|
30
|
+
} from './core.mjs'
|
|
31
|
+
import { ensureCacheDir, listWorkspaceCachesCached, readPrompts, readWorkspaceCache, writePrompts, writeWorkspaceCache } from './history-cache.mjs'
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
|
|
34
|
+
export const name = 'dsh-context-manager'
|
|
35
|
+
|
|
36
|
+
export const inject = ['webServer', 'agents', 'sessionQuery', 'sessionPersistence']
|
|
37
|
+
|
|
38
|
+
const NAMESPACE = 'context'
|
|
39
|
+
|
|
40
|
+
// 路由响应文案;导出供测试断言与实现同步。
|
|
41
|
+
export const MESSAGES = {
|
|
42
|
+
unknownSession: '会话不存在',
|
|
43
|
+
badJsonBody: '请求体不是合法 JSON',
|
|
44
|
+
systemError: '操作失败(系统级错误,详见服务端日志)',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SETTINGS_SCHEMA = schemastery.object({
|
|
48
|
+
historyEnabled: schemastery.boolean().default(true)
|
|
49
|
+
.description('历史输入浮层(Alt+↑),低频功能,关闭后输入框锚点与快捷键整体不渲染;变更刷新页面生效'),
|
|
50
|
+
steerRecallEnabled: schemastery.boolean().default(true)
|
|
51
|
+
.description('插话撤回图标,低频功能,关闭后撤回图标整体不渲染;变更刷新页面生效'),
|
|
52
|
+
forkEnabled: schemastery.boolean().default(true)
|
|
53
|
+
.description('消息气泡操作排的分叉按钮,关闭后按钮整体不渲染;变更刷新页面生效'),
|
|
54
|
+
forkAutoResendEnabled: schemastery.boolean().default(false)
|
|
55
|
+
.description('分叉成功后自动把该轮原输入发送到子会话(重生成语义);关闭时仅回填子会话输入框供编辑重发'),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function sendJson(res, status, payload) {
|
|
59
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
60
|
+
res.end(JSON.stringify(payload))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 错误响应出口:业务错误(Error 无错误码)原样透传消息;系统级错误(带 fs 错误码,
|
|
64
|
+
// message 内嵌绝对路径)收敛为固定文案,原始错误仅进服务端日志
|
|
65
|
+
function respondError(ctx, res, error) {
|
|
66
|
+
const isSystem = Boolean(error && typeof error.code === 'string' && error.code !== '')
|
|
67
|
+
if (isSystem && ctx.logger) ctx.logger.warn('context-manager 系统级错误: ' + String(error && error.stack || error))
|
|
68
|
+
const message = isSystem
|
|
69
|
+
? MESSAGES.systemError
|
|
70
|
+
: (error && error.message ? error.message : String(error))
|
|
71
|
+
sendJson(res, 400, { error: message })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readSettings(ctx) {
|
|
75
|
+
const settings = ctx.get('settings')
|
|
76
|
+
const value = settings ? settings.get(NAMESPACE) : undefined
|
|
77
|
+
return {
|
|
78
|
+
historyEnabled: !value || value.historyEnabled !== false,
|
|
79
|
+
steerRecallEnabled: !value || value.steerRecallEnabled !== false,
|
|
80
|
+
forkEnabled: !value || value.forkEnabled !== false,
|
|
81
|
+
forkAutoResendEnabled: Boolean(value && value.forkAutoResendEnabled === true),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readBody(req) {
|
|
86
|
+
const BODY_MAX_BYTES = 64 * 1024
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let size = 0
|
|
89
|
+
const chunks = []
|
|
90
|
+
req.on('data', (chunk) => {
|
|
91
|
+
size += chunk.length
|
|
92
|
+
if (size > BODY_MAX_BYTES) {
|
|
93
|
+
reject(new Error('请求体超过上限'))
|
|
94
|
+
req.destroy()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
chunks.push(chunk)
|
|
98
|
+
})
|
|
99
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
100
|
+
req.on('error', reject)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rejectMethod(req, res, method) {
|
|
105
|
+
if (req.method === method) return true
|
|
106
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
111
|
+
export function apply(ctx, config) {
|
|
112
|
+
// 历史输入:工作区粒度持久缓存(~/.dsh/historyPrompt/<工作区>-<hash>.json)。
|
|
113
|
+
// 浮层请求直接读缓存文件立即返回(毫秒级);对齐在后台解压范围内会话产物,
|
|
114
|
+
// 与缓存合并去重后写回——运行中会话也参与(实时追加其新输入)。
|
|
115
|
+
// 解压不重复执行:每会话提取结果持久化在工作区缓存 extracts 段,附产物 stat 指纹
|
|
116
|
+
// (mtimeMs+size),对齐时先 stat 比对,产物没变零解压——判断全部基于磁盘,跨重启生效。
|
|
117
|
+
// 运行中会话(当前会话)提取结果只保留内存不落盘(产物持续变化,落盘指纹立即失效)。
|
|
118
|
+
// 解压是同步 CPU 操作:对齐中连续占用超 SLICE 即让出 YIELD,启动对齐延迟
|
|
119
|
+
// STARTUP_DELAY 再跑,绝不阻塞宿主启动。测试经 env DSH_HISTORY_CACHE_DIR 注入临时目录
|
|
120
|
+
const cacheDir = process.env.DSH_HISTORY_CACHE_DIR
|
|
121
|
+
|| join(homedir(), '.dsh', 'historyPrompt')
|
|
122
|
+
// 巨产物跳过线按宿主版本黑名单取值:0.1.1/0.1.2 解压实现无周期让出从严 8MiB,
|
|
123
|
+
// 其余 16MiB。运行宿主清单经 require 链解析(插件随宿主树部署时必命中),
|
|
124
|
+
// 探测失败按黑名单保守回退旧线。激活时求值一次即冻结,热路径只读值;
|
|
125
|
+
// 宿主热升级后重启生效
|
|
126
|
+
const maxArtifactBytes = resolveMaxArtifactBytes()
|
|
127
|
+
function resolveMaxArtifactBytes() {
|
|
128
|
+
let version = null
|
|
129
|
+
try {
|
|
130
|
+
version = createRequire(import.meta.url)('@deepseek-ai/dsh/package.json').version
|
|
131
|
+
} catch {
|
|
132
|
+
version = null
|
|
133
|
+
}
|
|
134
|
+
return maxArtifactBytesForHost(version)
|
|
135
|
+
}
|
|
136
|
+
const alignThrottle = new Map()
|
|
137
|
+
const runtimeExtracts = new Map()
|
|
138
|
+
const headerCache = new Map()
|
|
139
|
+
ctx.effect(() => () => { alignThrottle.clear(); runtimeExtracts.clear(); headerCache.clear() }, 'context-manager history align state')
|
|
140
|
+
|
|
141
|
+
// 双车道对齐队列:批量车道(启动/请求触发,一次解一整个窗口)与焦点车道
|
|
142
|
+
// (单会话实时追加,延时敏感)各自串行、互不等待——焦点车道不被冷启动
|
|
143
|
+
// 批量任务的分钟级解压堵死。两车道并发写同一工作区缓存时,最坏丢一次
|
|
144
|
+
// 更新,但批量窗口必含活跃会话(其自身重读即收敛),下轮指纹失配自愈
|
|
145
|
+
function makeLane() {
|
|
146
|
+
const tasks = []
|
|
147
|
+
let draining = false
|
|
148
|
+
return (body) => new Promise((resolve) => {
|
|
149
|
+
tasks.push(() => body().then(resolve, resolve))
|
|
150
|
+
if (draining) return
|
|
151
|
+
draining = true
|
|
152
|
+
void (async () => {
|
|
153
|
+
while (tasks.length > 0) await tasks.shift()()
|
|
154
|
+
draining = false
|
|
155
|
+
})()
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const enqueueBulkAlign = makeLane()
|
|
159
|
+
const enqueueFocusAlign = makeLane()
|
|
160
|
+
|
|
161
|
+
function sleep(ms) {
|
|
162
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function safeStat(path) {
|
|
166
|
+
try {
|
|
167
|
+
const info = await stat(path)
|
|
168
|
+
return { mtimeMs: info.mtimeMs, size: info.size }
|
|
169
|
+
} catch {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 单会话提取:产物 stat 指纹与缓存一致即复用(零解压);否则解压并更新缓存。
|
|
175
|
+
// 运行中会话走内存指纹(runtimeExtracts),其余走磁盘 extracts
|
|
176
|
+
async function extractSession(recordItem, fingerprint, extracts, isRunning) {
|
|
177
|
+
const sessionId = recordItem.header.id
|
|
178
|
+
const known = isRunning ? runtimeExtracts.get(sessionId) : extracts[sessionId]
|
|
179
|
+
if (fingerprint !== null && known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size) {
|
|
180
|
+
return known.entries
|
|
181
|
+
}
|
|
182
|
+
const snapshot = await ctx.sessionQuery.readSession(sessionId)
|
|
183
|
+
const entries = extractUserInputs(snapshot.events)
|
|
184
|
+
const record = { mtimeMs: fingerprint === null ? 0 : fingerprint.mtimeMs, size: fingerprint === null ? 0 : fingerprint.size, entries }
|
|
185
|
+
if (isRunning) runtimeExtracts.set(sessionId, record)
|
|
186
|
+
else if (fingerprint !== null) extracts[sessionId] = record
|
|
187
|
+
return entries
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 批量对齐执行体:列出范围会话,逐个按指纹增量提取(磁盘 extracts 复用),
|
|
191
|
+
// 与缓存合并后原子写回,extracts 裁剪到完整扫描窗口。经批量队列入队。
|
|
192
|
+
// 超过 MAX_ARTIFACT 的巨产物直接跳过:解压耗时与内存峰值随产物规模失控
|
|
193
|
+
// (宿主内部逐帧解压带周期让出,不阻塞主循环);其历史来自缓存 entries 的既有贡献
|
|
194
|
+
function alignWorkspaceNow(cwd) {
|
|
195
|
+
return enqueueBulkAlign(async () => {
|
|
196
|
+
const agents = ctx.get('agents')
|
|
197
|
+
const persistence = ctx.get('sessionPersistence')
|
|
198
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
199
|
+
// subagent 会话不在主列表(官方 sessionVisible 以 origin 排除):其输入是
|
|
200
|
+
// 任务提示与注入文本而非人类输入,且会以更近的产物时间挤占扫描窗口,必须排除
|
|
201
|
+
const window = records
|
|
202
|
+
.filter((recordItem) => recordItem.header.origin !== 'subagent'
|
|
203
|
+
&& recordItem.header.cwd !== undefined && samePath(recordItem.header.cwd, cwd))
|
|
204
|
+
.slice(0, HISTORY_SESSION_SCAN_LIMIT)
|
|
205
|
+
// 该 cwd 无主会话(纯 subagent 派生或已清空):无可对齐内容,缓存读写一并跳过
|
|
206
|
+
if (window.length === 0) return
|
|
207
|
+
const cached = await readWorkspaceCache(cacheDir, cwd)
|
|
208
|
+
const extracts = cached && cached.extracts ? { ...cached.extracts } : {}
|
|
209
|
+
const fresh = []
|
|
210
|
+
let sliceStart = Date.now()
|
|
211
|
+
for (const recordItem of window) {
|
|
212
|
+
const isRunning = isSessionRunning({ agents, sessionId: recordItem.header.id })
|
|
213
|
+
const located = persistence ? persistence.locate(recordItem.header) : undefined
|
|
214
|
+
const fingerprint = located ? await safeStat(located.path) : null
|
|
215
|
+
if (fingerprint !== null && fingerprint.size > maxArtifactBytes) continue
|
|
216
|
+
try {
|
|
217
|
+
const entries = await extractSession(recordItem, fingerprint, extracts, isRunning)
|
|
218
|
+
fresh.push(...entries.map((entry) => ({ ...entry, sid: recordItem.header.id })))
|
|
219
|
+
} catch (error) {
|
|
220
|
+
ctx.logger && ctx.logger.warn('context-manager 历史输入对齐失败(' + recordItem.header.id + '): ' + String(error && error.stack || error))
|
|
221
|
+
}
|
|
222
|
+
// 解压(可能命中缓存跳过)后检查连续占用:超 SLICE 让出 YIELD 不霸占主循环
|
|
223
|
+
if (Date.now() - sliceStart > HISTORY_ALIGN_SLICE_MS) {
|
|
224
|
+
await sleep(HISTORY_ALIGN_YIELD_MS)
|
|
225
|
+
sliceStart = Date.now()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// extracts 裁剪:仅保留窗口内非运行会话(运行中走内存,窗口外已淘汰)
|
|
229
|
+
const windowIds = new Set(window.map((recordItem) => recordItem.header.id))
|
|
230
|
+
const prunedExtracts = {}
|
|
231
|
+
for (const [sessionId, record] of Object.entries(extracts)) {
|
|
232
|
+
if (windowIds.has(sessionId) && !isSessionRunning({ agents, sessionId })) prunedExtracts[sessionId] = record
|
|
233
|
+
}
|
|
234
|
+
// 既往污染自愈:旧版本窗口未排除 subagent,其条目已混入缓存 entries——
|
|
235
|
+
// 合并前按列表 origin 识别清除;会话已删除的条目无法识别,随时间被挤出
|
|
236
|
+
const subagentIds = new Set(records
|
|
237
|
+
.filter((recordItem) => recordItem.header.origin === 'subagent')
|
|
238
|
+
.map((recordItem) => recordItem.header.id))
|
|
239
|
+
const legacyEntries = subagentIds.size === 0 ? (cached ? cached.entries : [])
|
|
240
|
+
: (cached ? cached.entries : []).filter((entry) => entry.sid === undefined || !subagentIds.has(entry.sid))
|
|
241
|
+
const merged = aggregateInputs(
|
|
242
|
+
legacyEntries.concat(fresh),
|
|
243
|
+
{ limit: HISTORY_INPUT_LIMIT, maxChars: HISTORY_INPUT_MAX_CHARS },
|
|
244
|
+
)
|
|
245
|
+
await writeWorkspaceCache(cacheDir, cwd, { entries: merged, extracts: prunedExtracts })
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 焦点对齐执行体:只解目标会话并写回(实时追加路径),走独立焦点车道,
|
|
250
|
+
// 不被批量车道的分钟级解压堵死。不裁剪 extracts(裁剪由批量对齐负责),
|
|
251
|
+
// 只新增/更新目标会话指纹
|
|
252
|
+
function alignSessionFocus(cwd, header) {
|
|
253
|
+
return enqueueFocusAlign(async () => {
|
|
254
|
+
// 与批量窗口同一不变式:缓存只收主会话输入(正常 UI 不可达,防直调路由)
|
|
255
|
+
if (header.origin === 'subagent') return
|
|
256
|
+
const sessionId = header.id
|
|
257
|
+
const persistence = ctx.get('sessionPersistence')
|
|
258
|
+
const located = persistence ? persistence.locate(header) : undefined
|
|
259
|
+
const fingerprint = located ? await safeStat(located.path) : null
|
|
260
|
+
if (fingerprint === null || fingerprint.size > maxArtifactBytes) return
|
|
261
|
+
const isRunning = isSessionRunning({ agents: ctx.get('agents'), sessionId })
|
|
262
|
+
const cached = await readWorkspaceCache(cacheDir, cwd)
|
|
263
|
+
const extracts = cached && cached.extracts ? { ...cached.extracts } : {}
|
|
264
|
+
const entries = await extractSession({ header }, fingerprint, extracts, isRunning)
|
|
265
|
+
const merged = aggregateInputs(
|
|
266
|
+
(cached ? cached.entries : []).concat(entries.map((entry) => ({ ...entry, sid: sessionId }))),
|
|
267
|
+
{ limit: HISTORY_INPUT_LIMIT, maxChars: HISTORY_INPUT_MAX_CHARS },
|
|
268
|
+
)
|
|
269
|
+
await writeWorkspaceCache(cacheDir, cwd, { entries: merged, extracts })
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 节流壳:请求触发的后台对齐入口,30s 内同工作区只排队一次
|
|
274
|
+
function alignWorkspace(cwd) {
|
|
275
|
+
const now = Date.now()
|
|
276
|
+
const last = alignThrottle.get(cwd)
|
|
277
|
+
if (last !== undefined && now - last < HISTORY_ALIGN_THROTTLE_MS) return
|
|
278
|
+
alignThrottle.set(cwd, now)
|
|
279
|
+
void alignWorkspaceNow(cwd)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 启动:只确保目录存在,**零解压**——历史直接读磁盘缓存(重启前的输入本就在
|
|
283
|
+
// entries 里),宿主启动零负担;全量对齐延迟 STARTUP_DELAY 再跑(只解产物变过
|
|
284
|
+
// 的会话)。定时器随插件销毁清理
|
|
285
|
+
const startupTimers = []
|
|
286
|
+
ctx.effect(() => () => { for (const timer of startupTimers) clearTimeout(timer) }, 'context-manager history startup timers')
|
|
287
|
+
|
|
288
|
+
function scheduleStartupAlign() {
|
|
289
|
+
startupTimers.push(setTimeout(() => {
|
|
290
|
+
void (async () => {
|
|
291
|
+
await ensureCacheDir(cacheDir)
|
|
292
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
293
|
+
const workspaces = []
|
|
294
|
+
// subagent 会话不参与归纳:纯 subagent 的 cwd 无人类输入,不值得建缓存;
|
|
295
|
+
// 过滤先行再取最近 STARTUP_SCAN 条,subagent 密集派生时不挤占归纳窗口
|
|
296
|
+
for (const recordItem of records
|
|
297
|
+
.filter((item) => item.header.origin !== 'subagent')
|
|
298
|
+
.slice(0, HISTORY_STARTUP_SCAN_LIMIT)) {
|
|
299
|
+
const recordCwd = recordItem.header.cwd
|
|
300
|
+
if (recordCwd === undefined) continue
|
|
301
|
+
if (workspaces.some((known) => samePath(known, recordCwd))) continue
|
|
302
|
+
workspaces.push(recordCwd)
|
|
303
|
+
}
|
|
304
|
+
for (const workspaceCwd of workspaces) alignWorkspace(workspaceCwd)
|
|
305
|
+
})().catch((error) => {
|
|
306
|
+
ctx.logger && ctx.logger.warn('context-manager 历史缓存启动对齐失败: ' + String(error && error.stack || error))
|
|
307
|
+
})
|
|
308
|
+
}, HISTORY_STARTUP_DELAY_MS))
|
|
309
|
+
}
|
|
310
|
+
scheduleStartupAlign()
|
|
311
|
+
|
|
312
|
+
// 聚焦对齐去重:同会话在队列/执行中不重复入队(轮询重入须挡住风暴)。
|
|
313
|
+
// 值为在途 promise,路由据此有界等待同一次对齐
|
|
314
|
+
const pendingFocus = new Map()
|
|
315
|
+
ctx.effect(() => () => pendingFocus.clear(), 'context-manager history pending focus')
|
|
316
|
+
|
|
317
|
+
// 按范围读取历史:全部直接读持久缓存(毫秒级)立即返回,对齐由后台进行;
|
|
318
|
+
// aligned=false 表示数据未就绪(首次无缓存,或请求会话产物已变而缓存未跟上),
|
|
319
|
+
// client 据此提示等待并静默重拉
|
|
320
|
+
async function collectInputs(scope, sessionId, header) {
|
|
321
|
+
const aggregateOptions = { limit: HISTORY_INPUT_LIMIT, maxChars: HISTORY_INPUT_MAX_CHARS }
|
|
322
|
+
const cwd = header.cwd
|
|
323
|
+
if (scope === 'prompts') {
|
|
324
|
+
const items = await readPrompts(cacheDir)
|
|
325
|
+
return { inputs: aggregateInputs(items, aggregateOptions), aligned: true }
|
|
326
|
+
}
|
|
327
|
+
if (scope === 'session') {
|
|
328
|
+
if (cwd === undefined) return { inputs: [], aligned: true }
|
|
329
|
+
alignWorkspace(cwd)
|
|
330
|
+
const read = async () => {
|
|
331
|
+
const cached = await readWorkspaceCache(cacheDir, cwd)
|
|
332
|
+
return {
|
|
333
|
+
cached,
|
|
334
|
+
mine: aggregateInputs(cached ? cached.entries.filter((entry) => entry.sid === sessionId) : [], aggregateOptions),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// 实时追加:产物 stat 与已知指纹不一致 = 会话有新输入未入缓存——聚焦对齐
|
|
338
|
+
// 只解该会话并写回。路由有界等待本次对齐(首条 <3s 预算):正常会话单次
|
|
339
|
+
// 请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,后续
|
|
340
|
+
// 轮询流式补齐。巨产物跳过聚焦(解压耗时与内存峰值失控),维持 aligned=true
|
|
341
|
+
const check = async (cached) => {
|
|
342
|
+
const agents = ctx.get('agents')
|
|
343
|
+
const running = isSessionRunning({ agents, sessionId })
|
|
344
|
+
const known = running ? runtimeExtracts.get(sessionId) : cached && cached.extracts ? cached.extracts[sessionId] : undefined
|
|
345
|
+
const located = ctx.get('sessionPersistence') ? ctx.get('sessionPersistence').locate(header) : undefined
|
|
346
|
+
const fingerprint = located ? await safeStat(located.path) : null
|
|
347
|
+
return {
|
|
348
|
+
stale: fingerprint !== null && !(known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
|
|
349
|
+
oversize: fingerprint !== null && fingerprint.size > maxArtifactBytes,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
let { cached, mine } = await read()
|
|
353
|
+
let { stale, oversize } = await check(cached)
|
|
354
|
+
if (stale && !oversize) {
|
|
355
|
+
let focus = pendingFocus.get(sessionId)
|
|
356
|
+
if (!focus) {
|
|
357
|
+
focus = alignSessionFocus(cwd, header).finally(() => pendingFocus.delete(sessionId))
|
|
358
|
+
pendingFocus.set(sessionId, focus)
|
|
359
|
+
}
|
|
360
|
+
const done = await Promise.race([focus.then(() => true), sleep(HISTORY_FOCUS_WAIT_MS).then(() => false)])
|
|
361
|
+
;({ cached, mine } = await read())
|
|
362
|
+
;({ stale } = await check(cached))
|
|
363
|
+
if (!done || stale) return { inputs: mine, aligned: false }
|
|
364
|
+
return { inputs: mine, aligned: true }
|
|
365
|
+
}
|
|
366
|
+
return { inputs: mine, aligned: cached !== null }
|
|
367
|
+
}
|
|
368
|
+
if (scope === 'global') {
|
|
369
|
+
// 浮层轮询路径:stat 指纹缓存,产物未变的文件不再重复读盘
|
|
370
|
+
const caches = await listWorkspaceCachesCached(cacheDir)
|
|
371
|
+
for (const cache of caches) alignWorkspace(cache.cwd)
|
|
372
|
+
return { inputs: aggregateInputs(caches.flatMap((cache) => cache.entries), aggregateOptions), aligned: true }
|
|
373
|
+
}
|
|
374
|
+
const cached = await readWorkspaceCache(cacheDir, cwd)
|
|
375
|
+
if (cwd !== undefined) alignWorkspace(cwd)
|
|
376
|
+
return { inputs: aggregateInputs(cached ? cached.entries : [], aggregateOptions), aligned: cached !== null }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const routes = [
|
|
380
|
+
{
|
|
381
|
+
path: '/api/context/inputs',
|
|
382
|
+
handler: async (req, res) => {
|
|
383
|
+
if (!rejectMethod(req, res, 'GET')) return
|
|
384
|
+
try {
|
|
385
|
+
const url = new URL(req.url, 'http://localhost')
|
|
386
|
+
const sessionId = (url.searchParams.get('sessionId') || '').trim()
|
|
387
|
+
if (sessionId === '') throw new Error('sessionId 不能为空')
|
|
388
|
+
const scopeRaw = url.searchParams.get('scope') || 'session'
|
|
389
|
+
const scope = HISTORY_SCOPES.includes(scopeRaw) ? scopeRaw : 'session'
|
|
390
|
+
const header = await findHeader(ctx, sessionId, headerCache)
|
|
391
|
+
if (header === undefined) {
|
|
392
|
+
sendJson(res, 400, { error: MESSAGES.unknownSession })
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
// workspace 范围依赖 cwd 归属;无 cwd 会话在 session 范围无从定位工作区缓存,
|
|
396
|
+
// global 不依赖 cwd 照常聚合
|
|
397
|
+
const result = scope === 'workspace' && header.cwd === undefined
|
|
398
|
+
? { inputs: [], aligned: true }
|
|
399
|
+
: await collectInputs(scope, sessionId, header)
|
|
400
|
+
sendJson(res, 200, result)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
respondError(ctx, res, error)
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
// 常用提示词收藏切换:text 已收藏则移除,否则收藏(截断与上限同历史输入)
|
|
408
|
+
path: '/api/context/prompts/toggle',
|
|
409
|
+
handler: async (req, res) => {
|
|
410
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
411
|
+
try {
|
|
412
|
+
let body
|
|
413
|
+
try {
|
|
414
|
+
body = JSON.parse(await readBody(req))
|
|
415
|
+
} catch {
|
|
416
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
417
|
+
}
|
|
418
|
+
const text = typeof body.text === 'string' ? body.text.trim().slice(0, HISTORY_INPUT_MAX_CHARS) : ''
|
|
419
|
+
if (text === '') throw new Error('text 不能为空')
|
|
420
|
+
const items = await readPrompts(cacheDir)
|
|
421
|
+
const at = items.findIndex((item) => item.text === text)
|
|
422
|
+
let collected
|
|
423
|
+
if (at >= 0) {
|
|
424
|
+
items.splice(at, 1)
|
|
425
|
+
collected = false
|
|
426
|
+
} else {
|
|
427
|
+
items.unshift({ text, at: Date.now() })
|
|
428
|
+
if (items.length > HISTORY_PROMPTS_MAX) items.length = HISTORY_PROMPTS_MAX
|
|
429
|
+
collected = true
|
|
430
|
+
}
|
|
431
|
+
await writePrompts(cacheDir, items)
|
|
432
|
+
sendJson(res, 200, { collected })
|
|
433
|
+
} catch (error) {
|
|
434
|
+
respondError(ctx, res, error)
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
// 历史浮层启停:读走 settings(原生设置页与本分区开关同一存储),
|
|
440
|
+
// 写经 settings.update,原生设置页与本分区即时同值
|
|
441
|
+
path: '/api/context/history-enabled',
|
|
442
|
+
handler: async (req, res) => {
|
|
443
|
+
try {
|
|
444
|
+
if (req.method === 'GET') {
|
|
445
|
+
sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
449
|
+
let body
|
|
450
|
+
try {
|
|
451
|
+
body = JSON.parse(await readBody(req))
|
|
452
|
+
} catch {
|
|
453
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
454
|
+
}
|
|
455
|
+
const settings = ctx.get('settings')
|
|
456
|
+
if (!settings) throw new Error('宿主设置服务不可用')
|
|
457
|
+
// update 异步落盘后才提交新值:await 保证持久化完成后再读回
|
|
458
|
+
await settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
|
|
459
|
+
sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
|
|
460
|
+
} catch (error) {
|
|
461
|
+
respondError(ctx, res, error)
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
// 插话撤回启停:与历史浮层开关同构,读走 settings,写经 settings.update
|
|
467
|
+
path: '/api/context/steer-recall-enabled',
|
|
468
|
+
handler: async (req, res) => {
|
|
469
|
+
try {
|
|
470
|
+
if (req.method === 'GET') {
|
|
471
|
+
sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
475
|
+
let body
|
|
476
|
+
try {
|
|
477
|
+
body = JSON.parse(await readBody(req))
|
|
478
|
+
} catch {
|
|
479
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
480
|
+
}
|
|
481
|
+
const settings = ctx.get('settings')
|
|
482
|
+
if (!settings) throw new Error('宿主设置服务不可用')
|
|
483
|
+
// update 异步落盘后才提交新值:await 保证持久化完成后再读回
|
|
484
|
+
await settings.update(NAMESPACE, { steerRecallEnabled: Boolean(body && body.enabled) })
|
|
485
|
+
sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
|
|
486
|
+
} catch (error) {
|
|
487
|
+
respondError(ctx, res, error)
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
// 对话 fork 启停:与历史浮层开关同构,读走 settings,写经 settings.update
|
|
493
|
+
path: '/api/context/fork-enabled',
|
|
494
|
+
handler: async (req, res) => {
|
|
495
|
+
try {
|
|
496
|
+
if (req.method === 'GET') {
|
|
497
|
+
sendJson(res, 200, { enabled: readSettings(ctx).forkEnabled })
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
501
|
+
let body
|
|
502
|
+
try {
|
|
503
|
+
body = JSON.parse(await readBody(req))
|
|
504
|
+
} catch {
|
|
505
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
506
|
+
}
|
|
507
|
+
const settings = ctx.get('settings')
|
|
508
|
+
if (!settings) throw new Error('宿主设置服务不可用')
|
|
509
|
+
// update 异步落盘后才提交新值:await 保证持久化完成后再读回
|
|
510
|
+
await settings.update(NAMESPACE, { forkEnabled: Boolean(body && body.enabled) })
|
|
511
|
+
sendJson(res, 200, { enabled: readSettings(ctx).forkEnabled })
|
|
512
|
+
} catch (error) {
|
|
513
|
+
respondError(ctx, res, error)
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
// 分叉后自动重发启停:默认关(仅回填供编辑);读走 settings,写经 settings.update
|
|
519
|
+
path: '/api/context/fork-auto-resend-enabled',
|
|
520
|
+
handler: async (req, res) => {
|
|
521
|
+
try {
|
|
522
|
+
if (req.method === 'GET') {
|
|
523
|
+
sendJson(res, 200, { enabled: readSettings(ctx).forkAutoResendEnabled })
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
527
|
+
let body
|
|
528
|
+
try {
|
|
529
|
+
body = JSON.parse(await readBody(req))
|
|
530
|
+
} catch {
|
|
531
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
532
|
+
}
|
|
533
|
+
const settings = ctx.get('settings')
|
|
534
|
+
if (!settings) throw new Error('宿主设置服务不可用')
|
|
535
|
+
await settings.update(NAMESPACE, { forkAutoResendEnabled: Boolean(body && body.enabled) })
|
|
536
|
+
sendJson(res, 200, { enabled: readSettings(ctx).forkAutoResendEnabled })
|
|
537
|
+
} catch (error) {
|
|
538
|
+
respondError(ctx, res, error)
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
]
|
|
543
|
+
|
|
544
|
+
for (const route of routes) {
|
|
545
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
|
|
546
|
+
'dsh-context-manager ' + route.path)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
550
|
+
settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// header 查找缓存:inputs 路由每次请求都要查 header,listSessions 为全量扫描
|
|
555
|
+
// (单次数百 ms,与后台对齐并发时成倍放大),而 sessionId→header 基本不变——
|
|
556
|
+
// 扫一次进缓存,TTL 界定新鲜度,容量上限防无界增长;只缓存命中,
|
|
557
|
+
// 未命中不缓存(新会话创建竞态下次扫描即命中,负缓存会误伤)。
|
|
558
|
+
// 与 session-manager 各持独立实例,互不共享状态
|
|
559
|
+
const HEADER_CACHE_TTL_MS = 10 * 60 * 1000
|
|
560
|
+
const HEADER_CACHE_MAX = 500
|
|
561
|
+
|
|
562
|
+
function findHeader(ctx, sessionId, cache) {
|
|
563
|
+
const hit = cache.get(sessionId)
|
|
564
|
+
if (hit && Date.now() - hit.at < HEADER_CACHE_TTL_MS) return Promise.resolve(hit.header)
|
|
565
|
+
return ctx.sessionQuery.listSessions().then((records) => {
|
|
566
|
+
const found = records.find((recordItem) => String(recordItem.header.id) === sessionId)
|
|
567
|
+
if (!found) return undefined
|
|
568
|
+
if (cache.size >= HEADER_CACHE_MAX) cache.delete(cache.keys().next().value)
|
|
569
|
+
cache.set(sessionId, { header: found.header, at: Date.now() })
|
|
570
|
+
return found.header
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// win32 路径大小写不敏感:realpath 已规范大小写,但比对侧可能存在大小写漂移
|
|
575
|
+
function samePath(left, right) {
|
|
576
|
+
if (left === right) return true
|
|
577
|
+
return process.platform === 'win32' && String(left).toLowerCase() === String(right).toLowerCase()
|
|
578
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
|
|
7
|
+
|
|
8
|
+
// F2 进行中轮分叉 的客户端契约(源码切片锁定,与既有 fork 契约测试同构):
|
|
9
|
+
// - 进行中轮条目在 loadTurnEnds 映射中以 seq:null + open:true 登记
|
|
10
|
+
// - 注入资格的锚点查找跳过 open 轮(锚必须是闭合轮 turn/end)
|
|
11
|
+
// - 分叉 resolve 后,open 轮触发 cancelSession 停原会话,闭合轮不触发
|
|
12
|
+
|
|
13
|
+
test('源码契约:进行中轮登记为 open 且无闭合锚', () => {
|
|
14
|
+
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const map = new Map()'), CLIENT_SRC.indexOf('return map'))
|
|
15
|
+
assert.ok(slice.includes("map.set(turn, { seq: null, text: turnText, open: true })"), '流末尾无 turn/end 的轮应登记 seq:null + open:true')
|
|
16
|
+
assert.ok(slice.includes("map.set(turn, { seq: event.seq, text: turnText, open: false })"), '闭合轮应登记 open:false')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('源码契约:分叉锚点查找只接受闭合轮(seq 非 null)', () => {
|
|
20
|
+
assert.ok(CLIENT_SRC.includes('candidate && candidate.seq !== null'), '锚点查找必须跳过进行中轮(null seq)')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('源码契约:进行中轮分叉后停原会话,闭合轮分叉不停', () => {
|
|
24
|
+
const click = CLIENT_SRC.slice(CLIENT_SRC.indexOf('forkSessionFn({ sessionId: current.sessionId'), CLIENT_SRC.indexOf(".catch((error) => {\n const code = error && error.code"))
|
|
25
|
+
assert.ok(click.includes('currentEntry.open === true'), 'cancel 应以 open 标记为条件')
|
|
26
|
+
assert.ok(click.includes('cancelSessionFn({ sessionId: current.sessionId })'), 'open 轮分叉成功后应停原会话')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('源码契约:进行中轮的按钮说明标注进行中语义', () => {
|
|
30
|
+
assert.ok(CLIENT_SRC.includes("entry.open\n ? '重写该轮(进行中)"), '按钮 title 应区分进行中轮')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// F3 分叉后自动重发 的客户端契约:
|
|
34
|
+
// - 开关经 fork-auto-resend-enabled 路由拉取,默认关
|
|
35
|
+
// - 点击登记的草稿带 autoSubmit(跟随开关)
|
|
36
|
+
// - 消费侧 autoSubmit 时经 prompt 通道提交,prompt 失败仅 toast
|
|
37
|
+
// - prompt 不可用(旧宿主)时 submitPrompt 为 null,自动降级仅回填
|
|
38
|
+
|
|
39
|
+
test('源码契约:自动重发开关经新路由拉取且默认关', () => {
|
|
40
|
+
assert.ok(CLIENT_SRC.includes("api(FORK_AUTO_RESEND_URL)"), '应经 fork-auto-resend-enabled 拉开关')
|
|
41
|
+
assert.ok(CLIENT_SRC.includes('setAutoResend(Boolean(payload && payload.enabled === true))'), '开关默认必须是关(严格 true 才开)')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('源码契约:autoSubmit 草稿消费时回填后经 prompt 提交', () => {
|
|
45
|
+
const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
|
|
46
|
+
assert.ok(consume.includes('inputActions.setDraft(text)'), '回填必须先行(prompt 前,失败也有兜底文本)')
|
|
47
|
+
assert.ok(consume.includes('submitRef.current({ sessionId, text })'), 'autoSubmit 时应经 prompt 通道提交')
|
|
48
|
+
assert.ok(consume.includes("toast('自动重发失败"), 'prompt 失败应 toast 且文本保留')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('源码契约:prompt 通道参数形态(requestId 幂等 + queue + text 段)', () => {
|
|
52
|
+
assert.ok(CLIENT_SRC.includes('requestId: (typeof crypto'), 'prompt 请求应携带幂等 requestId')
|
|
53
|
+
assert.ok(CLIENT_SRC.includes("content: [{ type: 'text', text }]"), 'prompt 内容应为 text 分段数组(宿主 hasPromptContent 校验形态)')
|
|
54
|
+
assert.ok(CLIENT_SRC.includes("mode: 'queue'"), 'prompt 应带 mode=queue(无进行中轮即开跑)')
|
|
55
|
+
assert.ok(CLIENT_SRC.includes('clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone'), 'prompt 应带本地时区(官方 client face 同形态)')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('源码契约:旧宿主无 prompt 时通道降级为 null(仅回填)', () => {
|
|
59
|
+
assert.ok(CLIENT_SRC.includes("(remoteSession && typeof remoteSession.prompt === 'function')"), 'prompt 通道应以能力探测守门(含面缺失)')
|
|
60
|
+
})
|