dsh-reference-session-collector 0.0.0-stage → 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/src/index.ts ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * dsh-reference-session-collector 宿主半(T5.1)。
3
+ *
4
+ * - `export const inject = ['webServer']`(唯一声明;webStartup 不列,老线兼容
5
+ * ——trustedHosts 经 ctx.get 双名回退链 best-effort 读取)。
6
+ * - 单条 fenced 路由 `GET/POST /api/dsh-reference-session-collector`,op 分发
7
+ * status / sessions / session / surface / recall / search / sync。
8
+ * - 双通道注册(direct `ctx.get` + `ctx.inject` 迟到臂),duplicate-route
9
+ * 容错(同 lib 双挂载 = 成功),`ctx.effect` 注销。
10
+ * - webServer 缺席时插件本体照常同步,仅 HTTP 面缺失(fail-soft)。
11
+ * - 启动延迟 2.5s 首轮 syncAll + interval 轮(缺省 30s,env 可关);
12
+ * dispose 关库清定时器。
13
+ *
14
+ * 只读纪律:本文件不直接触碰会话文件——全部经 ProjectionSync 端口。
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import {
19
+ createQueryContext,
20
+ listSessions,
21
+ recallReport,
22
+ search,
23
+ searchGrouped,
24
+ sessionDetail,
25
+ status,
26
+ surfaceLedger,
27
+ type QueryContext,
28
+ } from './surface.ts'
29
+ import { openProjectionStore, type ProjectionStore } from './schema.ts'
30
+ import { projectSessionEvents } from './projection.ts'
31
+ import type { SessionProjection } from './projection.ts'
32
+
33
+ // steward 式导出面:库/投影/切词/查询/同步 API 对消费方(与单测)可导入。
34
+ export { openProjectionStore, reloadSummaries, PROJECTION_SCHEMA_VERSION,
35
+ type ProjectionStore, type SessionRow } from './schema.ts'
36
+ export { projectSessionEvents, extractSearchEventText, extractTitleFromEvents,
37
+ type SessionProjection, type ProjectionSegment, type ProjectionReplace,
38
+ type SessionSummary, type ProjectionOptions, type ExactCounter } from './projection.ts'
39
+ export { segmentForIndex, segmentQueryTerm, sanitizeFtsQuery } from './segment.ts'
40
+ export { stripInjectedBlocks, INJECTED_BLOCK_TAGS } from './strip-injected.ts'
41
+ export { createQueryContext, status, listSessions, sessionDetail, surfaceLedger,
42
+ recallReport, search, searchGrouped, buildSearchSnippet, LedgerCache, LEDGER_LRU_CAP,
43
+ type QueryContext, type GroupedSearchRequest, type GroupedSearchHit } from './surface.ts'
44
+ export { ProjectionSync, discoverSessionLogs, resolveDshHome, resolveIndexDir,
45
+ resolveExactCounter, readArchivedSessionIds, startIntervalSync, resolveSyncIntervalMs,
46
+ DEFAULT_SYNC_INTERVAL_MS, SYNC_INTERVAL_ENV, DISCOVERY_LIMIT,
47
+ type SyncResult, type SyncStorePort, type ProjectionPort } from './sync.ts'
48
+ export { decodeSessionLogBytes, decodeSessionLogFile, classifySessionHeader,
49
+ readSessionGenerations, generationLogFilename,
50
+ type DecodedEvent, type SessionLogRead } from './session-file-decode.ts'
51
+ import {
52
+ ProjectionSync,
53
+ resolveIndexDir,
54
+ startIntervalSync,
55
+ type SyncReplaceMeta,
56
+ type SyncStorePort,
57
+ type SyncWatermark,
58
+ } from './sync.ts'
59
+
60
+ /** 本插件声明的宿主服务(唯一声明;webStartup 走 ctx.get 回退臂)。 */
61
+ export const inject = ['webServer']
62
+
63
+ /** API 路由前缀(fenced 单条)。 */
64
+ export const DRS_API_PATH = '/api/dsh-reference-session-collector'
65
+
66
+ /** 启动首轮同步延迟(ms)——让路给宿主冷启动。 */
67
+ export const BOOT_SYNC_DELAY_MS = 2_500
68
+
69
+ /** webServer 服务面(结构化镜像,零 value import)。 */
70
+ interface WebServerLike {
71
+ register(route: {
72
+ kind: 'exact' | 'prefix'
73
+ path: string
74
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
75
+ }): (() => void) | void
76
+ }
77
+
78
+ /** web runtime 服务面:绑定派生的可信 authority。 */
79
+ interface WebStartupLike {
80
+ trustedHosts: readonly string[]
81
+ }
82
+
83
+ /** `webServer.register` 对同串 exact 路由抛 duplicate——双挂载视为成功(CCI 同款)。 */
84
+ const isDuplicateRouteError = (error: unknown): boolean =>
85
+ String((error as Error)?.message ?? error).includes('duplicate exact route')
86
+
87
+ function asWebServer(value: unknown): WebServerLike | undefined {
88
+ if (value === null || typeof value !== 'object') return undefined
89
+ return typeof (value as WebServerLike).register === 'function' ? (value as WebServerLike) : undefined
90
+ }
91
+
92
+ function writeJson(res: ServerResponse, code: number, body: unknown): void {
93
+ try {
94
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
95
+ } catch { /* 已发头的 mock res(单测面)忽略 */ }
96
+ res.end(JSON.stringify(body))
97
+ }
98
+
99
+ /** Host 头信任门(steward isTrustedApiRequest 同款简化):trustedHosts 缺席=放行。 */
100
+ function isTrustedRequest(req: IncomingMessage, trustedHosts: readonly string[] | undefined): boolean {
101
+ if (trustedHosts === undefined || trustedHosts.length === 0) return true
102
+ const host = req.headers['host']
103
+ if (typeof host !== 'string') return false
104
+ return trustedHosts.some(trusted => trusted.toLowerCase() === host.toLowerCase())
105
+ }
106
+
107
+ /** sync 端口 → ProjectionStore 真身适配(dir 水位经常驻 Map 扫描)。 */
108
+ export function createStorePort(store: ProjectionStore): SyncStorePort {
109
+ return {
110
+ getSessionByDir(dirId: string): SyncWatermark | undefined {
111
+ for (const row of store.summaries.values()) {
112
+ if (row.dir_id !== dirId) continue
113
+ return {
114
+ sessionId: row.session_id,
115
+ artifactBytes: row.artifact_bytes,
116
+ artifactMtimeMs: row.artifact_mtime_ms,
117
+ archived: row.archived,
118
+ }
119
+ }
120
+ return undefined
121
+ },
122
+ replaceSession(projection: unknown, meta: SyncReplaceMeta): void {
123
+ store.replaceSession(projection as SessionProjection, meta.artifactBytes, meta.artifactMtimeMs, {
124
+ archived: meta.archived === 1 ? 1 : 0,
125
+ })
126
+ },
127
+ setSessionArchived(sessionId: string, archived: number): void {
128
+ store.setArchived(sessionId, archived === 1 ? 1 : 0)
129
+ },
130
+ listProjectedDirs(): { dirId: string; sessionId: string }[] {
131
+ const out: { dirId: string; sessionId: string }[] = []
132
+ for (const row of store.summaries.values()) {
133
+ out.push({ dirId: row.dir_id, sessionId: row.session_id })
134
+ }
135
+ return out
136
+ },
137
+ deleteSession(sessionId: string): void {
138
+ store.deleteSession(sessionId)
139
+ },
140
+ }
141
+ }
142
+
143
+ /** 请求处理依赖(导出面便于路由单测直调)。 */
144
+ export interface DrsHandlerDeps {
145
+ query: QueryContext
146
+ sync: { syncAll: () => Promise<unknown>; lastSyncResult: unknown; isSyncing: boolean }
147
+ trustedHosts?: readonly string[]
148
+ }
149
+
150
+ /**
151
+ * 省参自解析(CCI 无参臂契约):max(last_time_ms) 的最活跃会话;
152
+ * 同刻按 synced_at 新者;空库返回 undefined(调用侧 400)。
153
+ */
154
+ function resolveLatestSessionId(query: QueryContext): string | undefined {
155
+ let bestId: string | undefined
156
+ let bestTime = -1
157
+ let bestSync = ''
158
+ for (const row of query.store.summaries.values()) {
159
+ if (row.last_time_ms > bestTime || (row.last_time_ms === bestTime && row.synced_at > bestSync)) {
160
+ bestId = row.session_id
161
+ bestTime = row.last_time_ms
162
+ bestSync = row.synced_at
163
+ }
164
+ }
165
+ return bestId
166
+ }
167
+
168
+ /**
169
+ * op 分发:GET `?op=…`;POST `{op:'sync'}` 触发后台同步。
170
+ * 未知 op / 缺参 → 400(不留空壳)。
171
+ */
172
+ export async function handleDrsRequest(deps: DrsHandlerDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
173
+ if (!isTrustedRequest(req, deps.trustedHosts)) {
174
+ writeJson(res, 403, { ok: false, error: 'forbidden' })
175
+ return
176
+ }
177
+ let url: URL
178
+ try {
179
+ url = new URL(req.url ?? DRS_API_PATH, 'http://localhost')
180
+ } catch {
181
+ writeJson(res, 400, { ok: false, error: 'bad url' })
182
+ return
183
+ }
184
+ const op = url.searchParams.get('op') ?? ''
185
+ const sessionId = url.searchParams.get('session') ?? ''
186
+ const method = String(req.method ?? 'GET').toUpperCase()
187
+
188
+ if (method === 'POST') {
189
+ if (op !== 'sync') {
190
+ writeJson(res, 400, { ok: false, error: `unknown POST op: ${op || '(missing)'}` })
191
+ return
192
+ }
193
+ // 触发后台同步即回(syncing 旗标防撞轮,busy 轮自然合并到下一 interval)。
194
+ void deps.sync.syncAll().catch(() => {})
195
+ writeJson(res, 200, { ok: true, accepted: true, lastResult: deps.sync.lastSyncResult ?? null })
196
+ return
197
+ }
198
+
199
+ switch (op) {
200
+ case 'status': {
201
+ writeJson(res, 200, { ok: true, ...status(deps.query, deps.sync.lastSyncResult ?? null) })
202
+ return
203
+ }
204
+ case 'sessions': {
205
+ const offset = Number(url.searchParams.get('offset') ?? '0')
206
+ const limit = Number(url.searchParams.get('limit') ?? '50')
207
+ writeJson(res, 200, { ok: true, ...listSessions(deps.query,
208
+ Number.isFinite(offset) ? offset : 0,
209
+ Number.isFinite(limit) ? limit : 50) })
210
+ return
211
+ }
212
+ case 'session': {
213
+ if (sessionId === '') {
214
+ writeJson(res, 400, { ok: false, error: 'session parameter required' })
215
+ return
216
+ }
217
+ const turn = url.searchParams.get('turn') ?? undefined
218
+ const fields = url.searchParams.get('fields') ?? undefined
219
+ const detail = sessionDetail(deps.query, sessionId, {
220
+ ...(turn === undefined ? {} : { turn }),
221
+ ...(fields === undefined ? {} : { fields }),
222
+ })
223
+ if (detail.session === undefined) {
224
+ writeJson(res, 200, { ok: true, session: null, rows: [] })
225
+ return
226
+ }
227
+ writeJson(res, 200, { ok: true, ...detail })
228
+ return
229
+ }
230
+ case 'surface': {
231
+ const sid = sessionId !== '' ? sessionId : resolveLatestSessionId(deps.query)
232
+ if (sid === undefined) {
233
+ writeJson(res, 400, { ok: false, error: 'session parameter required (no sessions to resolve)' })
234
+ return
235
+ }
236
+ const ledger = surfaceLedger(deps.query, sid)
237
+ writeJson(res, 200, sessionId !== ''
238
+ ? { ok: true, ...ledger }
239
+ : { ok: true, ...ledger, resolved_session_id: sid, resolved_by: 'latest' })
240
+ return
241
+ }
242
+ case 'recall': {
243
+ const sid = sessionId !== '' ? sessionId : resolveLatestSessionId(deps.query)
244
+ if (sid === undefined) {
245
+ writeJson(res, 400, { ok: false, error: 'session parameter required (no sessions to resolve)' })
246
+ return
247
+ }
248
+ const report = recallReport(deps.query, sid)
249
+ writeJson(res, 200, sessionId !== ''
250
+ ? { ok: true, ...report }
251
+ : { ok: true, ...report, resolved_session_id: sid, resolved_by: 'latest' })
252
+ return
253
+ }
254
+ case 'search': {
255
+ const q = url.searchParams.get('q') ?? ''
256
+ if (q === '') {
257
+ writeJson(res, 400, { ok: false, error: 'q parameter required' })
258
+ return
259
+ }
260
+ // 分组臂(steward 直连):group=session 时走 searchGrouped,参数见
261
+ // GroupedSearchRequest;缺省仍为逐文档平铺命中(CCI/既有调用不变)。
262
+ if (url.searchParams.get('group') === 'session') {
263
+ const kindsRaw = url.searchParams.get('kinds') ?? ''
264
+ const archivedRaw = url.searchParams.get('archived')
265
+ const fromRaw = url.searchParams.get('from')
266
+ const toRaw = url.searchParams.get('to')
267
+ const limitRaw = url.searchParams.get('limit')
268
+ const grouped = searchGrouped(deps.query, {
269
+ query: q,
270
+ ...(kindsRaw === '' ? {} : { kinds: kindsRaw.split(',').map(k => k.trim()).filter(Boolean) }),
271
+ ...(archivedRaw === 'active' || archivedRaw === 'archived' || archivedRaw === 'all'
272
+ ? { archived: archivedRaw } : {}),
273
+ ...(fromRaw !== null && Number.isFinite(Number(fromRaw)) ? { from: Number(fromRaw) } : {}),
274
+ ...(toRaw !== null && Number.isFinite(Number(toRaw)) ? { to: Number(toRaw) } : {}),
275
+ ...(url.searchParams.get('surface') === '0' ? { surfaceOnly: false } : {}),
276
+ ...(url.searchParams.get('sort') === 'time' ? { sortBy: 'time' as const } : {}),
277
+ ...(limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {}),
278
+ })
279
+ writeJson(res, 200, { ok: true, ...grouped })
280
+ return
281
+ }
282
+ const limitRaw = url.searchParams.get('limit')
283
+ writeJson(res, 200, { ok: true, ...search(deps.query, q, {
284
+ ...(sessionId === '' ? {} : { session: sessionId }),
285
+ ...(limitRaw === null ? {} : { limit: Number(limitRaw) }),
286
+ }) })
287
+ return
288
+ }
289
+ default: {
290
+ writeJson(res, 400, { ok: false, error: `unknown op: ${op || '(missing)'}` })
291
+ }
292
+ }
293
+ }
294
+
295
+ /** cordis 对未声明 inject 的服务面访问(属性读与 ctx.get)都会抛——两层安全
296
+ * 包裹(装机实锤×2:请求期未保护读 webStartup/webRuntime → webServer 全局
297
+ * 兑底 400 空体;Proxy 属性读臂同样需要 try)。 */
298
+ function safeReadService(ctx: Context, name: string): unknown {
299
+ try {
300
+ const direct = (ctx as unknown as Record<string, unknown>)[name]
301
+ if (direct !== undefined) return direct
302
+ } catch { /* Proxy 面:未声明服务属性读抛 */ }
303
+ try {
304
+ return typeof ctx.get === 'function' ? ctx.get(name) : undefined
305
+ } catch {
306
+ return undefined
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 插件主体:开库 → 装配 sync(启动延迟首轮 + interval 轮)→ 双通道注册路由。
312
+ * @param ctx - host 插件上下文(webServer 声明注入;webStartup/webRuntime 回退臂)。
313
+ */
314
+ export function apply(ctx: Context): void {
315
+ const log = (level: 'info' | 'warn', message: string, ...args: unknown[]): void => {
316
+ try {
317
+ const logger = (ctx as unknown as { logger?: { warn?: (m: string, ...a: unknown[]) => void; info?: (m: string, ...a: unknown[]) => void } }).logger
318
+ if (level === 'warn' && typeof logger?.warn === 'function') logger.warn(message, ...args)
319
+ else if (level === 'info' && typeof logger?.info === 'function') logger.info(message, ...args)
320
+ else console[level]('[dsh-reference-session-collector]', message, ...args)
321
+ } catch { /* 日志面永不致命 */ }
322
+ }
323
+
324
+ const storeDir = resolveIndexDir()
325
+ let store: ProjectionStore
326
+ let query: QueryContext
327
+ try {
328
+ store = openProjectionStore(storeDir)
329
+ query = createQueryContext(store)
330
+ } catch (error) {
331
+ log('warn', 'projection store 不可用(node:sqlite 缺席/目录不可写):插件停用—— %s',
332
+ String((error as Error)?.message ?? error))
333
+ return
334
+ }
335
+
336
+ const sync = new ProjectionSync({
337
+ store: createStorePort(store),
338
+ project: (events, options) => projectSessionEvents(events, {
339
+ dirId: options.dirId,
340
+ ...(options.header === undefined ? {} : { header: options.header }),
341
+ // 端口计数器返 TokenCount;投影只需数值臂(CCI verify 同款降级)。
342
+ ...(options.exactCounter === undefined ? {} : {
343
+ exactCounter: (text: string): number => {
344
+ const count = options.exactCounter?.(text)
345
+ return count !== undefined && count.kind === 'exact-tokenizer'
346
+ ? count.tokens
347
+ : Math.ceil(text.length / 4)
348
+ },
349
+ }),
350
+ }),
351
+ log: (level, message, ...args) => log(level, message, ...args),
352
+ })
353
+
354
+ // 生命周期:dispose 清定时器 + 关库。
355
+ let bootTimer: ReturnType<typeof setTimeout> | undefined
356
+ let disposeInterval: () => void = () => {}
357
+ ctx.effect(() => {
358
+ bootTimer = setTimeout(() => {
359
+ void sync.syncAll().then(result => {
360
+ log('info', 'boot sync done: updated=%d skipped=%d basis=%s %dms',
361
+ result.updated, result.skipped, result.basis, result.durationMs)
362
+ }).catch((error: unknown) => log('warn', 'boot sync failed: %s', String((error as Error)?.message ?? error)))
363
+ }, BOOT_SYNC_DELAY_MS)
364
+ disposeInterval = startIntervalSync(sync)
365
+ return () => {
366
+ if (bootTimer !== undefined) clearTimeout(bootTimer)
367
+ disposeInterval()
368
+ store.close()
369
+ }
370
+ }, 'dsh-reference-session-collector: projection lifecycle')
371
+
372
+ // trustedHosts best-effort(双名回退链,属性/get 两层 safe;缺席=放行)。
373
+ const readStartupFace = (): WebStartupLike | undefined => {
374
+ const direct = safeReadService(ctx, 'webStartup') ?? safeReadService(ctx, 'webRuntime')
375
+ return direct !== null && typeof direct === 'object' ? direct as WebStartupLike : undefined
376
+ }
377
+
378
+ const depsFor = (): DrsHandlerDeps => {
379
+ const startup = readStartupFace()
380
+ return {
381
+ query,
382
+ sync,
383
+ ...(startup?.trustedHosts !== undefined ? { trustedHosts: startup.trustedHosts } : {}),
384
+ }
385
+ }
386
+
387
+ let registered = false
388
+ const register = (webServer: WebServerLike, channel: 'direct' | 'inject'): void => {
389
+ if (registered) return
390
+ try {
391
+ const off = webServer.register({
392
+ kind: 'exact',
393
+ path: DRS_API_PATH,
394
+ handler: (req, res) => handleDrsRequest(depsFor(), req, res),
395
+ })
396
+ registered = true
397
+ if (typeof off === 'function') {
398
+ ctx.effect(() => off, 'dsh-reference-session-collector: /api route unregister')
399
+ }
400
+ log('info', 'drs api route registered (%s)', channel)
401
+ } catch (error) {
402
+ if (isDuplicateRouteError(error)) {
403
+ registered = true
404
+ log('info', 'drs api route already owned by second mount — ok')
405
+ return
406
+ }
407
+ log('warn', 'drs api route registration failed (%s): %s', channel, String((error as Error)?.message ?? error))
408
+ }
409
+ }
410
+
411
+ const readService = (name: string): unknown => safeReadService(ctx, name)
412
+
413
+ const active = asWebServer(readService('webServer'))
414
+ if (active !== undefined) {
415
+ register(active, 'direct')
416
+ if (registered) return
417
+ }
418
+
419
+ try {
420
+ ctx.inject(['webServer'], (injected) => {
421
+ const webServer = asWebServer((injected as { webServer?: unknown }).webServer)
422
+ if (webServer === undefined) {
423
+ log('warn', 'webServer exposes no register() — drs api route not registered (projection sync keeps running)')
424
+ return
425
+ }
426
+ register(webServer, 'inject')
427
+ })
428
+ } catch (error) {
429
+ log('warn', 'webServer inject arm unavailable: %s — HTTP 面缺席,同步本体照常', String((error as Error)?.message ?? error))
430
+ }
431
+ }