@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenkai114/dsh-team-admin",
3
- "version": "0.2.1",
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, { ok: true, name: 'team-admin', version, maxMembers: budget === null ? null : budget.maxMembers })
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.1'
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
- return roster !== undefined && roster !== null && typeof roster.maxMembers === 'number' ? { maxMembers: roster.maxMembers } : null
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
- * 顶部「子智能体」选择器读 `list` 与 `control` 基线,内置「智能体团队」面板读同一批投影,
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
- return await strip(await original.apply(this, args))
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
- * 把「已删除成员」占掉的名额还给 roster:宿主用 roster 的 append-only 成员数判断 maxMembers,
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>)} 刷新预算的函数;宿主没有 agentTeams 时返回 null。
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 budget = { extra: 0 }
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 + budget.extra,
202
+ get: () => base + live.current,
194
203
  })
195
- roster[ROSTER_MARK] = true
196
- roster[ROSTER_REFRESH] = async () => {
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
- budget.extra = collectDismissedIds(await store.readAll()).size
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
- void roster[ROSTER_REFRESH]()
206
- return roster[ROSTER_REFRESH]
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
+ }