@mzzsfy/dsh-session-manager 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.
- package/README.md +82 -0
- package/cordis.patch.yml +12 -0
- package/package.json +51 -0
- package/src/client.js +835 -0
- package/src/core.mjs +190 -0
- package/src/history-cache.mjs +94 -0
- package/src/index.js +1069 -0
- package/src/trash.mjs +49 -0
- package/test/client-id.test.mjs +16 -0
- package/test/client-toast.test.mjs +89 -0
- package/test/core.test.mjs +307 -0
- package/test/index.test.mjs +1851 -0
- package/test/parity.test.mjs +160 -0
- package/test/trash.test.mjs +73 -0
package/src/core.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// 纯逻辑层:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分。
|
|
2
|
+
// 全部为无副作用纯函数,host 与测试共用。
|
|
3
|
+
|
|
4
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
5
|
+
export const DEFAULT_AUTO_ARCHIVE_DAYS = 7
|
|
6
|
+
export const DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS = 24
|
|
7
|
+
|
|
8
|
+
/** 更新时间 = max(createdAt, 最近活跃时间)。 */
|
|
9
|
+
export function updatedAtOf(header, activityAtMs) {
|
|
10
|
+
return Math.max(header.createdAt, activityAtMs ?? 0)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 归档评估状态机:按阈值筛出待归档会话 id。
|
|
15
|
+
* @param records - 候选行 {id, archived, running, blank, updatedAt}
|
|
16
|
+
* @returns 超期且未归档、非运行中、非空白的会话 id
|
|
17
|
+
*/
|
|
18
|
+
export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
|
|
19
|
+
if (!Number.isFinite(thresholdDays) || thresholdDays <= 0) return []
|
|
20
|
+
const cutoff = nowMs - thresholdDays * DAY_MS
|
|
21
|
+
return records
|
|
22
|
+
.filter((item) => !item.archived && !item.running && !item.blank && item.updatedAt < cutoff)
|
|
23
|
+
.map((item) => item.id)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 归档面板行:session.list 行与归档集合的交集,按更新时间倒序;标题缺失回退会话 id(与 client 镜像同规)。 */
|
|
27
|
+
export function projectArchiveRows({ rows, archivedIds }) {
|
|
28
|
+
const archived = new Set(archivedIds)
|
|
29
|
+
return rows
|
|
30
|
+
.filter((row) => archived.has(row.id))
|
|
31
|
+
.map((row) => ({ id: row.id, title: row.title || row.id, updatedAt: row.updatedAt }))
|
|
32
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 归档集合差分:previous 缺省(基线首帧)不产生提示。 */
|
|
36
|
+
export function diffArchived(previousIds, nextIds) {
|
|
37
|
+
if (previousIds === undefined) return []
|
|
38
|
+
const previous = new Set(previousIds)
|
|
39
|
+
return nextIds.filter((id) => !previous.has(id))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Toast 差分步进:连续两个 ready 快照才计新增。
|
|
44
|
+
* 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
|
|
45
|
+
* 归档事件,任一单帧都无法与事件区分,故以「上一帧也是 ready」为差分启用条件,
|
|
46
|
+
* 启动与重连首装不误报,重连基线携带的离期新增仍会提示。
|
|
47
|
+
* client.js 有一份镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
48
|
+
*/
|
|
49
|
+
export function archiveToastStep(previous, snapshot) {
|
|
50
|
+
const ready = Boolean(snapshot && snapshot.phase === 'ready')
|
|
51
|
+
const ids = (snapshot && snapshot.archivedSessionIds) || []
|
|
52
|
+
const added = previous !== undefined && previous.ready && ready ? diffArchived(previous.ids, ids) : []
|
|
53
|
+
return { state: { ready, ids }, added }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
|
|
57
|
+
export function deleteEligibility({ archivedIds, sessionId }) {
|
|
58
|
+
return archivedIds.includes(sessionId)
|
|
59
|
+
? { ok: true }
|
|
60
|
+
: { ok: false, code: 'not-archived' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 运行中判定:agent 注册表 status 为 running 即运行中,与归档评估同款判据。 */
|
|
64
|
+
export function isSessionRunning({ agents, sessionId }) {
|
|
65
|
+
const entry = agents ? agents.get(sessionId) : undefined
|
|
66
|
+
return Boolean(entry && entry.status === 'running')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 删除半失败聚合文案:trash 成功后各收尾失败点的主文案与后缀,host 响应逐键消费
|
|
70
|
+
export const DELETE_MESSAGES = {
|
|
71
|
+
partial: '已移入回收站,但移除列表记录失败',
|
|
72
|
+
archiveCleanup: '已移入回收站,但移除归档记录失败',
|
|
73
|
+
ledgerFailed: '已移入回收站,但重挂载记录失败',
|
|
74
|
+
ledgerSuffix: ',且重挂载记录失败',
|
|
75
|
+
runningDuringTrash: '警告:回收期间会话恢复运行,产物已移入回收站;建议检查会话状态',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 删除收尾半失败聚合:trash 成功后的失败矩阵折叠为单一响应体。
|
|
80
|
+
* 主文案优先级 detach → 归档清理 → 台账,台账失败以后缀并入前两者,运行中翻转
|
|
81
|
+
* 以警告后缀并入一切形态。三形态逐键一致:{ok} / {ok,message} / {ok,partial,message}。
|
|
82
|
+
*/
|
|
83
|
+
export function aggregateDeleteOutcome({ detachFailed = false, archiveCleanupFailed = false, ledgerFailed = false, runningDuringTrash = false }) {
|
|
84
|
+
const warningSuffix = runningDuringTrash ? ';' + DELETE_MESSAGES.runningDuringTrash : ''
|
|
85
|
+
const ledgerSuffix = ledgerFailed ? DELETE_MESSAGES.ledgerSuffix : ''
|
|
86
|
+
if (detachFailed) return { ok: true, partial: true, message: DELETE_MESSAGES.partial + ledgerSuffix + warningSuffix }
|
|
87
|
+
if (archiveCleanupFailed) return { ok: true, partial: true, message: DELETE_MESSAGES.archiveCleanup + ledgerSuffix + warningSuffix }
|
|
88
|
+
if (ledgerFailed) return { ok: true, partial: true, message: DELETE_MESSAGES.ledgerFailed + warningSuffix }
|
|
89
|
+
return runningDuringTrash
|
|
90
|
+
? { ok: true, message: DELETE_MESSAGES.runningDuringTrash }
|
|
91
|
+
: { ok: true }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 空白产物判定:读到 EOF 且换行数不足两行,即仅 header(或空)视为空白。 */
|
|
95
|
+
export function artifactLooksBlank(headText, hasMore) {
|
|
96
|
+
let breaks = 0
|
|
97
|
+
for (const char of headText) {
|
|
98
|
+
if (char === '\n') breaks += 1
|
|
99
|
+
if (breaks >= 2) return false
|
|
100
|
+
}
|
|
101
|
+
return !hasMore && breaks < 2
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 台账合并:同 id 替换,新条目置顶;返回新数组,不改入参。client.js 有镜像,修改需两处同步。 */
|
|
105
|
+
export function mergeDeletedEntry(deleted, entry) {
|
|
106
|
+
return [entry, ...deleted.filter((item) => item.sessionId !== entry.sessionId)]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 台账移除:幂等;removed 标记是否发生变化,调用方据此决定是否写回。 */
|
|
110
|
+
export function removeDeletedEntry(deleted, sessionId) {
|
|
111
|
+
const next = deleted.filter((item) => item.sessionId !== sessionId)
|
|
112
|
+
return { deleted: next, removed: next.length !== deleted.length }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 已删除面板行:标题取会话行展示标题,缺失回退会话 id,按删除时间倒序。client.js 有镜像,修改需两处同步。 */
|
|
116
|
+
export function projectDeletedRows(deleted, sessionsById) {
|
|
117
|
+
const byId = sessionsById || {}
|
|
118
|
+
return [...deleted]
|
|
119
|
+
.sort((left, right) => right.deletedAt - left.deletedAt)
|
|
120
|
+
.map((item) => ({
|
|
121
|
+
sessionId: item.sessionId,
|
|
122
|
+
path: item.path,
|
|
123
|
+
deletedAt: item.deletedAt,
|
|
124
|
+
title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 历史输入回溯参数:host 聚合路由使用(client 单文件自包含不经此,展示侧另行内联)。
|
|
129
|
+
// 范围按 ←/→ 切换序排列:索引 0 为常用提示词(个人收藏),索引 1 为当前会话
|
|
130
|
+
// (浮层默认落点),→ 向更大范围,← 返回收藏
|
|
131
|
+
// 对齐 = 后台解压范围内会话产物与持久缓存合并;两次对齐最小间隔防持续解压。
|
|
132
|
+
// 解压是同步 CPU 操作会阻塞主循环:启动零解压(历史直接读磁盘缓存),
|
|
133
|
+
// 全量对齐延迟 STARTUP_DELAY 再跑、只回溯最近 STARTUP_SCAN 个会话、
|
|
134
|
+
// 跳过超 MAX_ARTIFACT 的巨产物(单次 readSession 内部不可让出,巨产物一解卡死主循环);
|
|
135
|
+
// 对齐中连续解压占用超 SLICE 即让出 YIELD。
|
|
136
|
+
// 运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效
|
|
137
|
+
export const HISTORY_SESSION_SCAN_LIMIT = 20
|
|
138
|
+
export const HISTORY_INPUT_LIMIT = 200
|
|
139
|
+
export const HISTORY_INPUT_MAX_CHARS = 20 * 1000
|
|
140
|
+
export const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
|
|
141
|
+
export const HISTORY_PROMPTS_MAX = 100
|
|
142
|
+
export const HISTORY_ALIGN_THROTTLE_MS = 30 * 1000
|
|
143
|
+
export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
|
|
144
|
+
export const HISTORY_STARTUP_SCAN_LIMIT = 100
|
|
145
|
+
export const HISTORY_ALIGN_SLICE_MS = 200
|
|
146
|
+
export const HISTORY_ALIGN_YIELD_MS = 100
|
|
147
|
+
export const HISTORY_ALIGN_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
|
|
148
|
+
// 会话范围路由触发焦点对齐后就地等待的时限:首条数据可见预算(3s)内
|
|
149
|
+
// 留出解压+读盘份额,超时改走轮询流式补齐
|
|
150
|
+
export const HISTORY_FOCUS_WAIT_MS = 2 * 1000
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 从会话事件流提取人类输入:仅 user/message 且来源为用户本人,
|
|
154
|
+
* 文本块按行拼接;工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
|
|
155
|
+
* @param events - readSession 返回的原始事件数组
|
|
156
|
+
* @returns 条目 { text, at },时间取事件时间
|
|
157
|
+
*/
|
|
158
|
+
export function extractUserInputs(events) {
|
|
159
|
+
const entries = []
|
|
160
|
+
for (const event of events || []) {
|
|
161
|
+
if (!event || event.type !== 'user/message') continue
|
|
162
|
+
const message = event.data
|
|
163
|
+
if (!message || !message.source || message.source.kind !== 'user') continue
|
|
164
|
+
const text = (message.content || [])
|
|
165
|
+
.filter((block) => block && block.type === 'text' && block.text !== '')
|
|
166
|
+
.map((block) => block.text)
|
|
167
|
+
.join('\n')
|
|
168
|
+
if (text.trim() === '') continue
|
|
169
|
+
entries.push({ text, at: event.time })
|
|
170
|
+
}
|
|
171
|
+
return entries
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 历史输入聚合:精确文本去重保留最新时间,时间倒序,截 limit 条,单条截 maxChars 字符。
|
|
175
|
+
* 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。 */
|
|
176
|
+
export function aggregateInputs(entries, { limit, maxChars }) {
|
|
177
|
+
const latestByText = new Map()
|
|
178
|
+
for (const entry of entries || []) {
|
|
179
|
+
const known = latestByText.get(entry.text)
|
|
180
|
+
if (known === undefined || entry.at > known.at) latestByText.set(entry.text, entry)
|
|
181
|
+
}
|
|
182
|
+
return [...latestByText.values()]
|
|
183
|
+
.sort((left, right) => right.at - left.at)
|
|
184
|
+
.slice(0, limit)
|
|
185
|
+
.map((entry) => ({
|
|
186
|
+
text: entry.text.slice(0, maxChars),
|
|
187
|
+
at: entry.at,
|
|
188
|
+
...(entry.sid !== undefined ? { sid: entry.sid } : {}),
|
|
189
|
+
}))
|
|
190
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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, writeFile, rename } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
function workspaceFile(dir, cwd) {
|
|
13
|
+
const leaf = cwd.split(/[\\/]/).filter(Boolean).pop() || 'root'
|
|
14
|
+
const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 8)
|
|
15
|
+
return join(dir, `${leaf}-${hash}.json`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ensureCacheDir(dir) {
|
|
19
|
+
return mkdir(dir, { recursive: true })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalize(parsed, cwd) {
|
|
23
|
+
if (!parsed || parsed.cwd !== cwd || !Array.isArray(parsed.entries)) return null
|
|
24
|
+
return {
|
|
25
|
+
cwd,
|
|
26
|
+
entries: parsed.entries,
|
|
27
|
+
extracts: parsed.extracts && typeof parsed.extracts === 'object' ? parsed.extracts : {},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readWorkspaceCache(dir, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return normalize(JSON.parse(await readFile(workspaceFile(dir, cwd), 'utf8')), cwd)
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 原子写:先写临时文件再改名,进程中断不会留下半截 JSON
|
|
40
|
+
export async function writeWorkspaceCache(dir, cwd, cache) {
|
|
41
|
+
await ensureCacheDir(dir)
|
|
42
|
+
const target = workspaceFile(dir, cwd)
|
|
43
|
+
const tmp = target + '.tmp'
|
|
44
|
+
await writeFile(tmp, JSON.stringify({ cwd, entries: cache.entries, extracts: cache.extracts }, null, 0), 'utf8')
|
|
45
|
+
await rename(tmp, target)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listWorkspaceCaches(dir) {
|
|
49
|
+
let names
|
|
50
|
+
try {
|
|
51
|
+
names = await readdir(dir)
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
const caches = await Promise.all(names
|
|
56
|
+
.filter((name) => name.endsWith('.json') && name !== PROMPTS_FILE)
|
|
57
|
+
.map(async (name) => {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(await readFile(join(dir, name), 'utf8'))
|
|
60
|
+
if (!parsed || typeof parsed.cwd !== 'string' || !Array.isArray(parsed.entries)) return null
|
|
61
|
+
return parsed
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}))
|
|
66
|
+
return caches.filter((cache) => cache && typeof cache.cwd === 'string' && Array.isArray(cache.entries))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 常用提示词:全局单文件(不按工作区),记录用户显式收藏的输入文本。
|
|
70
|
+
// 收藏是用户意志,与自动对齐的历史缓存互不覆写
|
|
71
|
+
const PROMPTS_FILE = 'prompts.json'
|
|
72
|
+
|
|
73
|
+
function normalizePrompts(parsed) {
|
|
74
|
+
if (!parsed || !Array.isArray(parsed.items)) return []
|
|
75
|
+
return parsed.items
|
|
76
|
+
.filter((item) => item && typeof item.text === 'string' && item.text.length > 0)
|
|
77
|
+
.map((item) => ({ text: item.text, at: typeof item.at === 'number' ? item.at : 0 }))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function readPrompts(dir) {
|
|
81
|
+
try {
|
|
82
|
+
return normalizePrompts(JSON.parse(await readFile(join(dir, PROMPTS_FILE), 'utf8')))
|
|
83
|
+
} catch {
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function writePrompts(dir, items) {
|
|
89
|
+
await ensureCacheDir(dir)
|
|
90
|
+
const target = join(dir, PROMPTS_FILE)
|
|
91
|
+
const tmp = target + '.tmp'
|
|
92
|
+
await writeFile(tmp, JSON.stringify({ items }, null, 0), 'utf8')
|
|
93
|
+
await rename(tmp, target)
|
|
94
|
+
}
|