@goodandready/dsh-session-control 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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +127 -0
- package/lib/transcript.js +67 -0
- package/package.json +54 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Серверная половина dsh-session-control.
|
|
3
|
+
*
|
|
4
|
+
* Здесь живут пространство настроек (закрепления и скрытия) и один маршрут —
|
|
5
|
+
* расшифровка заархивированной сессии.
|
|
6
|
+
*
|
|
7
|
+
* Почему маршрут вообще понадобился. Заархивированную сессию нельзя открыть в
|
|
8
|
+
* браузере: окно истории у клиента поднимается только для сессии «на сцене»,
|
|
9
|
+
* то есть текущей, а текущей архивная быть не может — политика ядра снимает
|
|
10
|
+
* такой выбор. Потоковый `session/follow` по обычному запросу не открывается,
|
|
11
|
+
* ему нужен потоковый переносчик, а `session/page` отвечает «failed to observe
|
|
12
|
+
* session», пока за сессией не следят.
|
|
13
|
+
*
|
|
14
|
+
* Зато на хосте есть штатная дверь: `sessionPersistence.readRaw(id)` отдаёт
|
|
15
|
+
* сохранённый лог, не поднимая живую сессию. Через неё мы и читаем.
|
|
16
|
+
*/
|
|
17
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
|
+
import { transcriptFromEvents } from './transcript.js'
|
|
19
|
+
|
|
20
|
+
export const name = 'dsh-session-control'
|
|
21
|
+
|
|
22
|
+
/** Пространство настроек. Совпадает с ключом карточки settings.plugin.item. */
|
|
23
|
+
export const NS = 'dsh-session-control'
|
|
24
|
+
|
|
25
|
+
/** Сколько сообщений отдаём за один запрос: расшифровка читается, а не листается бесконечно. */
|
|
26
|
+
const TRANSCRIPT_LIMIT = 400
|
|
27
|
+
|
|
28
|
+
export const Config = z.object({
|
|
29
|
+
/**
|
|
30
|
+
* Идентификаторы закреплённых сессий. Порядок массива — порядок в панели.
|
|
31
|
+
* Закрепление общее на всю панель, а не внутри папки.
|
|
32
|
+
*/
|
|
33
|
+
pinned: z.array(z.string()).default([]),
|
|
34
|
+
/**
|
|
35
|
+
* Идентификаторы скрытых сессий. Наше скрытие обратимо, в отличие от
|
|
36
|
+
* ядрового архива: WorkspaceRegistry.archiveSession только добавляет, метода
|
|
37
|
+
* возврата в API нет.
|
|
38
|
+
*/
|
|
39
|
+
hidden: z.array(z.string()).default([]),
|
|
40
|
+
/**
|
|
41
|
+
* Прятать сессии без единого сообщения. Их непрерывно плодят расписания,
|
|
42
|
+
* мессенджер и доска, и в списке они мешают настоящим разговорам.
|
|
43
|
+
* Текущая сессия не прячется никогда, даже пустая: иначе пропадёт строка,
|
|
44
|
+
* в которую человек как раз собирается писать.
|
|
45
|
+
*/
|
|
46
|
+
hideBlank: z.boolean().default(true),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
export function apply(ctx, config) {
|
|
52
|
+
ctx.inject(['settings'], (sctx) => {
|
|
53
|
+
sctx.settings.register(NS, Config, { base: config })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
ctx.inject(['webServer', 'sessionPersistence'], (rctx) => {
|
|
57
|
+
rctx.effect(() => rctx.webServer.register({
|
|
58
|
+
kind: 'exact',
|
|
59
|
+
path: '/dsh-session-control/transcript',
|
|
60
|
+
handler: async (req, res) => {
|
|
61
|
+
const reply = (code, body) => {
|
|
62
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
63
|
+
res.end(JSON.stringify(body))
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(req.url, 'http://localhost')
|
|
67
|
+
const sessionId = url.searchParams.get('session')
|
|
68
|
+
if (sessionId === null || sessionId === '') {
|
|
69
|
+
reply(400, { ok: false, error: 'session parameter is required' })
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
// Сервис берём через get, а не как свойство контекста: так делает и
|
|
73
|
+
// ядровый экспорт логов.
|
|
74
|
+
const store = rctx.get('sessionPersistence')
|
|
75
|
+
// Поверхность бэкенда различается между выпусками ядра: где-то есть
|
|
76
|
+
// readRaw, где-то пара resolveLog + readStoredLog. Спрашиваем, что
|
|
77
|
+
// умеет этот, вместо того чтобы полагаться на версию.
|
|
78
|
+
if (store === undefined
|
|
79
|
+
|| typeof store.resolveLog !== 'function'
|
|
80
|
+
|| typeof store.readStoredLog !== 'function') {
|
|
81
|
+
reply(501, { ok: false, error: 'this session backend cannot read stored logs' })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
// Старые логи формата v0 ядро отказывается мигрировать при чтении
|
|
85
|
+
// и возвращает пустой лог, не сообщая об отказе. Отличаем это от
|
|
86
|
+
// действительно пустой сессии: врать «сообщений нет» нельзя.
|
|
87
|
+
let messages = []
|
|
88
|
+
let unreadable = false
|
|
89
|
+
let missing = false
|
|
90
|
+
try {
|
|
91
|
+
// resolveLog сам запускает миграцию формата и бросает на старых
|
|
92
|
+
// логах, поэтому под перехватом должен быть и он, а не только
|
|
93
|
+
// чтение.
|
|
94
|
+
const path = await store.resolveLog(sessionId)
|
|
95
|
+
if (path === undefined) {
|
|
96
|
+
missing = true
|
|
97
|
+
} else {
|
|
98
|
+
const log = await store.readStoredLog(path, sessionId)
|
|
99
|
+
const events = (log && log.events) || []
|
|
100
|
+
messages = transcriptFromEvents(events)
|
|
101
|
+
unreadable = events.length === 0
|
|
102
|
+
}
|
|
103
|
+
} catch (refused) {
|
|
104
|
+
unreadable = true
|
|
105
|
+
}
|
|
106
|
+
if (missing) {
|
|
107
|
+
reply(404, { ok: false, error: 'session not found' })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (unreadable) {
|
|
111
|
+
reply(200, { ok: true, sessionId, unreadable: true, total: 0, truncated: false, messages: [] })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
reply(200, {
|
|
115
|
+
ok: true,
|
|
116
|
+
sessionId,
|
|
117
|
+
total: messages.length,
|
|
118
|
+
truncated: messages.length > TRANSCRIPT_LIMIT,
|
|
119
|
+
messages: messages.slice(-TRANSCRIPT_LIMIT),
|
|
120
|
+
})
|
|
121
|
+
} catch (failure) {
|
|
122
|
+
reply(500, { ok: false, error: (failure && failure.message) || String(failure) })
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}), 'dsh-session-control: маршрут расшифровки')
|
|
126
|
+
})
|
|
127
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Превращение лога сессии в расшифровку.
|
|
3
|
+
*
|
|
4
|
+
* Модуль намеренно без единой внешней зависимости: это чистая логика, и она
|
|
5
|
+
* должна проверяться тестами без харнесса и без установки профиля.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Отобрать из событий то, что человек считает разговором.
|
|
10
|
+
*
|
|
11
|
+
* Берём его собственные сообщения, ответы агента и факт вызова инструмента.
|
|
12
|
+
* Рассуждения модели пропускаем — это её черновик, а не содержание беседы.
|
|
13
|
+
*
|
|
14
|
+
* @param events - события лога в порядке записи.
|
|
15
|
+
* @returns сообщения в том же порядке.
|
|
16
|
+
*/
|
|
17
|
+
export function transcriptFromEvents(events) {
|
|
18
|
+
const out = []
|
|
19
|
+
for (const event of events || []) {
|
|
20
|
+
const kind = event && event.type
|
|
21
|
+
if (kind === 'user/message' || kind === 'assistant/message') {
|
|
22
|
+
// Две формы в одном логе: у сообщений пользователя содержимое лежит
|
|
23
|
+
// прямо в data.content, у ответов агента — в data.message.content.
|
|
24
|
+
// Читаем обе, иначе половина разговора пропадает молча.
|
|
25
|
+
const data = event.data || {}
|
|
26
|
+
const message = data.message || {}
|
|
27
|
+
const parts = Array.isArray(data.content)
|
|
28
|
+
? data.content
|
|
29
|
+
: (Array.isArray(message.content) ? message.content : [])
|
|
30
|
+
const text = parts
|
|
31
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
32
|
+
.map((part) => part.text)
|
|
33
|
+
.join('\n')
|
|
34
|
+
if (text.trim() === '') continue
|
|
35
|
+
out.push({ role: kind === 'user/message' ? 'user' : 'assistant', text, time: event.time })
|
|
36
|
+
} else if (kind === 'tool/call') {
|
|
37
|
+
const data = event.data || {}
|
|
38
|
+
const toolName = data.name || (data.call && data.call.name)
|
|
39
|
+
if (typeof toolName === 'string' && toolName !== '') {
|
|
40
|
+
out.push({ role: 'tool', text: toolName, time: event.time })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* То же самое, но из сырого текста лога — одна JSON-запись на строку.
|
|
49
|
+
*
|
|
50
|
+
* Битые строки пропускаем: одна повреждённая запись не должна лишать человека
|
|
51
|
+
* всей остальной расшифровки.
|
|
52
|
+
*
|
|
53
|
+
* @param raw - содержимое файла лога.
|
|
54
|
+
* @returns сообщения в порядке лога.
|
|
55
|
+
*/
|
|
56
|
+
export function parseTranscript(raw) {
|
|
57
|
+
const events = []
|
|
58
|
+
for (const line of String(raw).split('\n')) {
|
|
59
|
+
if (line.trim() === '') continue
|
|
60
|
+
try {
|
|
61
|
+
events.push(JSON.parse(line))
|
|
62
|
+
} catch (notJson) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return transcriptFromEvents(events)
|
|
67
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodandready/dsh-session-control",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session management for the DeepSeek Harness sidebar: pin conversations, search their contents, read archived transcripts and hide the noise",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
},
|
|
24
|
+
"client": {
|
|
25
|
+
"platform": "web",
|
|
26
|
+
"inject": [
|
|
27
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
28
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
29
|
+
"@deepseek-ai/dsh-api-workspace-controller",
|
|
30
|
+
"@deepseek-ai/dsh-client-connection",
|
|
31
|
+
"@deepseek-ai/dsh-client-locale",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "node --test test/*.test.mjs"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/GooDAnDReaDY/dsh-session-control.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/GooDAnDReaDY/dsh-session-control#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-session-control/issues"
|
|
53
|
+
}
|
|
54
|
+
}
|