dsh-sessions-manager 3.2.2 → 3.4.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/README.en.md +24 -19
- package/README.md +24 -19
- package/assets/screenshot-session-autoarch.png +0 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-starred.png +0 -0
- package/assets/screenshot-session-storage.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/lib/client.js +258 -7
- package/lib/client.js.map +2 -2
- package/lib/index.js +584 -29
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/auto-archive.js +168 -0
- package/src/client/index.jsx +266 -10
- package/src/client/logic.js +6 -0
- package/src/index.js +238 -5
- package/src/markdown.js +175 -0
- package/src/star-index.js +109 -0
- package/src/storage-stats.js +94 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
package/src/index.js
CHANGED
|
@@ -12,6 +12,11 @@ import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { rewriteFrame0Cwd } from './zstd-frame.js'
|
|
15
|
+
import { renderSessionMarkdown } from './markdown.js'
|
|
16
|
+
import { createStarIndex } from './star-index.js'
|
|
17
|
+
import { aggregateStorage } from './storage-stats.js'
|
|
18
|
+
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
export const name = 'dsh-sessions-manager'
|
|
17
22
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
@@ -27,6 +32,7 @@ const DEFAULT_TRASH_SETTINGS = Object.freeze({ retentionDays: 0 })
|
|
|
27
32
|
const FETCH_TOOL_RE = /search|fetch|download|browse/i
|
|
28
33
|
const MAX_FETCHES = 12 // fetch 记录上限(防响应过大)
|
|
29
34
|
const MAX_FILES = 20 // write/edit 文件列表上限
|
|
35
|
+
const MAX_STORAGE_TOP = 50 // 存储排行返回上限(防响应过大)
|
|
30
36
|
|
|
31
37
|
function json(res, value, status = 200) {
|
|
32
38
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
@@ -151,7 +157,7 @@ export function apply(ctx) {
|
|
|
151
157
|
|
|
152
158
|
let wsByPath = {}
|
|
153
159
|
|
|
154
|
-
async function resolveOne(id) {
|
|
160
|
+
async function resolveOne(id, usage) {
|
|
155
161
|
let title = null, createdAt = null, cwd = null
|
|
156
162
|
try {
|
|
157
163
|
const o = await sq.readTitleSnapshot(id)
|
|
@@ -173,7 +179,7 @@ export function apply(ctx) {
|
|
|
173
179
|
const ws = cwd ? wsByPath[cwd] : undefined
|
|
174
180
|
const workspaceGone = !!(cwd && !ws)
|
|
175
181
|
const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
|
|
176
|
-
|
|
182
|
+
const base = {
|
|
177
183
|
sessionId: id,
|
|
178
184
|
title: display,
|
|
179
185
|
createdAt: createdAt || null,
|
|
@@ -182,6 +188,45 @@ export function apply(ctx) {
|
|
|
182
188
|
workspaceGone: workspaceGone ? true : false,
|
|
183
189
|
hasWorkspace: !!cwd,
|
|
184
190
|
}
|
|
191
|
+
// sizeBytes / updatedAt are opt-in: they cost one stat() per session, so
|
|
192
|
+
// the high-frequency list routes stay exactly as cheap as before.
|
|
193
|
+
if (usage) {
|
|
194
|
+
if (usage.sizeById && usage.sizeById.has(id)) base.sizeBytes = usage.sizeById.get(id)
|
|
195
|
+
if (usage.mtimeById && usage.mtimeById.has(id)) base.updatedAt = usage.mtimeById.get(id)
|
|
196
|
+
}
|
|
197
|
+
return base
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Disk usage + last-write time for every session, in one pass.
|
|
201
|
+
//
|
|
202
|
+
// sp.locate(header) resolves the log file behind a session header; a single
|
|
203
|
+
// stat() then yields both its size and its mtime. mtime doubles as the
|
|
204
|
+
// session's last-activity time — appending an event rewrites the log, so the
|
|
205
|
+
// file's last write tracks the conversation's last turn. It errs safe: a log
|
|
206
|
+
// we relocated (move) gets a fresh mtime and therefore looks *more* active
|
|
207
|
+
// than it is, which can only delay an auto-archive, never cause a wrong one.
|
|
208
|
+
async function collectUsage() {
|
|
209
|
+
const sizeById = new Map()
|
|
210
|
+
const mtimeById = new Map()
|
|
211
|
+
let headers = []
|
|
212
|
+
try { headers = await sp.list() } catch (e) { headers = [] }
|
|
213
|
+
if (!Array.isArray(headers)) headers = []
|
|
214
|
+
const CHUNK = 8
|
|
215
|
+
for (let i = 0; i < headers.length; i += CHUNK) {
|
|
216
|
+
await Promise.all(headers.slice(i, i + CHUNK).map(async (header) => {
|
|
217
|
+
const id = header && header.id != null ? String(header.id) : null
|
|
218
|
+
if (!id) return
|
|
219
|
+
try {
|
|
220
|
+
const loc = sp.locate(header)
|
|
221
|
+
if (!loc || typeof loc.path !== 'string' || !loc.path) return
|
|
222
|
+
const st = await stat(loc.path)
|
|
223
|
+
if (!st) return
|
|
224
|
+
if (typeof st.size === 'number') sizeById.set(id, st.size)
|
|
225
|
+
if (typeof st.mtimeMs === 'number' && st.mtimeMs > 0) mtimeById.set(id, Math.floor(st.mtimeMs))
|
|
226
|
+
} catch (e) { /* best-effort: an unreadable log just stays unknown */ }
|
|
227
|
+
}))
|
|
228
|
+
}
|
|
229
|
+
return { sizeById, mtimeById }
|
|
185
230
|
}
|
|
186
231
|
|
|
187
232
|
// Restore (unarchive) one session; throws on failure.
|
|
@@ -226,6 +271,24 @@ export function apply(ctx) {
|
|
|
226
271
|
return operation
|
|
227
272
|
}
|
|
228
273
|
|
|
274
|
+
// ---- Starred sessions (收藏, schema v3) -----------------------------------
|
|
275
|
+
// User marks, kept in the plugin's own index (never touches DSH logs). Stars
|
|
276
|
+
// survive archive & soft-delete — both are reversible — and are dropped only
|
|
277
|
+
// when the session is really gone (purge, or externally removed; the latter
|
|
278
|
+
// is caught by gcStars during list builds).
|
|
279
|
+
const stars = createStarIndex()
|
|
280
|
+
// Auto-archive settings live in their own schema-v4 store, off by default.
|
|
281
|
+
const autoArchive = createAutoArchiveStore()
|
|
282
|
+
|
|
283
|
+
async function gcStars(validIds) {
|
|
284
|
+
try {
|
|
285
|
+
const store = await stars.read()
|
|
286
|
+
const valid = new Set(validIds.map(String))
|
|
287
|
+
const gone = store.starredSessionIds.filter((id) => !valid.has(id))
|
|
288
|
+
if (gone.length) await stars.removeIds(gone)
|
|
289
|
+
} catch (e) { /* best-effort */ }
|
|
290
|
+
}
|
|
291
|
+
|
|
229
292
|
// Soft-delete one session: record it in the recycle-bin index but KEEP its
|
|
230
293
|
// log in the original workspace directory. Moving the file out (and detaching
|
|
231
294
|
// it from the workspace) orphaned the session into DSH's "未分组" group and
|
|
@@ -364,6 +427,7 @@ export function apply(ctx) {
|
|
|
364
427
|
purged = true
|
|
365
428
|
})
|
|
366
429
|
if (!purged) throw new Error('彻底删除失败')
|
|
430
|
+
stars.removeIds([sid]).catch(() => {})
|
|
367
431
|
return { ok: true, purged: true }
|
|
368
432
|
}
|
|
369
433
|
|
|
@@ -641,12 +705,16 @@ export function apply(ctx) {
|
|
|
641
705
|
})
|
|
642
706
|
}
|
|
643
707
|
|
|
644
|
-
|
|
708
|
+
// opts.usage: fill sizeBytes + updatedAt (one stat() per session). Off by
|
|
709
|
+
// default so the panel's list route keeps its original cost.
|
|
710
|
+
async function allSessionItems(opts = {}) {
|
|
645
711
|
let materialized = new Set()
|
|
646
712
|
let live = ctx.get('sessions')
|
|
713
|
+
let headersOk = false
|
|
647
714
|
try {
|
|
648
715
|
const headers = await sp.list()
|
|
649
716
|
materialized = new Set(headers.map((h) => String(h.id)))
|
|
717
|
+
headersOk = true
|
|
650
718
|
} catch (e) { /* best-effort */ }
|
|
651
719
|
const ids = []
|
|
652
720
|
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
|
|
@@ -663,14 +731,69 @@ export function apply(ctx) {
|
|
|
663
731
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
664
732
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
665
733
|
const items = []
|
|
734
|
+
const usage = (opts && opts.usage) ? await collectUsage() : null
|
|
666
735
|
const CHUNK = 6
|
|
667
736
|
for (let i = 0; i < visibleIds.length; i += CHUNK) {
|
|
668
|
-
|
|
737
|
+
// Arrow wrapper on purpose: Array#map passes (value, index, array), and
|
|
738
|
+
// resolveOne's second argument is the usage map.
|
|
739
|
+
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage)))
|
|
669
740
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
670
741
|
}
|
|
742
|
+
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
743
|
+
// failing sp.list() can never wipe the whole index.
|
|
744
|
+
let starredSet = new Set()
|
|
745
|
+
try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
|
|
746
|
+
for (const it of items) it.starred = starredSet.has(String(it.sessionId))
|
|
747
|
+
if (headersOk) await gcStars(ids)
|
|
671
748
|
return items
|
|
672
749
|
}
|
|
673
750
|
|
|
751
|
+
// ---- Storage usage + auto-archive ---------------------------------------
|
|
752
|
+
|
|
753
|
+
// Read-only rollup: per-workspace totals plus the largest sessions. The
|
|
754
|
+
// aggregation itself is a pure function (src/storage-stats.js).
|
|
755
|
+
async function buildStorage(opts = {}) {
|
|
756
|
+
const items = await allSessionItems({ usage: true })
|
|
757
|
+
const raw = Number(opts && opts.topN)
|
|
758
|
+
const topN = Number.isInteger(raw) && raw > 0 ? Math.min(raw, MAX_STORAGE_TOP) : 10
|
|
759
|
+
return aggregateStorage(items, { topN })
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Archive conversations that have been idle past the configured window.
|
|
763
|
+
//
|
|
764
|
+
// Deliberately lazy — there is no timer. The sweep runs when the panel reads
|
|
765
|
+
// its settings (and on demand), at most once a day: a background interval
|
|
766
|
+
// would keep the host process alive and would archive conversations while
|
|
767
|
+
// nobody is looking at the panel.
|
|
768
|
+
async function autoArchiveSweep(opts = {}) {
|
|
769
|
+
const store = await autoArchive.read()
|
|
770
|
+
const days = store.settings.inactiveDays
|
|
771
|
+
if (!days) return { ok: true, skipped: 'disabled', archived: 0 }
|
|
772
|
+
const now = Date.now()
|
|
773
|
+
if (!(opts && opts.force) && autoArchive.isFresh(store, now)) {
|
|
774
|
+
return { ok: true, skipped: 'throttled', archived: 0, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount }
|
|
775
|
+
}
|
|
776
|
+
const items = await allSessionItems({ usage: true })
|
|
777
|
+
const candidates = pickInactiveCandidates(items, {
|
|
778
|
+
inactiveDays: days,
|
|
779
|
+
skipStarred: store.settings.skipStarred,
|
|
780
|
+
activeSessionId: getActiveSessionId(ctx),
|
|
781
|
+
now,
|
|
782
|
+
})
|
|
783
|
+
let archived = 0
|
|
784
|
+
const failed = []
|
|
785
|
+
for (const sid of candidates) {
|
|
786
|
+
try {
|
|
787
|
+
const result = await archiveOne(sid)
|
|
788
|
+
if (result && result.archived) archived++
|
|
789
|
+
} catch (e) {
|
|
790
|
+
failed.push({ sessionId: sid, error: String((e && e.message) || e) })
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
await autoArchive.recordRun(archived, now)
|
|
794
|
+
return { ok: true, archived, candidates: candidates.length, failed, lastRunAt: now }
|
|
795
|
+
}
|
|
796
|
+
|
|
674
797
|
async function sidebarAuthority() {
|
|
675
798
|
const ids = []
|
|
676
799
|
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) {}
|
|
@@ -855,7 +978,7 @@ export function apply(ctx) {
|
|
|
855
978
|
const items = []
|
|
856
979
|
const CHUNK = 6
|
|
857
980
|
for (let i = 0; i < idStrs.length; i += CHUNK) {
|
|
858
|
-
const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))
|
|
981
|
+
const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)))
|
|
859
982
|
items.push.apply(items, res2)
|
|
860
983
|
}
|
|
861
984
|
json(res, { items })
|
|
@@ -1049,6 +1172,57 @@ export function apply(ctx) {
|
|
|
1049
1172
|
},
|
|
1050
1173
|
}))
|
|
1051
1174
|
|
|
1175
|
+
// Star / unstar one or many sessions (收藏, schema v3).
|
|
1176
|
+
disposers.push(ctx.webServer.register({
|
|
1177
|
+
kind: 'exact',
|
|
1178
|
+
path: '/archived-sessions/star/set',
|
|
1179
|
+
handler: async (req, res) => {
|
|
1180
|
+
try {
|
|
1181
|
+
const body = await readJsonBody(req)
|
|
1182
|
+
const starred = !!(body && body.starred)
|
|
1183
|
+
let ids = parseIds(body)
|
|
1184
|
+
if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
|
|
1185
|
+
ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
|
|
1186
|
+
}
|
|
1187
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1188
|
+
const starredSessionIds = await stars.setStarred(ids, starred)
|
|
1189
|
+
json(res, { ok: true, starredSessionIds })
|
|
1190
|
+
} catch (e) {
|
|
1191
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
}))
|
|
1195
|
+
|
|
1196
|
+
// Human-readable Markdown export (one session). Raw-log ZIP export is
|
|
1197
|
+
// dsh's own GET /api/session.export — we deliberately do not duplicate it
|
|
1198
|
+
// (see reports/HANDOFF-dsh-sessions-manager-roadmap.md §2.4).
|
|
1199
|
+
disposers.push(ctx.webServer.register({
|
|
1200
|
+
kind: 'exact',
|
|
1201
|
+
path: '/archived-sessions/export-md',
|
|
1202
|
+
handler: async (req, res) => {
|
|
1203
|
+
try {
|
|
1204
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1205
|
+
const sid = url.searchParams.get('sessionId')
|
|
1206
|
+
requireSessionId(sid)
|
|
1207
|
+
const r = await sp.readFrom(sid, 0)
|
|
1208
|
+
if (!r || !r.meta) {
|
|
1209
|
+
const error = new Error('无法读取该会话的日志')
|
|
1210
|
+
error.status = 404
|
|
1211
|
+
throw error
|
|
1212
|
+
}
|
|
1213
|
+
const md = renderSessionMarkdown({ ...r.meta, id: sid }, r.events || [])
|
|
1214
|
+
res.writeHead(200, {
|
|
1215
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
1216
|
+
'content-disposition': `attachment; filename="dsh-session-${sid}.md"`,
|
|
1217
|
+
'cache-control': 'no-store',
|
|
1218
|
+
})
|
|
1219
|
+
res.end(md)
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
json(res, { error: String((e && e.message) || e) }, errorStatus(e))
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
}))
|
|
1225
|
+
|
|
1052
1226
|
disposers.push(ctx.webServer.register({
|
|
1053
1227
|
kind: 'exact',
|
|
1054
1228
|
path: '/archived-sessions/sidebar-state',
|
|
@@ -1153,6 +1327,65 @@ export function apply(ctx) {
|
|
|
1153
1327
|
},
|
|
1154
1328
|
}))
|
|
1155
1329
|
|
|
1330
|
+
// Storage usage rollup (read-only): per-workspace totals + largest sessions.
|
|
1331
|
+
disposers.push(ctx.webServer.register({
|
|
1332
|
+
kind: 'exact',
|
|
1333
|
+
path: '/archived-sessions/storage',
|
|
1334
|
+
handler: async (req, res) => {
|
|
1335
|
+
try {
|
|
1336
|
+
const body = await readJsonBody(req)
|
|
1337
|
+
json(res, await buildStorage({ topN: body && body.topN }))
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
}))
|
|
1343
|
+
|
|
1344
|
+
// Auto-archive settings. A plain read (no patch keys) doubles as the lazy
|
|
1345
|
+
// sweep trigger — that is how the once-a-day cleanup gets a chance to run
|
|
1346
|
+
// without a background timer.
|
|
1347
|
+
disposers.push(ctx.webServer.register({
|
|
1348
|
+
kind: 'exact',
|
|
1349
|
+
path: '/archived-sessions/auto-archive/settings',
|
|
1350
|
+
handler: async (req, res) => {
|
|
1351
|
+
try {
|
|
1352
|
+
const body = await readJsonBody(req)
|
|
1353
|
+
const patch = {}
|
|
1354
|
+
if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
|
|
1355
|
+
if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
|
|
1356
|
+
const settings = Object.keys(patch).length
|
|
1357
|
+
? await autoArchive.update(patch)
|
|
1358
|
+
: (await autoArchive.read()).settings
|
|
1359
|
+
const sweep = await autoArchiveSweep()
|
|
1360
|
+
const store = await autoArchive.read()
|
|
1361
|
+
json(res, {
|
|
1362
|
+
ok: true,
|
|
1363
|
+
settings,
|
|
1364
|
+
lastRunAt: store.lastRunAt,
|
|
1365
|
+
lastArchivedCount: store.lastArchivedCount,
|
|
1366
|
+
sweep,
|
|
1367
|
+
})
|
|
1368
|
+
} catch (e) {
|
|
1369
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
}))
|
|
1373
|
+
|
|
1374
|
+
// Run the auto-archive sweep right now, ignoring the once-a-day throttle.
|
|
1375
|
+
disposers.push(ctx.webServer.register({
|
|
1376
|
+
kind: 'exact',
|
|
1377
|
+
path: '/archived-sessions/auto-archive/run',
|
|
1378
|
+
handler: async (req, res) => {
|
|
1379
|
+
try {
|
|
1380
|
+
const sweep = await autoArchiveSweep({ force: true })
|
|
1381
|
+
const store = await autoArchive.read()
|
|
1382
|
+
json(res, { ok: true, ...sweep, settings: store.settings, lastRunAt: store.lastRunAt, lastArchivedCount: store.lastArchivedCount })
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, 500)
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
}))
|
|
1388
|
+
|
|
1156
1389
|
return () => { for (const d of disposers) d() }
|
|
1157
1390
|
}, 'dsh-sessions-manager: routes')
|
|
1158
1391
|
}
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Render a session log as human-readable Markdown.
|
|
2
|
+
//
|
|
3
|
+
// Pure: no DOM, no I/O, no dsh imports. Everything it needs arrives as
|
|
4
|
+
// arguments, so the renderer is unit-testable without a running host.
|
|
5
|
+
//
|
|
6
|
+
// Field shapes below were read off real session logs (2026-09-01), not guessed:
|
|
7
|
+
// user/message data.content = [{ type:'text', text } | { type:'image', ... }]
|
|
8
|
+
// assistant/message data.message.content = [{ type:'text'|'reasoning'|'tool-call', ... }]
|
|
9
|
+
// tool/call data.{name, arguments} (arguments is a JSON *string*)
|
|
10
|
+
// tool/result data.message.content = [{ type:'tool-result', content:[{type:'text',text}] }]
|
|
11
|
+
// Note the asymmetry: user text lives at data.content, assistant text one level
|
|
12
|
+
// deeper at data.message.content. Streaming deltas (`assistant/chunk`,
|
|
13
|
+
// `text-chunks`, `reasoning-chunks`) are never rendered — `assistant/message`
|
|
14
|
+
// already carries the final text for each step.
|
|
15
|
+
|
|
16
|
+
const MAX_TOOL_ARG = 200
|
|
17
|
+
|
|
18
|
+
function isoTime(value) {
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
20
|
+
try { return new Date(value).toISOString() } catch { return null }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function yamlString(value) {
|
|
24
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function blocksOf(value) {
|
|
28
|
+
return Array.isArray(value) ? value.filter((b) => b && typeof b === 'object') : []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Join the text blocks of a content array; image blocks are counted separately.
|
|
32
|
+
function textFromBlocks(blocks) {
|
|
33
|
+
const parts = []
|
|
34
|
+
for (const block of blocks) {
|
|
35
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
36
|
+
}
|
|
37
|
+
return parts.join('\n\n').trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function imageCountOf(blocks) {
|
|
41
|
+
let count = 0
|
|
42
|
+
for (const block of blocks) if (block.type === 'image') count++
|
|
43
|
+
return count
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reasoningFromBlocks(blocks) {
|
|
47
|
+
const parts = []
|
|
48
|
+
for (const block of blocks) {
|
|
49
|
+
if (block.type === 'reasoning' && typeof block.text === 'string' && block.text.trim()) parts.push(block.text.trim())
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A short, human-usable summary of one tool call's arguments.
|
|
55
|
+
export function summarizeToolArguments(name, rawArguments) {
|
|
56
|
+
let parsed = null
|
|
57
|
+
if (typeof rawArguments === 'string') {
|
|
58
|
+
try { parsed = JSON.parse(rawArguments) } catch { parsed = null }
|
|
59
|
+
} else if (rawArguments && typeof rawArguments === 'object') {
|
|
60
|
+
parsed = rawArguments
|
|
61
|
+
}
|
|
62
|
+
if (parsed === null) return typeof rawArguments === 'string' ? rawArguments.slice(0, MAX_TOOL_ARG) : ''
|
|
63
|
+
if (typeof parsed !== 'object') return String(parsed).slice(0, MAX_TOOL_ARG)
|
|
64
|
+
const preferred = ['command', 'file_path', 'path', 'query', 'url', 'pattern']
|
|
65
|
+
for (const key of preferred) {
|
|
66
|
+
if (typeof parsed[key] === 'string' && parsed[key].trim()) return parsed[key]
|
|
67
|
+
}
|
|
68
|
+
const keys = Object.keys(parsed)
|
|
69
|
+
if (keys.length === 0) return ''
|
|
70
|
+
const rest = {}
|
|
71
|
+
for (const key of keys.slice(0, 6)) {
|
|
72
|
+
const value = parsed[key]
|
|
73
|
+
rest[key] = typeof value === 'string' ? value : JSON.stringify(value)
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify(rest).slice(0, MAX_TOOL_ARG)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render one session as Markdown.
|
|
80
|
+
* @param {object} meta - Session header (`{ id, cwd, createdAt, title? }`).
|
|
81
|
+
* @param {Array<object>} events - Session events as stored in the log.
|
|
82
|
+
* @param {object} [options]
|
|
83
|
+
* @param {boolean} [options.includeReasoning=false] - Emit assistant reasoning blocks.
|
|
84
|
+
* @param {boolean} [options.includeToolResults=false] - Emit tool results.
|
|
85
|
+
* @param {number} [options.exportedAt] - Override the export timestamp (tests).
|
|
86
|
+
* @returns {string} Markdown document.
|
|
87
|
+
*/
|
|
88
|
+
export function renderSessionMarkdown(meta, events, options = {}) {
|
|
89
|
+
const includeReasoning = options.includeReasoning === true
|
|
90
|
+
const includeToolResults = options.includeToolResults === true
|
|
91
|
+
const header = meta && typeof meta === 'object' ? meta : {}
|
|
92
|
+
const list = Array.isArray(events) ? events : []
|
|
93
|
+
|
|
94
|
+
// The last session/title event wins — DSH may retitle a session later on.
|
|
95
|
+
let title = typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null
|
|
96
|
+
for (const ev of list) {
|
|
97
|
+
const data = ev && ev.data
|
|
98
|
+
if (ev && ev.type === 'session/title' && data && typeof data.title === 'string' && data.title.trim()) {
|
|
99
|
+
title = data.title.trim()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const front = ['---']
|
|
104
|
+
if (title) front.push(`title: ${yamlString(title)}`)
|
|
105
|
+
if (typeof header.id === 'string' && header.id) front.push(`sessionId: ${yamlString(header.id)}`)
|
|
106
|
+
if (typeof header.cwd === 'string' && header.cwd) front.push(`cwd: ${yamlString(header.cwd)}`)
|
|
107
|
+
const created = isoTime(header.createdAt)
|
|
108
|
+
if (created) front.push(`createdAt: ${created}`)
|
|
109
|
+
const exported = isoTime(options.exportedAt)
|
|
110
|
+
if (exported) front.push(`exportedAt: ${exported}`)
|
|
111
|
+
front.push('---')
|
|
112
|
+
|
|
113
|
+
const out = [front.join('\n')]
|
|
114
|
+
if (title) out.push('', `# ${title}`)
|
|
115
|
+
|
|
116
|
+
let turn = null
|
|
117
|
+
for (const ev of list) {
|
|
118
|
+
if (!ev || typeof ev !== 'object') continue
|
|
119
|
+
const data = ev.data && typeof ev.data === 'object' ? ev.data : {}
|
|
120
|
+
const type = ev.type
|
|
121
|
+
|
|
122
|
+
if (type === 'turn/start') {
|
|
123
|
+
const next = Number.isInteger(data.turn) ? data.turn : null
|
|
124
|
+
if (next !== null && next !== turn) {
|
|
125
|
+
turn = next
|
|
126
|
+
out.push('', `## 第 ${turn} 轮`)
|
|
127
|
+
}
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (type === 'user/message') {
|
|
132
|
+
const blocks = blocksOf(data.content)
|
|
133
|
+
const text = textFromBlocks(blocks)
|
|
134
|
+
const images = imageCountOf(blocks)
|
|
135
|
+
if (!text && images === 0) continue
|
|
136
|
+
out.push('', '### 用户', '')
|
|
137
|
+
if (text) out.push(text)
|
|
138
|
+
for (let i = 0; i < images; i++) out.push('', ``)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (type === 'assistant/message') {
|
|
143
|
+
const message = data.message && typeof data.message === 'object' ? data.message : {}
|
|
144
|
+
const blocks = blocksOf(message.content)
|
|
145
|
+
const text = textFromBlocks(blocks)
|
|
146
|
+
const reasoning = includeReasoning ? reasoningFromBlocks(blocks) : ''
|
|
147
|
+
if (!text && !reasoning) continue
|
|
148
|
+
out.push('', '### 助手', '')
|
|
149
|
+
if (reasoning) out.push('> 思考:' + reasoning.split('\n').join('\n> '), '')
|
|
150
|
+
if (text) out.push(text)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === 'tool/call') {
|
|
155
|
+
const name = typeof data.name === 'string' && data.name ? data.name : 'tool'
|
|
156
|
+
const summary = summarizeToolArguments(name, data.arguments)
|
|
157
|
+
out.push('', `### 工具调用:\`${name}\``, '')
|
|
158
|
+
out.push(summary ? '```\n' + summary + '\n```' : '(无参数)')
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (type === 'tool/result' && includeToolResults) {
|
|
163
|
+
const message = data.message && typeof data.message === 'object' ? data.message : {}
|
|
164
|
+
const blocks = blocksOf(message.content)
|
|
165
|
+
let text = ''
|
|
166
|
+
for (const block of blocks) {
|
|
167
|
+
if (block.type === 'tool-result') text = textFromBlocks(blocksOf(block.content))
|
|
168
|
+
}
|
|
169
|
+
if (text) out.push('', '<details><summary>工具结果</summary>', '', '```\n' + text.slice(0, 2000) + '\n```', '', '</details>')
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
out.push('')
|
|
174
|
+
return out.join('\n')
|
|
175
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Durable "starred sessions" index (schema v3).
|
|
2
|
+
//
|
|
3
|
+
// Deliberately mirrors the recycle-bin index in src/index.js: version field,
|
|
4
|
+
// automatic upgrade of older shapes, atomic write (tmp + rename) and a single
|
|
5
|
+
// chained mutation queue so two concurrent requests can never clobber each
|
|
6
|
+
// other. Extracted from the host bundle so it can be unit-tested directly —
|
|
7
|
+
// pass `dir` to point the index at a temp directory.
|
|
8
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
// v3 is the first star schema; it starts at 3 so it can never be confused with
|
|
14
|
+
// the recycle bin's v1/v2 documents even if a file is copied between them.
|
|
15
|
+
export const STAR_SCHEMA_VERSION = 3
|
|
16
|
+
|
|
17
|
+
const DEFAULT_STAR_DIR = join(homedir(), '.dsh', 'sessions-manager')
|
|
18
|
+
|
|
19
|
+
function isSafeSessionId(value) {
|
|
20
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\\/\0]/.test(value) && value !== '.' && value !== '..'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Coerce anything on disk (or nothing at all) into a valid v3 store.
|
|
25
|
+
* Accepts a bare array of ids (the pre-schema shape) and upgrades it.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeStarStore(raw) {
|
|
28
|
+
const legacy = Array.isArray(raw) ? raw : null
|
|
29
|
+
const source = legacy || (raw && typeof raw === 'object' ? raw : null)
|
|
30
|
+
const ids = source && Array.isArray(source.starredSessionIds) ? source.starredSessionIds : (legacy || [])
|
|
31
|
+
const clean = []
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
// Strings only: silently coercing a number into an id would let junk into
|
|
35
|
+
// the index and mask a caller bug.
|
|
36
|
+
if (!isSafeSessionId(id)) continue
|
|
37
|
+
if (seen.has(id)) continue
|
|
38
|
+
seen.add(id)
|
|
39
|
+
clean.push(id)
|
|
40
|
+
}
|
|
41
|
+
return { schemaVersion: STAR_SCHEMA_VERSION, starredSessionIds: clean }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Open the star index.
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.dir] - Directory holding the index (tests inject a temp dir).
|
|
48
|
+
* @param {string} [options.indexPath] - Full index path, overriding `dir`.
|
|
49
|
+
*/
|
|
50
|
+
export function createStarIndex(options = {}) {
|
|
51
|
+
const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_STAR_DIR || DEFAULT_STAR_DIR
|
|
52
|
+
const indexPath = options.indexPath || join(dir, 'star.json')
|
|
53
|
+
let mutation = Promise.resolve()
|
|
54
|
+
|
|
55
|
+
async function read() {
|
|
56
|
+
try {
|
|
57
|
+
return normalizeStarStore(JSON.parse(readFileSync(indexPath, 'utf8')))
|
|
58
|
+
} catch {
|
|
59
|
+
return normalizeStarStore(null)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function write(store) {
|
|
64
|
+
await mkdir(dir, { recursive: true })
|
|
65
|
+
const tmp = join(dir, `.star-${process.pid}-${Date.now()}.tmp`)
|
|
66
|
+
await writeFile(tmp, JSON.stringify(normalizeStarStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
67
|
+
await rename(tmp, indexPath)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Serialize read-modify-write cycles: every mutator sees the store as left by
|
|
71
|
+
// the previous one, and a rejected mutator still keeps the chain alive.
|
|
72
|
+
function mutate(mutator) {
|
|
73
|
+
const operation = mutation.then(async () => {
|
|
74
|
+
const store = await read()
|
|
75
|
+
const result = await mutator(store)
|
|
76
|
+
await write(store)
|
|
77
|
+
return result
|
|
78
|
+
})
|
|
79
|
+
mutation = operation.catch(() => {})
|
|
80
|
+
return operation
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Star or unstar sessions.
|
|
85
|
+
* @param {string[]} ids - Session ids to change.
|
|
86
|
+
* @param {boolean} starred - true to star, false to unstar.
|
|
87
|
+
* @returns {Promise<string[]>} The full starred set after the change.
|
|
88
|
+
*/
|
|
89
|
+
function setStarred(ids, starred) {
|
|
90
|
+
const wanted = (Array.isArray(ids) ? ids : []).filter(isSafeSessionId).map(String)
|
|
91
|
+
return mutate((store) => {
|
|
92
|
+
const set = new Set(store.starredSessionIds)
|
|
93
|
+
for (const id of wanted) {
|
|
94
|
+
if (starred) set.add(id)
|
|
95
|
+
else set.delete(id)
|
|
96
|
+
}
|
|
97
|
+
store.starredSessionIds = [...set]
|
|
98
|
+
return store.starredSessionIds
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Drop ids once their session is gone (purged / deleted), otherwise the index
|
|
103
|
+
// would grow forever with ids that can never be listed again.
|
|
104
|
+
function removeIds(ids) {
|
|
105
|
+
return setStarred(ids, false)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { read, write, mutate, setStarred, removeIds, indexPath, dir }
|
|
109
|
+
}
|