dsh-multi-session 0.3.4 → 0.5.3
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/CHANGELOG.md +56 -0
- package/README.md +37 -8
- package/assets/screenshot-board.jpg +0 -0
- package/assets/screenshot-board.png +0 -0
- package/client.js +1014 -57
- package/index.js +437 -5
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
12
13
|
import { dirname, join } from 'node:path'
|
|
13
|
-
import { tmpdir } from 'node:os'
|
|
14
|
+
import { homedir, tmpdir } from 'node:os'
|
|
15
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
14
16
|
|
|
15
17
|
export const name = 'dsh-multi-session'
|
|
16
18
|
|
|
@@ -57,7 +59,10 @@ const MAX_PROBES = 20
|
|
|
57
59
|
/** 会话对话摘要:面板里点开某个会话时由 Host 侧读真实事件日志再折叠。 */
|
|
58
60
|
const DIGEST_ROUTE = '/plugins/dsh-multi-session/digest'
|
|
59
61
|
const PROMPT_ROUTE = '/plugins/dsh-multi-session/prompt'
|
|
62
|
+
const SEARCH_ROUTE = '/plugins/dsh-multi-session/search'
|
|
60
63
|
const PROMPT_TIMEOUT_MS = 15000
|
|
64
|
+
// 提问工具停下等答案时,会话的 running 仍然是 true —— 光看 running 分不出来。
|
|
65
|
+
const ASK_TOOL = 'ask_user_question'
|
|
61
66
|
|
|
62
67
|
/**
|
|
63
68
|
* 每个会话的摘要缓存。readSurface 一次要 2.5 秒(整段日志重放),
|
|
@@ -137,6 +142,13 @@ function foldDigest(events, limit) {
|
|
|
137
142
|
let turns = 0
|
|
138
143
|
let userTurns = 0
|
|
139
144
|
let endReason = null
|
|
145
|
+
// 「在等你」判定用的状态:
|
|
146
|
+
// - 未决的审批(approval/asked 有、approval/decided 没有)
|
|
147
|
+
// - 未返回的提问工具(ask_user_question 的 tool/call 有、tool/result 没有)
|
|
148
|
+
const pendingCalls = new Map()
|
|
149
|
+
const openApprovals = new Set()
|
|
150
|
+
let lastWorkspaceSeq = 0
|
|
151
|
+
let lastWorkspaceTurn = 0
|
|
140
152
|
for (const event of Array.isArray(events) ? events : []) {
|
|
141
153
|
if (event === null || typeof event !== 'object') continue
|
|
142
154
|
const data = event.data !== null && typeof event.data === 'object' ? event.data : {}
|
|
@@ -155,11 +167,236 @@ function foldDigest(events, limit) {
|
|
|
155
167
|
if (text !== '') {
|
|
156
168
|
items.push({ kind: 'assistant', text: text.length > 3000 ? `${text.slice(0, 3000)}…` : text, at })
|
|
157
169
|
}
|
|
170
|
+
} else if (event.type === 'tool/call') {
|
|
171
|
+
const callId = String(data.callId ?? '')
|
|
172
|
+
const name = String(data.name ?? '')
|
|
173
|
+
if (callId !== '') pendingCalls.set(callId, { name, at })
|
|
174
|
+
} else if (event.type === 'tool/result') {
|
|
175
|
+
const callId = String(data.message?.toolCallId ?? '')
|
|
176
|
+
if (callId !== '') pendingCalls.delete(callId)
|
|
177
|
+
} else if (event.type === 'approval/asked') {
|
|
178
|
+
openApprovals.add(String(data.id ?? ''))
|
|
179
|
+
} else if (event.type === 'approval/decided') {
|
|
180
|
+
openApprovals.delete(String(data.id ?? ''))
|
|
181
|
+
} else if (event.type === 'workspace/changes') {
|
|
182
|
+
// seq 必须记住:workspaceChanges.summary() 只认这条事件的 seq。
|
|
183
|
+
lastWorkspaceSeq = typeof event.seq === 'number' ? event.seq : 0
|
|
184
|
+
lastWorkspaceTurn = Number(data.turn) || 0
|
|
158
185
|
}
|
|
159
|
-
// tool/call、tool/result
|
|
160
|
-
// 工具调用和它们的输出对"看清这段聊了什么"
|
|
186
|
+
// tool/call、tool/result 一律不搬进 items:并排视图只保留"我 ↔ AI 的对话正文",
|
|
187
|
+
// 工具调用和它们的输出对"看清这段聊了什么"是纯噪音。上面的状态跟踪只用于判定状态。
|
|
161
188
|
}
|
|
162
|
-
|
|
189
|
+
|
|
190
|
+
// 「在等你」的两种来源,任一成立即算:
|
|
191
|
+
// 1) 有未决的审批请求(approval/asked 没有对应的 approval/decided);
|
|
192
|
+
// 2) 最后一个工具调用是 ask_user_question 且还没返回结果。
|
|
193
|
+
let waiting = ''
|
|
194
|
+
if (openApprovals.size > 0) {
|
|
195
|
+
waiting = 'approval'
|
|
196
|
+
} else {
|
|
197
|
+
for (const call of pendingCalls.values()) {
|
|
198
|
+
if (call.name === ASK_TOOL) {
|
|
199
|
+
waiting = 'question'
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
items: items.slice(-limit),
|
|
207
|
+
turns: turns > 0 ? turns : userTurns,
|
|
208
|
+
endReason,
|
|
209
|
+
waiting,
|
|
210
|
+
workspaceSeq: lastWorkspaceSeq,
|
|
211
|
+
workspaceTurn: lastWorkspaceTurn,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 这一轮改了哪些文件。走 workspaceChanges.summary(),它只认 `workspace/changes`
|
|
217
|
+
* 事件的 seq —— 也就是说必须用 foldDigest 顺带记下来的那个 seq,传别的 seq 一律 undefined。
|
|
218
|
+
* 拿不到就返回 null,让客户端当成"没信息"而不是"改了 0 个文件"。
|
|
219
|
+
*/
|
|
220
|
+
function summarizeChanges(service, sessionId, folded) {
|
|
221
|
+
const seq = Number(folded?.workspaceSeq) || 0
|
|
222
|
+
if (seq <= 0) return null
|
|
223
|
+
if (service === undefined || service === null || typeof service.summary !== 'function') return null
|
|
224
|
+
let summary
|
|
225
|
+
try {
|
|
226
|
+
summary = service.summary(sessionId, seq)
|
|
227
|
+
} catch {
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
if (summary === null || typeof summary !== 'object') return null
|
|
231
|
+
const files = Array.isArray(summary.files) ? summary.files : []
|
|
232
|
+
return {
|
|
233
|
+
turn: Number(summary.turn) || 0,
|
|
234
|
+
total: Number(summary.total) || files.length,
|
|
235
|
+
added: Number(summary.added) || 0,
|
|
236
|
+
deleted: Number(summary.deleted) || 0,
|
|
237
|
+
files: files.slice(0, 40).map((file) => ({
|
|
238
|
+
path: String(file?.path ?? ''),
|
|
239
|
+
display: String(file?.display ?? file?.path ?? ''),
|
|
240
|
+
added: Number(file?.added) || 0,
|
|
241
|
+
deleted: Number(file?.deleted) || 0,
|
|
242
|
+
binary: file?.binary === true,
|
|
243
|
+
oversized: file?.oversized === true,
|
|
244
|
+
})),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 请求是不是从本机发起的。只用于开发期自测与 loopback 放行,
|
|
250
|
+
* 不参与任何业务鉴权判断(业务一律走 connection.requestRejection)。
|
|
251
|
+
*/
|
|
252
|
+
function isLoopback(req) {
|
|
253
|
+
const address = String(req?.socket?.remoteAddress ?? '')
|
|
254
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 逐个会话扫日志的兜底搜索。
|
|
259
|
+
*
|
|
260
|
+
* 为什么不用 sessionQuery.filterEvents:它每次要把整个会话完整重放一遍,
|
|
261
|
+
* 实测单个会话 3.35 秒(那个会话 33513 个事件)。扫 40 个会话要 30 多秒。
|
|
262
|
+
*
|
|
263
|
+
* 为什么不能直接 readFile 就得到文本:DSH 的会话日志是**多个 zstd 帧拼接**的
|
|
264
|
+
* (一个会话实测 281 帧),Node 的 zstdDecompressSync 只解第一帧就停,
|
|
265
|
+
* createZstdDecompress 流式也只解第一帧。必须按 magic 0x28B52FFD 切开逐帧解。
|
|
266
|
+
*
|
|
267
|
+
* 实测:1041KB 的文件解成 2744KB 只要 33ms,全文扫 31ms —— 快 50 倍。
|
|
268
|
+
*/
|
|
269
|
+
const SESSION_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
270
|
+
/** sessionId → 日志文件路径。目录结构很浅、文件不多,缓存一次就够。 */
|
|
271
|
+
const sessionFileCache = new Map()
|
|
272
|
+
|
|
273
|
+
function sessionsRoot() {
|
|
274
|
+
return join(homedir(), '.dsh', 'sessions')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 在 sessions/<分组>/<uuid>/session.*.jsonl* 里找这个会话的日志文件。 */
|
|
278
|
+
function findSessionFile(sessionId) {
|
|
279
|
+
const cached = sessionFileCache.get(sessionId)
|
|
280
|
+
if (cached !== undefined) return cached
|
|
281
|
+
const bare = String(sessionId).replace(/^session-/, '')
|
|
282
|
+
const root = sessionsRoot()
|
|
283
|
+
let found = null
|
|
284
|
+
let groups = []
|
|
285
|
+
try {
|
|
286
|
+
groups = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory())
|
|
287
|
+
} catch {
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
for (const group of groups) {
|
|
291
|
+
const dir = join(root, group.name, bare)
|
|
292
|
+
try {
|
|
293
|
+
const files = readdirSync(dir).filter((name) => name.startsWith('session.') && name.includes('jsonl'))
|
|
294
|
+
if (files.length > 0) {
|
|
295
|
+
found = join(dir, files[0])
|
|
296
|
+
break
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// 这个分组里没有,继续下一个。
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
sessionFileCache.set(sessionId, found)
|
|
303
|
+
return found
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 把多帧 zstd(或普通 jsonl)解成 UTF-8 文本。解不动的帧跳过,不让整次搜索失败。 */
|
|
307
|
+
function readSessionLogText(file) {
|
|
308
|
+
const raw = readFileSync(file)
|
|
309
|
+
let buffer = raw
|
|
310
|
+
if (file.endsWith('.zstd')) {
|
|
311
|
+
const offsets = []
|
|
312
|
+
let at = 0
|
|
313
|
+
while ((at = raw.indexOf(SESSION_MAGIC, at)) >= 0) {
|
|
314
|
+
offsets.push(at)
|
|
315
|
+
at += 4
|
|
316
|
+
}
|
|
317
|
+
const parts = []
|
|
318
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
319
|
+
const start = offsets[index]
|
|
320
|
+
const end = index + 1 < offsets.length ? offsets[index + 1] : raw.length
|
|
321
|
+
try {
|
|
322
|
+
parts.push(zstdDecompressSync(raw.subarray(start, end)))
|
|
323
|
+
} catch {
|
|
324
|
+
// 单帧坏了就跳过。
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
buffer = Buffer.concat(parts)
|
|
328
|
+
}
|
|
329
|
+
return buffer.toString('utf8')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 哪些事件算"对话正文"。搜索只在这几类里找:
|
|
334
|
+
* 用户看的是"哪句话在哪个会话里说过",而不是内部状态变更。
|
|
335
|
+
* 尤其 `agent/inbox/spliced` 会把同一条用户消息再抄一遍,不排除就会重复命中。
|
|
336
|
+
*/
|
|
337
|
+
const SEARCHABLE_TYPES = new Set(['user/message', 'assistant/message'])
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 一行日志的 JSON → 事件类型。解析失败返回空串(搜索照样能用,只是不给标签)。
|
|
341
|
+
*/
|
|
342
|
+
function eventTypeOf(line) {
|
|
343
|
+
const at = line.indexOf('"type":"')
|
|
344
|
+
if (at < 0) return ''
|
|
345
|
+
const start = at + 8
|
|
346
|
+
const end = line.indexOf('"', start)
|
|
347
|
+
if (end < 0) return ''
|
|
348
|
+
const value = line.slice(start, end)
|
|
349
|
+
// 头一行是 {"type":"session",...},不是事件。
|
|
350
|
+
return value === 'session' ? '' : value
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 一行日志 → 可读正文。
|
|
355
|
+
*
|
|
356
|
+
* user/message 的正文在 `data.content[].text`,
|
|
357
|
+
* assistant/message 的在 `data.message.content[].text`(多包一层 message)。
|
|
358
|
+
* 抽不到就退回整行原文 —— 搜索要的是"这句话在哪",不是精确的字段还原。
|
|
359
|
+
*/
|
|
360
|
+
function lineOfText(line) {
|
|
361
|
+
const trimmed = line.trim()
|
|
362
|
+
if (trimmed === '') return ''
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(trimmed)
|
|
365
|
+
const data = parsed && typeof parsed === 'object' ? parsed.data : null
|
|
366
|
+
if (data !== null && typeof data === 'object') {
|
|
367
|
+
const owners = [data, data.message]
|
|
368
|
+
for (const owner of owners) {
|
|
369
|
+
if (owner === null || typeof owner !== 'object') continue
|
|
370
|
+
const blocks = Array.isArray(owner.content) ? owner.content : null
|
|
371
|
+
if (blocks === null) continue
|
|
372
|
+
const texts = []
|
|
373
|
+
for (const block of blocks) {
|
|
374
|
+
if (block && typeof block === 'object' && typeof block.text === 'string' && block.text !== '') {
|
|
375
|
+
texts.push(block.text)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (texts.length > 0) return texts.join('\n')
|
|
379
|
+
}
|
|
380
|
+
if (typeof data.text === 'string' && data.text !== '') return data.text
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// 不是完整 JSON(多帧截断之类),退回原文。
|
|
384
|
+
}
|
|
385
|
+
return trimmed
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 从一段文本里截出命中处前后各 60 字,让结果能看出是"哪句话里命中的"。
|
|
390
|
+
* 找不到就返回开头 160 字(有些事件的 text 是拼装出来的,下标可能对不上)。
|
|
391
|
+
*/
|
|
392
|
+
function snippetAround(text, needle) {
|
|
393
|
+
const source = String(text ?? '')
|
|
394
|
+
if (source === '') return ''
|
|
395
|
+
const at = needle === '' ? -1 : source.toLowerCase().indexOf(needle)
|
|
396
|
+
if (at < 0) return source.replace(/\s+/g, ' ').slice(0, 160)
|
|
397
|
+
const start = Math.max(0, at - 60)
|
|
398
|
+
const end = Math.min(source.length, at + needle.length + 60)
|
|
399
|
+
return `${start > 0 ? '…' : ''}${source.slice(start, end).replace(/\s+/g, ' ')}${end < source.length ? '…' : ''}`
|
|
163
400
|
}
|
|
164
401
|
|
|
165
402
|
function sendJson(res, status, value, options = {}) {
|
|
@@ -516,7 +753,7 @@ export function apply(ctx, config) {
|
|
|
516
753
|
// v3:折叠规则变了(只剩我↔AI 正文),旧缓存必须失效。
|
|
517
754
|
// live 必须进键:运行中的短 TTL 缓存和空闲的长 TTL 缓存不能互相污染,
|
|
518
755
|
// 否则会话刚跑完那一刻,空闲请求会命中"助手最后一句话还没写进去"的旧 payload。
|
|
519
|
-
const cacheKey = `
|
|
756
|
+
const cacheKey = `v5:${mode}:${live ? 'live' : 'idle'}:${sessionId}:${limit}`
|
|
520
757
|
const ttl = live ? DIGEST_TTL_LIVE : DIGEST_TTL_IDLE
|
|
521
758
|
if (!noCache) {
|
|
522
759
|
const hit = digestCache.get(cacheKey)
|
|
@@ -571,6 +808,7 @@ export function apply(ctx, config) {
|
|
|
571
808
|
}
|
|
572
809
|
|
|
573
810
|
const folded = foldDigest(events, limit)
|
|
811
|
+
const changes = summarizeChanges(get('workspaceChanges'), sessionId, folded)
|
|
574
812
|
remember({
|
|
575
813
|
ok: true,
|
|
576
814
|
mode: 'observe',
|
|
@@ -581,6 +819,8 @@ export function apply(ctx, config) {
|
|
|
581
819
|
cursor,
|
|
582
820
|
turns: folded.turns,
|
|
583
821
|
endReason: folded.endReason,
|
|
822
|
+
waiting: folded.waiting,
|
|
823
|
+
changes,
|
|
584
824
|
items: folded.items,
|
|
585
825
|
})
|
|
586
826
|
return
|
|
@@ -677,6 +917,198 @@ export function apply(ctx, config) {
|
|
|
677
917
|
}),
|
|
678
918
|
)
|
|
679
919
|
|
|
920
|
+
// 跨会话全文搜索:sessionController.search 的官方语义是
|
|
921
|
+
// "Search visible Session content without resuming an Agent",
|
|
922
|
+
// 也就是不会激活会话、不烧 token,找到的只是历史上已经写下的文字。
|
|
923
|
+
ctx.effect(() =>
|
|
924
|
+
webServer.register({
|
|
925
|
+
kind: 'exact',
|
|
926
|
+
path: SEARCH_ROUTE,
|
|
927
|
+
async handler(req, res) {
|
|
928
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
929
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
930
|
+
sendJson(res, 405, { error: 'method not allowed' }, { headers: { allow: 'GET, HEAD' } })
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
let params
|
|
935
|
+
try {
|
|
936
|
+
params = new URL(String(req.url ?? '/'), 'http://127.0.0.1').searchParams
|
|
937
|
+
} catch {
|
|
938
|
+
sendJson(res, 400, { error: 'bad url' })
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// 开发期自测:确认"索引关掉之后磁盘扫描到底能不能用"。
|
|
943
|
+
// 只在本机(回环)+ dev 开关同时满足时才开 —— 这条分支能把会话正文原文吐出来,
|
|
944
|
+
// 生产环境绝不能留这个口子。
|
|
945
|
+
if (params.get('selftest') === '1' && DEV && isLoopback(req)) {
|
|
946
|
+
const probeId = (params.get('sessionId') ?? '').trim()
|
|
947
|
+
const probeQuery = (params.get('q') ?? '').trim()
|
|
948
|
+
if (probeId === '' || probeQuery === '') {
|
|
949
|
+
sendJson(res, 400, { error: 'selftest needs ?q=&sessionId=' })
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
const startedAt = Date.now()
|
|
954
|
+
const file = findSessionFile(probeId)
|
|
955
|
+
const findMs = Date.now() - startedAt
|
|
956
|
+
if (file === null) {
|
|
957
|
+
sendJson(res, 200, { ok: true, selftest: true, file: null, findMs, note: 'no log file for this session' })
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
const readStart = Date.now()
|
|
961
|
+
const text = readSessionLogText(file)
|
|
962
|
+
const readMs = Date.now() - readStart
|
|
963
|
+
const needle = probeQuery.toLowerCase()
|
|
964
|
+
const hits = []
|
|
965
|
+
let count = 0
|
|
966
|
+
for (const line of text.split('\n')) {
|
|
967
|
+
if (line === '') continue
|
|
968
|
+
if (!line.toLowerCase().includes(needle)) continue
|
|
969
|
+
const type = eventTypeOf(line)
|
|
970
|
+
if (!SEARCHABLE_TYPES.has(type)) continue
|
|
971
|
+
count += 1
|
|
972
|
+
if (hits.length < 3) hits.push({ type, snippet: snippetAround(lineOfText(line), needle) })
|
|
973
|
+
}
|
|
974
|
+
sendJson(res, 200, {
|
|
975
|
+
ok: true,
|
|
976
|
+
selftest: true,
|
|
977
|
+
file: file.replace(/^.*[\\/]/, ''),
|
|
978
|
+
findMs,
|
|
979
|
+
readMs,
|
|
980
|
+
sourceBytes: text.length,
|
|
981
|
+
count,
|
|
982
|
+
hits,
|
|
983
|
+
})
|
|
984
|
+
} catch (error) {
|
|
985
|
+
sendJson(res, 502, { error: String(error?.message ?? error) })
|
|
986
|
+
}
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const gate = connectionOf()
|
|
991
|
+
const rejection = gate === undefined ? 503 : gate.requestRejection(req)
|
|
992
|
+
if (rejection !== undefined) {
|
|
993
|
+
sendJson(res, rejection, {
|
|
994
|
+
error: rejection === 503 ? 'authentication unavailable' : rejection === 401 ? 'unauthorized' : 'forbidden',
|
|
995
|
+
})
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const query = (params.get('q') ?? '').trim()
|
|
1000
|
+
if (query === '') {
|
|
1001
|
+
sendJson(res, 400, { error: 'q is required' })
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const sessionController = get('sessionController')
|
|
1006
|
+
|
|
1007
|
+
// 1) 优先用官方索引:快、带排序,而且覆盖全部可见会话。
|
|
1008
|
+
if (sessionController !== undefined && typeof sessionController.search === 'function') {
|
|
1009
|
+
const probe = new AbortController()
|
|
1010
|
+
const probeTimer = setTimeout(() => probe.abort(), 8000)
|
|
1011
|
+
try {
|
|
1012
|
+
const startedAt = Date.now()
|
|
1013
|
+
const value = await sessionController.search({ query }, probe.signal)
|
|
1014
|
+
const items = Array.isArray(value?.items) ? value.items : []
|
|
1015
|
+
sendJson(res, 200, {
|
|
1016
|
+
ok: true,
|
|
1017
|
+
query,
|
|
1018
|
+
via: 'index',
|
|
1019
|
+
ms: Date.now() - startedAt,
|
|
1020
|
+
hasMore: value?.hasMore === true,
|
|
1021
|
+
items: items.slice(0, 60).map((item) => ({
|
|
1022
|
+
sessionId: String(item?.sessionId ?? ''),
|
|
1023
|
+
snippet: String(item?.snippet ?? '').slice(0, 400),
|
|
1024
|
+
})),
|
|
1025
|
+
})
|
|
1026
|
+
return
|
|
1027
|
+
} catch {
|
|
1028
|
+
// 这个部署把索引关掉了(openAt: "never")→ 静默落到下面的自建扫描。
|
|
1029
|
+
} finally {
|
|
1030
|
+
clearTimeout(probeTimer)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// 2) 兜底:直接读磁盘上的会话日志。
|
|
1035
|
+
//
|
|
1036
|
+
// this deployment 把 session-query 索引配成 openAt: "never" 时,官方
|
|
1037
|
+
// sessionController.search 直接报 "session search is disabled"。
|
|
1038
|
+
// sessionQuery.filterEvents 虽然可用,但每个会话要完整重放,实测 3.35 秒/会话;
|
|
1039
|
+
// 直接解 zstd 帧再扫只要 64ms/会话(差 50 倍),所以走这条。
|
|
1040
|
+
const ids = (params.get('sessions') ?? '')
|
|
1041
|
+
.split(',')
|
|
1042
|
+
.map((one) => one.trim())
|
|
1043
|
+
.filter((one) => one !== '')
|
|
1044
|
+
.slice(0, 60)
|
|
1045
|
+
if (ids.length === 0) {
|
|
1046
|
+
sendJson(res, 400, {
|
|
1047
|
+
error: 'session search index is disabled in this deployment; pass ?sessions=id1,id2 to scan those sessions directly',
|
|
1048
|
+
})
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const needle = query.toLowerCase()
|
|
1053
|
+
const startedAt = Date.now()
|
|
1054
|
+
const results = []
|
|
1055
|
+
let scanned = 0
|
|
1056
|
+
let unreadable = 0
|
|
1057
|
+
const CONCURRENCY = 4
|
|
1058
|
+
let cursor = 0
|
|
1059
|
+
const worker = async () => {
|
|
1060
|
+
while (cursor < ids.length) {
|
|
1061
|
+
const index = cursor
|
|
1062
|
+
cursor += 1
|
|
1063
|
+
const sessionId = ids[index]
|
|
1064
|
+
try {
|
|
1065
|
+
const file = findSessionFile(sessionId)
|
|
1066
|
+
scanned += 1
|
|
1067
|
+
if (file === null) {
|
|
1068
|
+
unreadable += 1
|
|
1069
|
+
continue
|
|
1070
|
+
}
|
|
1071
|
+
const text = readSessionLogText(file)
|
|
1072
|
+
if (text === '') continue
|
|
1073
|
+
const hits = []
|
|
1074
|
+
let count = 0
|
|
1075
|
+
for (const line of text.split('\n')) {
|
|
1076
|
+
if (line === '') continue
|
|
1077
|
+
if (!line.toLowerCase().includes(needle)) continue
|
|
1078
|
+
const type = eventTypeOf(line)
|
|
1079
|
+
if (!SEARCHABLE_TYPES.has(type)) continue
|
|
1080
|
+
count += 1
|
|
1081
|
+
if (hits.length < 4) {
|
|
1082
|
+
hits.push({ type, role: type === 'user/message' ? '我' : '助手', snippet: snippetAround(lineOfText(line), needle) })
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (count > 0) results.push({ sessionId, count, hits })
|
|
1086
|
+
} catch {
|
|
1087
|
+
// 单个会话读失败就跳过,不让整次搜索崩掉。
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, ids.length) }, worker))
|
|
1092
|
+
results.sort((left, right) => right.count - left.count)
|
|
1093
|
+
sendJson(res, 200, {
|
|
1094
|
+
ok: true,
|
|
1095
|
+
query,
|
|
1096
|
+
via: 'scan',
|
|
1097
|
+
ms: Date.now() - startedAt,
|
|
1098
|
+
scanned,
|
|
1099
|
+
unreadable,
|
|
1100
|
+
hasMore: ids.length >= 60,
|
|
1101
|
+
items: results.slice(0, 60).map((item) => ({
|
|
1102
|
+
sessionId: item.sessionId,
|
|
1103
|
+
snippet: item.hits[0] ? item.hits[0].snippet : '',
|
|
1104
|
+
count: item.count,
|
|
1105
|
+
hits: item.hits,
|
|
1106
|
+
})),
|
|
1107
|
+
})
|
|
1108
|
+
},
|
|
1109
|
+
}),
|
|
1110
|
+
)
|
|
1111
|
+
|
|
680
1112
|
// 在面板里直接给别的会话发消息 / 打断它,不用先切过去。
|
|
681
1113
|
// 走 sessionController(Host 侧唯一能 admission 一条 prompt 的入口)。
|
|
682
1114
|
ctx.effect(() =>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-session",
|
|
3
|
-
"version": "0.3
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.3",
|
|
4
|
+
"description": "多会话一体:在同一个会话里总览并排显示多个会话的实时状态与完整对话,标出谁在等你回话、谁改了哪些文件,钉住任意几个同时看、直接发消息或把结论转发给别的会话,不用来回点开切换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"exports": {
|