@chenkai114/dsh-team-admin 0.2.1 → 0.2.2
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/package.json +2 -2
- package/src/api.mjs +8 -1
- package/src/filter.mjs +17 -0
- package/src/index.js +57 -26
- package/src/legacy.mjs +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chenkai114/dsh-team-admin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DSH 插件:GUI 管理任意会话的智能体团队 —— 查看成员、删除角色并清理其持久会话(进回收站,可恢复)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -55,4 +55,4 @@
|
|
|
55
55
|
"test:real": "node tests/e2e/real-data.mjs",
|
|
56
56
|
"test:pack": "npm pack --dry-run"
|
|
57
57
|
}
|
|
58
|
-
}
|
|
58
|
+
}
|
package/src/api.mjs
CHANGED
|
@@ -80,7 +80,14 @@ export function createApi({ ctx, getStore, version, logSystem, services }) {
|
|
|
80
80
|
if (path === '/api/team-admin/health') {
|
|
81
81
|
if (method !== 'GET') { sendJson(res, 405, { error: '请求方法不支持' }); return }
|
|
82
82
|
const budget = typeof services.readRosterBudget === 'function' ? services.readRosterBudget() : null
|
|
83
|
-
sendJson(res, 200, {
|
|
83
|
+
sendJson(res, 200, {
|
|
84
|
+
ok: true,
|
|
85
|
+
name: 'team-admin',
|
|
86
|
+
version,
|
|
87
|
+
maxMembers: budget === null ? null : budget.maxMembers,
|
|
88
|
+
reclaimed: budget === null ? 0 : budget.reclaimed,
|
|
89
|
+
teams: budget === null ? {} : budget.teams,
|
|
90
|
+
})
|
|
84
91
|
return
|
|
85
92
|
}
|
|
86
93
|
|
package/src/filter.mjs
CHANGED
|
@@ -63,3 +63,20 @@ export function collectDismissedIds(state) {
|
|
|
63
63
|
}
|
|
64
64
|
return ids
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* 按 Lead 会话分组收集已删除成员 id。
|
|
68
|
+
* @param {object|null} state - store.readAll() 的返回值。
|
|
69
|
+
* @returns {Map<string, Set<string>>} lead 会话 id → 该团队已删除成员 id。
|
|
70
|
+
*/
|
|
71
|
+
export function collectDismissedByTeam(state) {
|
|
72
|
+
const teams = new Map()
|
|
73
|
+
const buckets = state !== null && typeof state === 'object' && state.dismissed !== null && typeof state.dismissed === 'object'
|
|
74
|
+
? state.dismissed
|
|
75
|
+
: {}
|
|
76
|
+
for (const [leadId, bucket] of Object.entries(buckets)) {
|
|
77
|
+
if (bucket === null || typeof bucket !== 'object') continue
|
|
78
|
+
const ids = new Set(Object.keys(bucket))
|
|
79
|
+
if (ids.size > 0) teams.set(leadId, ids)
|
|
80
|
+
}
|
|
81
|
+
return teams
|
|
82
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,8 @@ import { join } from 'node:path'
|
|
|
6
6
|
|
|
7
7
|
import { createApi } from './api.mjs'
|
|
8
8
|
import { dismissMember } from './dismiss.mjs'
|
|
9
|
-
import { collectDismissedIds, stripDismissedMembers } from './filter.mjs'
|
|
9
|
+
import { collectDismissedByTeam, collectDismissedIds, stripDismissedMembers } from './filter.mjs'
|
|
10
|
+
import { createLegacyDecorator } from './legacy.mjs'
|
|
10
11
|
import { openStore } from './store.mjs'
|
|
11
12
|
import { listTeamSessions, readTeam } from './team-read.mjs'
|
|
12
13
|
|
|
@@ -21,15 +22,16 @@ const ROUTE_PREFIX = '/api/team-admin'
|
|
|
21
22
|
const DISMISSED_TTL_MS = 2000
|
|
22
23
|
/** 包装标记:apply 与延迟注入可能都执行一次,避免重复包装。 */
|
|
23
24
|
const FILTER_MARK = Symbol.for('dsh.team-admin.projection-filter')
|
|
24
|
-
/** roster
|
|
25
|
+
/** roster 预算标记、刷新句柄与统计读取。 */
|
|
25
26
|
const ROSTER_MARK = Symbol.for('dsh.team-admin.roster-budget')
|
|
26
27
|
const ROSTER_REFRESH = Symbol.for('dsh.team-admin.roster-refresh')
|
|
28
|
+
const ROSTER_STATS = Symbol.for('dsh.team-admin.roster-stats')
|
|
27
29
|
/** 单值出口:一次请求/一次流的快照。 */
|
|
28
30
|
const UNARY_METHODS = ['list', 'projections']
|
|
29
31
|
/** 流出口:control 基线携带全部投影,follow 携带单个会话的开场快照。 */
|
|
30
32
|
const STREAM_METHODS = ['control', 'follow']
|
|
31
33
|
/** 与 package.json 版本保持一致(发布前由测试校验)。 */
|
|
32
|
-
const PLUGIN_VERSION = '0.2.
|
|
34
|
+
const PLUGIN_VERSION = '0.2.2'
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* 解析插件数据目录。
|
|
@@ -62,12 +64,15 @@ export function apply(ctx) {
|
|
|
62
64
|
const logSystem = (line) => {
|
|
63
65
|
if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(line)
|
|
64
66
|
}
|
|
67
|
+
const decorateList = createLegacyDecorator()
|
|
65
68
|
const ensureBudget = () => installRosterBudget(ctx, { getStore, logSystem, silent: true })
|
|
66
|
-
//
|
|
69
|
+
// 诊断用:读当前生效的成员上限与回收结果(按团队)。
|
|
67
70
|
const readRosterBudget = () => {
|
|
68
71
|
const service = typeof ctx.get === 'function' ? ctx.get('agentTeams') : undefined
|
|
69
72
|
const roster = service === null || service === undefined ? undefined : service.roster
|
|
70
|
-
|
|
73
|
+
if (roster === undefined || roster === null) return null
|
|
74
|
+
if (typeof roster[ROSTER_STATS] === 'function') return roster[ROSTER_STATS]()
|
|
75
|
+
return typeof roster.maxMembers === 'number' ? { maxMembers: roster.maxMembers, reclaimed: 0, teams: {} } : null
|
|
71
76
|
}
|
|
72
77
|
const handler = createApi({
|
|
73
78
|
ctx,
|
|
@@ -78,7 +83,7 @@ export function apply(ctx) {
|
|
|
78
83
|
listTeamSessions,
|
|
79
84
|
readTeam,
|
|
80
85
|
dismissMember,
|
|
81
|
-
//
|
|
86
|
+
// 删除成功后按团队重算名额。
|
|
82
87
|
onMemberDismissed: () => { void ensureBudget()?.() },
|
|
83
88
|
readRosterBudget,
|
|
84
89
|
},
|
|
@@ -91,11 +96,12 @@ export function apply(ctx) {
|
|
|
91
96
|
if (typeof ctx.inject === 'function') {
|
|
92
97
|
ctx.inject(['agentTeams'], (sub) => installRosterBudget(sub, { getStore, logSystem }))
|
|
93
98
|
}
|
|
94
|
-
if (!installSessionListFilter(ctx, { getStore, logSystem, silent: true }) && typeof ctx.inject === 'function') {
|
|
99
|
+
if (!installSessionListFilter(ctx, { getStore, logSystem, decorateList, silent: true }) && typeof ctx.inject === 'function') {
|
|
95
100
|
// 插件可能早于 sessionController 注册;等服务可用后再包一次。
|
|
96
|
-
ctx.inject(['sessionController'], (sub) => installSessionListFilter(sub, { getStore, logSystem }))
|
|
101
|
+
ctx.inject(['sessionController'], (sub) => installSessionListFilter(sub, { getStore, logSystem, decorateList }))
|
|
97
102
|
}
|
|
98
103
|
}
|
|
104
|
+
|
|
99
105
|
/**
|
|
100
106
|
* 建一个带短缓存的墓碑 id 读取器。
|
|
101
107
|
* @param {() => Promise<object>} getStore - 惰性打开的状态存储。
|
|
@@ -119,14 +125,14 @@ function createDismissedIds(getStore, logSystem) {
|
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
/**
|
|
122
|
-
* 给宿主的 sessionController 出口包一层:把已删除成员从 subagentCatalog / agentTeam.members
|
|
123
|
-
*
|
|
124
|
-
*
|
|
128
|
+
* 给宿主的 sessionController 出口包一层:把已删除成员从 subagentCatalog / agentTeam.members 里摘掉,
|
|
129
|
+
* 并给老格式会话补标题/空白标记。顶部选择器与团队面板读 `list` 与 `control` 基线,
|
|
130
|
+
* 在宿主出口处理一次即可覆盖所有客户端,不需要改宿主源码,也不用重新编译。
|
|
125
131
|
* @param {object} ctx - Cordis 插件上下文。
|
|
126
|
-
* @param {{ getStore: () => Promise<object>, logSystem: (line: string) => void, silent?: boolean }} deps -
|
|
132
|
+
* @param {{ getStore: () => Promise<object>, logSystem: (line: string) => void, decorateList?: (value: object) => object, silent?: boolean }} deps - 存储、日志与列表补全器。
|
|
127
133
|
* @returns {boolean} 至少包上一个方法时返回 true。
|
|
128
134
|
*/
|
|
129
|
-
export function installSessionListFilter(ctx, { getStore, logSystem, silent = false }) {
|
|
135
|
+
export function installSessionListFilter(ctx, { getStore, logSystem, decorateList, silent = false }) {
|
|
130
136
|
const get = ctx !== null && typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : null
|
|
131
137
|
const service = get === null ? undefined : get('sessionController')
|
|
132
138
|
if (service === null || service === undefined) {
|
|
@@ -149,7 +155,8 @@ export function installSessionListFilter(ctx, { getStore, logSystem, silent = fa
|
|
|
149
155
|
const original = service[method]
|
|
150
156
|
if (typeof original !== 'function') continue
|
|
151
157
|
service[method] = async function teamAdminFilteredUnary(...args) {
|
|
152
|
-
|
|
158
|
+
const value = await strip(await original.apply(this, args))
|
|
159
|
+
return method === 'list' && typeof decorateList === 'function' ? decorateList(value) : value
|
|
153
160
|
}
|
|
154
161
|
wrapped += 1
|
|
155
162
|
}
|
|
@@ -170,11 +177,12 @@ export function installSessionListFilter(ctx, { getStore, logSystem, silent = fa
|
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
180
|
+
* 把「已删除成员」占掉的名额还给**它所在的那个团队**:宿主的 `TeamRoster.spawnAdmitted`
|
|
181
|
+
* 用 `journal.state(root).members.length >= this.maxMembers` 判断,roster 只有一个上限字段。
|
|
182
|
+
* 这里在每次 spawn 前按 caller 的 Lead 设置本次生效的额外名额,spawn 结束清零。
|
|
175
183
|
* @param {object} ctx - Cordis 插件上下文。
|
|
176
184
|
* @param {{ getStore: () => Promise<object>, logSystem: (line: string) => void, silent?: boolean }} deps - 存储与日志。
|
|
177
|
-
* @returns {null | (() => Promise<number>)}
|
|
185
|
+
* @returns {null | (() => Promise<number>)} 刷新名额的函数;宿主没有 agentTeams 时返回 null。
|
|
178
186
|
*/
|
|
179
187
|
export function installRosterBudget(ctx, { getStore, logSystem, silent = false }) {
|
|
180
188
|
const get = ctx !== null && typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : null
|
|
@@ -186,22 +194,45 @@ export function installRosterBudget(ctx, { getStore, logSystem, silent = false }
|
|
|
186
194
|
}
|
|
187
195
|
if (roster[ROSTER_MARK] === true) return roster[ROSTER_REFRESH]
|
|
188
196
|
const base = roster.maxMembers
|
|
189
|
-
const
|
|
197
|
+
const extras = new Map()
|
|
198
|
+
const live = { current: 0, total: 0 }
|
|
190
199
|
Object.defineProperty(roster, 'maxMembers', {
|
|
191
200
|
configurable: true,
|
|
192
201
|
enumerable: true,
|
|
193
|
-
get: () => base +
|
|
202
|
+
get: () => base + live.current,
|
|
194
203
|
})
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
const originalSpawn = roster.spawn
|
|
205
|
+
if (typeof originalSpawn === 'function') {
|
|
206
|
+
roster.spawn = async function teamAdminScopedSpawn(caller, request) {
|
|
207
|
+
let extra = 0
|
|
208
|
+
try { extra = extras.get(this.membership(caller).root.id) ?? 0 } catch (error) { void error }
|
|
209
|
+
live.current = extra
|
|
210
|
+
try {
|
|
211
|
+
return await originalSpawn.call(this, caller, request)
|
|
212
|
+
} finally {
|
|
213
|
+
live.current = 0
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const refresh = async () => {
|
|
197
218
|
try {
|
|
198
219
|
const store = await getStore()
|
|
199
|
-
|
|
220
|
+
const teams = collectDismissedByTeam(await store.readAll())
|
|
221
|
+
extras.clear()
|
|
222
|
+
live.total = 0
|
|
223
|
+
for (const [leadId, ids] of teams) {
|
|
224
|
+
extras.set(leadId, ids.size)
|
|
225
|
+
live.total += ids.size
|
|
226
|
+
}
|
|
227
|
+
return live.total
|
|
200
228
|
} catch (error) {
|
|
201
229
|
logSystem(`team-admin:读取墓碑失败,名额不回收(${error instanceof Error ? error.message : String(error)})`)
|
|
230
|
+
return 0
|
|
202
231
|
}
|
|
203
|
-
return budget.extra
|
|
204
232
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
233
|
+
roster[ROSTER_MARK] = true
|
|
234
|
+
roster[ROSTER_REFRESH] = refresh
|
|
235
|
+
roster[ROSTER_STATS] = () => ({ maxMembers: base, reclaimed: live.total, teams: Object.fromEntries(extras) })
|
|
236
|
+
void refresh()
|
|
237
|
+
return refresh
|
|
238
|
+
}
|
package/src/legacy.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 老格式会话(session.jsonl.zstd / v1)没有当前的 title、sessionListMetadata 投影,
|
|
3
|
+
* 宿主只能按「无标题」渲染成未命名,也认不出空白草稿。这里按需读日志补齐:
|
|
4
|
+
* 标题取最后一条 session/title,空草稿标记 blank,让侧栏表现和当前格式一致。
|
|
5
|
+
*
|
|
6
|
+
* @module legacy
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
12
|
+
|
|
13
|
+
/** 已知的会话日志文件名,新到旧。 */
|
|
14
|
+
const LOG_NAMES = ['session.v4.jsonl.zstd', 'session.v3.jsonl.zstd', 'session.jsonl.zstd']
|
|
15
|
+
/** 默认缓存时长:日志很少变化,五分钟足够。 */
|
|
16
|
+
const DEFAULT_TTL_MS = 300000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 建一个惰性读老日志的补全器。
|
|
20
|
+
* @param {{ sessionsRoot?: string, ttlMs?: number }} [options] - 会话根目录与缓存时长。
|
|
21
|
+
* @returns {(value: object) => object} 就地补全 list 响应的函数。
|
|
22
|
+
*/
|
|
23
|
+
export function createLegacyDecorator(options = {}) {
|
|
24
|
+
const sessionsRoot = options.sessionsRoot ?? join(homedir(), '.dsh', 'sessions')
|
|
25
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
|
|
26
|
+
let indexAt = 0
|
|
27
|
+
let index = new Map()
|
|
28
|
+
const cache = new Map()
|
|
29
|
+
|
|
30
|
+
const rebuild = () => {
|
|
31
|
+
index = new Map()
|
|
32
|
+
let workspaces = []
|
|
33
|
+
try { workspaces = readdirSync(sessionsRoot) } catch { return }
|
|
34
|
+
for (const workspace of workspaces) {
|
|
35
|
+
const dir = join(sessionsRoot, workspace)
|
|
36
|
+
let names = []
|
|
37
|
+
try { names = readdirSync(dir) } catch { continue }
|
|
38
|
+
for (const name of names) if (name.startsWith('session-')) index.set(name, join(dir, name))
|
|
39
|
+
}
|
|
40
|
+
indexAt = Date.now()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const readInfo = (sessionId) => {
|
|
44
|
+
if (cache.has(sessionId)) return cache.get(sessionId)
|
|
45
|
+
if (Date.now() - indexAt > ttlMs) rebuild()
|
|
46
|
+
const dir = index.get(sessionId)
|
|
47
|
+
if (dir === undefined) return null
|
|
48
|
+
const file = LOG_NAMES.map((name) => join(dir, name)).find((path) => existsSync(path))
|
|
49
|
+
if (file === undefined) return null
|
|
50
|
+
let text = ''
|
|
51
|
+
try { text = zstdDecompressSync(readFileSync(file)).toString('utf8') } catch { return null }
|
|
52
|
+
let title = null
|
|
53
|
+
let hasUserMessage = false
|
|
54
|
+
let hasTurn = false
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
if (line === '') continue
|
|
57
|
+
if (line.includes('"session/title"')) {
|
|
58
|
+
try {
|
|
59
|
+
const event = JSON.parse(line)
|
|
60
|
+
const value = event?.data?.title
|
|
61
|
+
if (typeof value === 'string' && value.trim() !== '') title = value.trim()
|
|
62
|
+
} catch { /* 坏行忽略 */ }
|
|
63
|
+
}
|
|
64
|
+
if (line.includes('"turn/start"')) hasTurn = true
|
|
65
|
+
if (!hasUserMessage && line.includes('"role":"user"')) hasUserMessage = true
|
|
66
|
+
}
|
|
67
|
+
const info = { title, hasUserMessage, hasTurn }
|
|
68
|
+
cache.set(sessionId, info)
|
|
69
|
+
return info
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (value) => {
|
|
73
|
+
const items = Array.isArray(value?.items) ? value.items : []
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
if (item === null || typeof item !== 'object') continue
|
|
76
|
+
const sessionId = item.sessionId
|
|
77
|
+
if (typeof sessionId !== 'string') continue
|
|
78
|
+
const titled = typeof item.title === 'string' && item.title.trim() !== ''
|
|
79
|
+
// 新格式会话(有标题且非空白)不需要补
|
|
80
|
+
if (titled && item.blank === false) continue
|
|
81
|
+
const info = readInfo(sessionId)
|
|
82
|
+
if (info === null) continue
|
|
83
|
+
if (!titled && info.title !== null) item.title = info.title
|
|
84
|
+
if (info.hasTurn === false && info.hasUserMessage === false) item.blank = true
|
|
85
|
+
}
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
}
|