@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/src/core.mjs ADDED
@@ -0,0 +1,200 @@
1
+ // 纯逻辑层:历史输入提取/聚合、subagent 排除、fork 锚点与错误文案。
2
+ // 全部为无副作用纯函数,host 与测试共用。
3
+
4
+ // 历史输入回溯参数:host 聚合路由使用(client 单文件自包含不经此,展示侧另行内联)。
5
+ // 范围按 ←/→ 切换序排列:索引 0 为常用提示词(个人收藏),索引 1 为当前会话
6
+ // (浮层默认落点),→ 向更大范围,← 返回收藏
7
+ // 对齐 = 后台解压范围内会话产物与持久缓存合并;两次对齐最小间隔防持续解压。
8
+ // 解压是 CPU 操作:启动零解压(历史直接读磁盘缓存),全量对齐延迟 STARTUP_DELAY
9
+ // 再跑、只回溯最近 STARTUP_SCAN 个会话;超 MAX_ARTIFACT 的巨产物跳过——
10
+ // 上限防的是解压耗时与事件对象内存峰值失控,非阻塞主循环;跳过线按宿主版本
11
+ // 黑名单取值(见 isLegacyDecompressHost),对齐中连续解压占用超 SLICE 即让出 YIELD。
12
+ // 运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效
13
+ // 扫描窗口按主会话计(subagent 已排除):放宽到与启动回溯一致——extracts 指纹
14
+ // 缓存使扩大窗口的持续成本仅限新会话的一次性解压,产物未变的会话零解压
15
+ export const HISTORY_SESSION_SCAN_LIMIT = 100
16
+ export const HISTORY_INPUT_LIMIT = 200
17
+ export const HISTORY_INPUT_MAX_CHARS = 20 * 1000
18
+ export const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
19
+ export const HISTORY_PROMPTS_MAX = 100
20
+ export const HISTORY_ALIGN_THROTTLE_MS = 30 * 1000
21
+ export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
22
+ export const HISTORY_STARTUP_SCAN_LIMIT = 100
23
+ export const HISTORY_ALIGN_SLICE_MS = 200
24
+ export const HISTORY_ALIGN_YIELD_MS = 100
25
+ // 巨产物跳过线(压缩字节数):现役最大产物约 3.5MiB。新解压线 16MiB 留 4 倍余量,
26
+ // 超线产物解压后可达数百 MB(压缩比约 1:3.4,事件对象再放大),内存峰值失控;
27
+ // 旧解压线 8MiB 面向 0.1.1/0.1.2 黑名单宿主——其解压实现尚无周期让出,从严
28
+ export const HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
29
+ export const HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
30
+ // 与官方 semver 语义对齐的宽松校验:黑名单判定只取主/次/修订号,prerelease 无关
31
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/
32
+ // 旧解压实现黑名单:仅 0.1.1 与 0.1.2 系列(含各 prerelease);0.1.5 实测
33
+ // readZstdPrefix 逐帧解压 + 周期让出。版本非法/缺失按黑名单保守回退旧线
34
+ export function isLegacyDecompressHost(version) {
35
+ const match = SEMVER_PATTERN.exec(typeof version === 'string' ? version.trim() : '')
36
+ if (!match) return true
37
+ const major = Number(match[1])
38
+ const minor = Number(match[2])
39
+ const patch = Number(match[3])
40
+ return major === 0 && minor === 1 && (patch === 1 || patch === 2)
41
+ }
42
+ export function maxArtifactBytesForHost(version) {
43
+ return isLegacyDecompressHost(version) ? HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES : HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES
44
+ }
45
+ // 会话范围路由触发焦点对齐后就地等待的时限:首条数据可见预算(3s)内
46
+ // 留出解压+读盘份额,超时改走轮询流式补齐
47
+ export const HISTORY_FOCUS_WAIT_MS = 2 * 1000
48
+
49
+ /** 运行中判定:agent 注册表 status 为 running 即运行中。 */
50
+ export function isSessionRunning({ agents, sessionId }) {
51
+ const entry = agents ? agents.get(sessionId) : undefined
52
+ return Boolean(entry && entry.status === 'running')
53
+ }
54
+
55
+ /**
56
+ * 从会话事件流提取人类输入:仅 user/message 且来源为用户本人,
57
+ * 文本块按行拼接;工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
58
+ * @param events - readSession 返回的原始事件数组
59
+ * @returns 条目 { text, at },时间取事件时间
60
+ */
61
+ export function extractUserInputs(events) {
62
+ const entries = []
63
+ for (const event of events || []) {
64
+ if (!event || event.type !== 'user/message') continue
65
+ const message = event.data
66
+ if (!message || !message.source || message.source.kind !== 'user') continue
67
+ const text = (message.content || [])
68
+ .filter((block) => block && block.type === 'text' && block.text !== '')
69
+ .map((block) => block.text)
70
+ .join('\n')
71
+ if (text.trim() === '') continue
72
+ entries.push({ text, at: event.time })
73
+ }
74
+ return entries
75
+ }
76
+
77
+ /** 历史输入聚合:精确文本去重保留最新时间,时间倒序,截 limit 条,单条截 maxChars 字符。
78
+ * 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。
79
+ * 首条保护:被 limit 挤出时,每个来源会话在去重后集合中的最早条目仍保留——
80
+ * 会话开场提示词是最有复用价值也最旧的一批,时间倒序截断总会先挤掉它们;
81
+ * 保护在去重后集合上计算,文本已被更新的会话复用时不再按旧会话重复追加。 */
82
+ export function aggregateInputs(entries, { limit, maxChars }) {
83
+ const latestByText = new Map()
84
+ for (const entry of entries || []) {
85
+ const known = latestByText.get(entry.text)
86
+ if (known === undefined || entry.at > known.at) latestByText.set(entry.text, entry)
87
+ }
88
+ const earliestBySid = new Map()
89
+ for (const entry of latestByText.values()) {
90
+ if (entry.sid === undefined) continue
91
+ const known = earliestBySid.get(entry.sid)
92
+ if (known === undefined || entry.at < known.at) earliestBySid.set(entry.sid, entry)
93
+ }
94
+ const kept = [...latestByText.values()].sort((left, right) => right.at - left.at).slice(0, limit)
95
+ const keptTexts = new Set(kept.map((entry) => entry.text))
96
+ for (const first of earliestBySid.values()) {
97
+ if (!keptTexts.has(first.text)) kept.push(first)
98
+ }
99
+ return kept
100
+ .sort((left, right) => right.at - left.at)
101
+ .map((entry) => ({
102
+ text: entry.text.slice(0, maxChars),
103
+ at: entry.at,
104
+ ...(entry.sid !== undefined ? { sid: entry.sid } : {}),
105
+ }))
106
+ }
107
+
108
+ /**
109
+ * 历史浮层搜索:查询词去首尾空白后按不区分大小写子串匹配条目文本,
110
+ * 空查询原样返回全量。client.js 有镜像实现(单文件自包含无法跨文件 require),
111
+ * 修改需两处同步。
112
+ */
113
+ export function filterHistoryInputs(entries, query) {
114
+ const list = Array.isArray(entries) ? entries : []
115
+ const needle = String(query ?? '').trim().toLowerCase()
116
+ if (needle === '') return list
117
+ return list.filter((entry) => String((entry && entry.text) ?? '').toLowerCase().includes(needle))
118
+ }
119
+
120
+ // ── 对话 fork:锚点解析与错误文案(client 注入层消费的纯函数) ──
121
+
122
+ /**
123
+ * fork 错误文案映射:宿主 RemoteError code → 用户可读中文。
124
+ * @param code - SessionForkError 携带的 rpcError code,未知 code 原样透出
125
+ */
126
+ export function forkFailureText(code) {
127
+ if (code === 'session/fork-unavailable') return '该轮尚未完成,不可分叉'
128
+ if (code === 'session/workspace-attach-failed') return '分叉成功,但挂载到工作区失败'
129
+ return '分叉失败: ' + String(code ?? '未知错误')
130
+ }
131
+
132
+ /**
133
+ * 从一条 user/message 事件提取重试文本:仅来源为用户本人,文本块按行拼接;
134
+ * 空白或无文本块(纯图片等)返回 null——无法回填重试的轮不提供分叉按钮。
135
+ * client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
136
+ * @param data - user/message 事件 data(UserMessage 形状)
137
+ */
138
+ export function forkRetryText(data) {
139
+ const message = data && typeof data === 'object' ? data : {}
140
+ if (!message.source || message.source.kind !== 'user') return null
141
+ const text = (Array.isArray(message.content) ? message.content : [])
142
+ .filter((block) => block && block.type === 'text' && block.text !== '')
143
+ .map((block) => block.text)
144
+ .join('\n')
145
+ return text.trim() === '' ? null : text
146
+ }
147
+
148
+ /**
149
+ * 家族谱系投影:按 parentSessionId 把会话行组装成「根 → 后代」链,
150
+ * parentSessionId 断链(引用的父不在集合内)的行视为独立根。
151
+ * 编排派生会话(origin === 'subagent')不是用户的 fork 版本,整行剔除,
152
+ * 其后代随之断链成独立根——家族只统计用户可跳转的对话分支。
153
+ * @param items - sessions.list 行数组(至少含 sessionId/parentSessionId)
154
+ * @returns Map<sessionId, { root, chain: string[] }>——chain 为根到自身的 id 序列
155
+ */
156
+ export function sessionFamilyMap(items) {
157
+ const byId = new Map()
158
+ for (const item of Array.isArray(items) ? items : []) {
159
+ if (item && item.origin === 'subagent') continue
160
+ const id = item && (item.sessionId ?? item.id)
161
+ if (typeof id === 'string' && id !== '') byId.set(id, item)
162
+ }
163
+ const chains = new Map()
164
+ for (const id of byId.keys()) {
165
+ const chain = []
166
+ let cursor = id
167
+ let root = cursor
168
+ while (cursor !== undefined) {
169
+ chain.unshift(cursor)
170
+ root = cursor
171
+ const parent = byId.get(cursor)
172
+ cursor = parent && typeof parent.parentSessionId === 'string' && byId.has(parent.parentSessionId)
173
+ ? parent.parentSessionId
174
+ : undefined
175
+ if (chain.includes(cursor)) break
176
+ }
177
+ chains.set(id, { root, chain, item: byId.get(id) })
178
+ }
179
+ return chains
180
+ }
181
+
182
+ /**
183
+ * 家族环计数:某会话在所属家族中的序位与成员数(按 updatedAt 升序,与分叉先后一致)。
184
+ * 单成员家族返回 null(不渲染计数器)。
185
+ * @param chains - sessionFamilyMap 的结果
186
+ * @param sessionId - 目标会话
187
+ */
188
+ export function familyRing(chains, sessionId) {
189
+ const entry = chains instanceof Map ? chains.get(sessionId) : undefined
190
+ if (!entry) return null
191
+ const members = [...chains.entries()]
192
+ .filter(([, value]) => value.root === entry.root)
193
+ .sort((left, right) => {
194
+ const at = (record) => (record && record.item && typeof record.item.updatedAt === 'number' ? record.item.updatedAt : 0)
195
+ return at(left[1]) - at(right[1])
196
+ })
197
+ .map(([id]) => id)
198
+ if (members.length < 2) return null
199
+ return { index: members.indexOf(sessionId) + 1, total: members.length, members }
200
+ }
@@ -0,0 +1,128 @@
1
+ // 历史输入工作区缓存:每工作区一个 JSON 文件(~/.dsh/historyPrompt/<工作区>-<hash>.json),
2
+ // 记录该工作区聚合后的人类输入(带 sid 溯源)。浮层请求直接读文件(毫秒级)立即返回,
3
+ // 对齐在后台解压最新产物与缓存合并写回——解压成本不落在用户等待路径上。
4
+ // extracts 段持久化每会话的提取结果与其产物 stat 指纹(mtimeMs+size):
5
+ // 对齐时先 stat 比对指纹,产物没变的会话不再解压——判断完全基于磁盘,跨重启生效。
6
+ // 文件名 = 工作区路径末段(可辨认)+ 路径哈希前缀(防不同目录同名冲突)
7
+
8
+ import { createHash } from 'node:crypto'
9
+ import { mkdir, readFile, readdir, stat, writeFile, rename } from 'node:fs/promises'
10
+ import { dirname, join, sep } from 'node:path'
11
+
12
+ function workspaceFile(dir, cwd) {
13
+ // 末段取字母数字段(冒号等非法文件名字符剔除):盘根 C:\ 末段 'C:' 直出即非法文件名
14
+ const leaf = (cwd.match(/[A-Za-z0-9\u4e00-\u9fa5]+/g) || []).pop() || 'root'
15
+ const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 8)
16
+ return join(dir, `${leaf}-${hash}.json`)
17
+ }
18
+
19
+ export function ensureCacheDir(dir) {
20
+ return mkdir(dir, { recursive: true })
21
+ }
22
+
23
+ function normalize(parsed, cwd) {
24
+ if (!parsed || parsed.cwd !== cwd || !Array.isArray(parsed.entries)) return null
25
+ return {
26
+ cwd,
27
+ entries: parsed.entries,
28
+ extracts: parsed.extracts && typeof parsed.extracts === 'object' ? parsed.extracts : {},
29
+ }
30
+ }
31
+
32
+ export async function readWorkspaceCache(dir, cwd) {
33
+ try {
34
+ return normalize(JSON.parse(await readFile(workspaceFile(dir, cwd), 'utf8')), cwd)
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ // 原子写:先写临时文件再改名,进程中断不会留下半截 JSON
41
+ export async function writeWorkspaceCache(dir, cwd, cache) {
42
+ await ensureCacheDir(dir)
43
+ const target = workspaceFile(dir, cwd)
44
+ const tmp = target + '.tmp'
45
+ await writeFile(tmp, JSON.stringify({ cwd, entries: cache.entries, extracts: cache.extracts }, null, 0), 'utf8')
46
+ await rename(tmp, target)
47
+ }
48
+
49
+ // global 浮层轮询用指纹缓存:每 3s 轮询都全量读取并 JSON.parse 所有工作区缓存文件,
50
+ // 单文件 entries 可达数百条,持续打开浮层时是秒级重复大 IO+CPU。以 (mtimeMs,size)
51
+ // 指纹复用上次解析结果,产物未变的文件不再读盘;写入侧经临时文件改名,mtime 必变,
52
+ // 指纹天然失效。模块级 Map 以全路径为键(防多目录同名文件串扰),条目数有界。
53
+ const globalCachePollState = new Map()
54
+
55
+ export async function listWorkspaceCachesCached(dir) {
56
+ let names
57
+ try {
58
+ names = await readdir(dir)
59
+ } catch {
60
+ // 目录缺失/不可读:仅清本目录条目(分隔符锚定,防误清兄弟目录),条目自愈
61
+ for (const key of globalCachePollState.keys()) {
62
+ if (key.startsWith(dir + sep)) globalCachePollState.delete(key)
63
+ }
64
+ return []
65
+ }
66
+ const live = new Set()
67
+ const caches = await Promise.all(names
68
+ .filter((name) => name.endsWith('.json') && name !== PROMPTS_FILE)
69
+ .map(async (name) => {
70
+ const file = join(dir, name)
71
+ live.add(file)
72
+ let fingerprint
73
+ try {
74
+ const info = await stat(file)
75
+ fingerprint = { mtimeMs: info.mtimeMs, size: info.size }
76
+ } catch {
77
+ globalCachePollState.delete(file)
78
+ return null
79
+ }
80
+ const hit = globalCachePollState.get(file)
81
+ if (hit !== undefined && hit.fp.mtimeMs === fingerprint.mtimeMs && hit.fp.size === fingerprint.size) {
82
+ return hit.parsed
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(await readFile(file, 'utf8'))
86
+ if (!parsed || typeof parsed.cwd !== 'string' || !Array.isArray(parsed.entries)) {
87
+ globalCachePollState.delete(file)
88
+ return null
89
+ }
90
+ globalCachePollState.set(file, { fp: fingerprint, parsed })
91
+ return parsed
92
+ } catch {
93
+ globalCachePollState.delete(file)
94
+ return null
95
+ }
96
+ }))
97
+ for (const key of globalCachePollState.keys()) {
98
+ if (!live.has(key)) globalCachePollState.delete(key)
99
+ }
100
+ return caches.filter((cache) => cache && typeof cache.cwd === 'string' && Array.isArray(cache.entries))
101
+ }
102
+
103
+ // 常用提示词:全局单文件(不按工作区),记录用户显式收藏的输入文本。
104
+ // 收藏是用户意志,与自动对齐的历史缓存互不覆写
105
+ const PROMPTS_FILE = 'prompts.json'
106
+
107
+ function normalizePrompts(parsed) {
108
+ if (!parsed || !Array.isArray(parsed.items)) return []
109
+ return parsed.items
110
+ .filter((item) => item && typeof item.text === 'string' && item.text.length > 0)
111
+ .map((item) => ({ text: item.text, at: typeof item.at === 'number' ? item.at : 0 }))
112
+ }
113
+
114
+ export async function readPrompts(dir) {
115
+ try {
116
+ return normalizePrompts(JSON.parse(await readFile(join(dir, PROMPTS_FILE), 'utf8')))
117
+ } catch {
118
+ return []
119
+ }
120
+ }
121
+
122
+ export async function writePrompts(dir, items) {
123
+ await ensureCacheDir(dir)
124
+ const target = join(dir, PROMPTS_FILE)
125
+ const tmp = target + '.tmp'
126
+ await writeFile(tmp, JSON.stringify({ items }, null, 0), 'utf8')
127
+ await rename(tmp, target)
128
+ }