dsh-reference-session-collector 0.1.0 → 0.2.0-beta.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/dsh.plugin.json +1 -1
- package/lib/index.d.ts +44 -15
- package/lib/index.js +430 -50
- package/package.json +3 -2
- package/src/blocks.ts +45 -0
- package/src/index.ts +36 -0
- package/src/redact.ts +279 -0
- package/src/schema.ts +48 -40
- package/src/surface.ts +99 -20
- package/src/sync.ts +20 -3
package/src/surface.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 查询面(T4.1):status / listSessions / sessionDetail /
|
|
3
|
-
* recallReport / search 六函数 + 缓存层 2/3(sessions 汇总常驻 Map 由
|
|
2
|
+
* 查询面(T4.1 / v5 读侧 task_10-11):status / listSessions / sessionDetail /
|
|
3
|
+
* surfaceLedger / recallReport / search 六函数 + 缓存层 2/3(sessions 汇总常驻 Map 由
|
|
4
4
|
* schema.ts 维护;per-session 台账 LRU cap 32,键含 synced_at 代际——
|
|
5
|
-
*
|
|
5
|
+
* 会话重同步即自然失效;LRU 键不随 redact 变——redact 臂只落在无缓存的
|
|
6
|
+
* sessionDetail/search 两路径上)。
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
+
* v5 读侧契约:segments 存 hash 引用(text_hash/search_text_hash/
|
|
9
|
+
* redacted_*_hash),行面经 LEFT JOIN blocks 还原为 **v4 逐字段同形**的
|
|
10
|
+
* 15 列(缺省臂=CCI/steward/locate 零感知);surface_text 退役派生化——
|
|
11
|
+
* 经 `replaced_by→(session,seq)→hash→blocks` 两跳 JOIN 还原链头正文,
|
|
12
|
+
* JOIN 恒带 session_id(跨会话 seq 撞键防御,findings 风险①)。
|
|
13
|
+
* `redact=1` 切 redacted 列(含 snippet 锚定文本);fts 仍索引原文。
|
|
14
|
+
*
|
|
15
|
+
* sessionDetail 支持 `turn=` 区间过滤、`fields=search`(返回 time/
|
|
8
16
|
* search_text/on_surface 检索面——steward 二期切料与 memory 三期回填共用
|
|
9
|
-
*
|
|
17
|
+
* 此臂)、`tail=N` 尾窗(R4:会话尾 N 段升序,memory recentBySession 等价
|
|
18
|
+
* 供给面);search 查询走 segment.ts 同构切词(中文可用)。
|
|
10
19
|
*/
|
|
11
20
|
import type { ProjectionStore, SessionRow } from './schema.ts'
|
|
12
21
|
import { sanitizeFtsQuery } from './segment.ts'
|
|
@@ -126,24 +135,72 @@ function parseTurnRange(raw?: string): { min: number; max: number } | undefined
|
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
138
|
+
* v5 读侧列集:hash 引用列名对(缺省原文臂 / redact=1 脱敏臂)——
|
|
139
|
+
* 常数字面量拼入 SQL,无注入面(R3「redact=1 切 redacted 列」)。
|
|
140
|
+
*/
|
|
141
|
+
const PLAIN_FACE = { textHash: 'text_hash', searchTextHash: 'search_text_hash' } as const
|
|
142
|
+
const REDACTED_FACE = { textHash: 'redacted_text_hash', searchTextHash: 'redacted_search_text_hash' } as const
|
|
143
|
+
|
|
144
|
+
/** 两面对的结构类型(字面量并集在拼接参数处归宽)。 */
|
|
145
|
+
type FaceColumns = { readonly textHash: string; readonly searchTextHash: string }
|
|
146
|
+
|
|
147
|
+
/** v4 segments DDL 15 列的还原 SELECT(v5 逐字段同形回归地基;列序一致;
|
|
148
|
+
* 取材列对 bt/bs/hb 由 faceJoins 按 redact 臂选定,本模板不感知)。 */
|
|
149
|
+
const DETAIL_COLUMNS = `s.session_id, s.seq, s.turn, s.step, s.kind, s.role, s.time,
|
|
150
|
+
COALESCE(bt.body, '') AS text, s.tokens,
|
|
151
|
+
COALESCE(bs.body, '') AS search_text,
|
|
152
|
+
s.on_surface, s.compressed,
|
|
153
|
+
CASE WHEN s.compressed = 1 THEN COALESCE(hb.body, '') ELSE NULL END AS surface_text,
|
|
154
|
+
s.surface_tokens, s.replaced_by`
|
|
155
|
+
|
|
156
|
+
/** blocks/链头两跳 JOIN 子句(replaced_by 恒带 session_id,撞键防御)。 */
|
|
157
|
+
function faceJoins(face: FaceColumns): string {
|
|
158
|
+
return `FROM segments s
|
|
159
|
+
LEFT JOIN blocks bt ON bt.hash = s.${face.textHash}
|
|
160
|
+
LEFT JOIN blocks bs ON bs.hash = s.${face.searchTextHash}
|
|
161
|
+
LEFT JOIN segments h ON h.session_id = s.session_id AND h.seq = s.replaced_by AND s.compressed = 1
|
|
162
|
+
LEFT JOIN blocks hb ON hb.hash = h.${face.textHash}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** sessionDetail 选项(turn 区间 / fields 检索臂 / tail 尾窗 / redact 脱敏臂)。 */
|
|
166
|
+
export interface SessionDetailOptions {
|
|
167
|
+
turn?: string
|
|
168
|
+
fields?: string
|
|
169
|
+
/** R4 尾窗:正整数=会话尾 N 段(过滤后),升序返回;非法/缺省=全量。 */
|
|
170
|
+
tail?: number
|
|
171
|
+
/** R3:true=切 redacted 列(含派生 surface_text 用链头 redacted 正文)。 */
|
|
172
|
+
redact?: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* segments 明细(v4 逐字段同形还原)。
|
|
177
|
+
* @param opts.turn - `turn=` 区间过滤(`a-b` 闭区间或单值)。
|
|
178
|
+
* @param opts.fields - `'search'` 返检索面字段(time/search_text/on_surface)。
|
|
179
|
+
* @param opts.tail - 尾窗 N:先取 seq 降序 N 行再翻升序(与会话文件真实尾部对拍)。
|
|
180
|
+
* @param opts.redact - 脱敏臂:text/search_text/surface_text 取 redacted_* 列。
|
|
132
181
|
*/
|
|
133
182
|
export function sessionDetail(
|
|
134
183
|
ctx: QueryContext,
|
|
135
184
|
sessionId: string,
|
|
136
|
-
opts:
|
|
185
|
+
opts: SessionDetailOptions = {},
|
|
137
186
|
): { session: SessionRow | undefined; rows: unknown[] } {
|
|
138
187
|
const store = ctx.store
|
|
139
188
|
const session = store.summaries.get(sessionId)
|
|
189
|
+
const face = opts.redact === true ? REDACTED_FACE : PLAIN_FACE
|
|
140
190
|
const range = parseTurnRange(opts.turn)
|
|
191
|
+
const tail = typeof opts.tail === 'number' && Number.isFinite(opts.tail) && opts.tail > 0
|
|
192
|
+
? Math.floor(opts.tail)
|
|
193
|
+
: undefined
|
|
141
194
|
const where = range === undefined
|
|
142
|
-
? '
|
|
143
|
-
: '
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
195
|
+
? 'WHERE s.session_id=?'
|
|
196
|
+
: 'WHERE s.session_id=? AND s.turn BETWEEN ? AND ?'
|
|
197
|
+
const params: (string | number)[] = range === undefined ? [sessionId] : [sessionId, range.min, range.max]
|
|
198
|
+
const order = tail === undefined ? 'ORDER BY s.seq ASC' : 'ORDER BY s.seq DESC LIMIT ?'
|
|
199
|
+
if (tail !== undefined) params.push(tail)
|
|
200
|
+
const fetched = store.db
|
|
201
|
+
.prepare(`SELECT ${DETAIL_COLUMNS} ${faceJoins(face)} ${where} ${order}`)
|
|
202
|
+
.all(...params) as unknown as Record<string, unknown>[]
|
|
203
|
+
const rows = tail === undefined ? fetched : fetched.reverse()
|
|
147
204
|
if (opts.fields !== 'search') {
|
|
148
205
|
return { session, rows }
|
|
149
206
|
}
|
|
@@ -167,10 +224,21 @@ export function surfaceLedger(ctx: QueryContext, sessionId: string): Record<stri
|
|
|
167
224
|
const cached = ctx.surfaceLru.get(key)
|
|
168
225
|
if (cached !== undefined) return cached as Record<string, unknown>
|
|
169
226
|
const onSurface = store.db.prepare(
|
|
170
|
-
|
|
227
|
+
`SELECT s.seq, s.turn, s.step, s.kind, s.role, s.time, s.tokens,
|
|
228
|
+
COALESCE(b.body, '') AS text
|
|
229
|
+
FROM segments s
|
|
230
|
+
LEFT JOIN blocks b ON b.hash = s.text_hash
|
|
231
|
+
WHERE s.session_id=? AND s.on_surface=1 ORDER BY s.seq`,
|
|
171
232
|
).all(sessionId)
|
|
233
|
+
// surface_text 两跳派生(v5 退役直存):s.replaced_by→同会话链头行→其
|
|
234
|
+
// text_hash 块正文;与 v4 直存值等值对拍(chain 在投影期已收敛到终替)。
|
|
172
235
|
const compressed = store.db.prepare(
|
|
173
|
-
|
|
236
|
+
`SELECT s.seq, s.replaced_by,
|
|
237
|
+
COALESCE(hb.body, '') AS surface_text, s.surface_tokens
|
|
238
|
+
FROM segments s
|
|
239
|
+
LEFT JOIN segments h ON h.session_id = s.session_id AND h.seq = s.replaced_by
|
|
240
|
+
LEFT JOIN blocks hb ON hb.hash = h.text_hash
|
|
241
|
+
WHERE s.session_id=? AND s.compressed=1 ORDER BY s.seq`,
|
|
174
242
|
).all(sessionId)
|
|
175
243
|
const value: Record<string, unknown> = {
|
|
176
244
|
session_id: sessionId,
|
|
@@ -226,20 +294,26 @@ export function recallReport(ctx: QueryContext, sessionId: string): Record<strin
|
|
|
226
294
|
return value
|
|
227
295
|
}
|
|
228
296
|
|
|
229
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* FTS5 BM25 内容搜索(中英同构切词,走 sanitizeFtsQuery)。
|
|
299
|
+
* v5:fts 仍索引原文(R3);snippet 经 blocks 还原,`redact` 只切 snippet
|
|
300
|
+
* 取材列(redacted_search_text_hash),命中/排序语义两臂一致。
|
|
301
|
+
*/
|
|
230
302
|
export function search(
|
|
231
303
|
ctx: QueryContext,
|
|
232
304
|
query: string,
|
|
233
|
-
opts: { session?: string; limit?: number } = {},
|
|
305
|
+
opts: { session?: string; limit?: number; redact?: boolean } = {},
|
|
234
306
|
): { query: string; match: string; hits: Record<string, unknown>[] } {
|
|
235
307
|
const match = sanitizeFtsQuery(query)
|
|
236
308
|
if (match === '') return { query, match, hits: [] }
|
|
237
309
|
const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 20)), 100)
|
|
310
|
+
const snippetCol = (opts.redact === true ? REDACTED_FACE : PLAIN_FACE).searchTextHash
|
|
238
311
|
const sessionClause = opts.session !== undefined && opts.session !== '' ? 'AND segments_fts.session_id = ?' : ''
|
|
239
312
|
const sql = `SELECT segments_fts.session_id AS session_id, segments_fts.seq AS seq, segments_fts.kind AS kind,
|
|
240
|
-
bm25(segments_fts) AS rank, substr(
|
|
313
|
+
bm25(segments_fts) AS rank, substr(COALESCE(b.body, ''), 1, 200) AS snippet, s.time, s.on_surface, s.turn
|
|
241
314
|
FROM segments_fts
|
|
242
315
|
JOIN segments s ON s.session_id = segments_fts.session_id AND s.seq = segments_fts.seq
|
|
316
|
+
LEFT JOIN blocks b ON b.hash = s.${snippetCol}
|
|
243
317
|
WHERE segments_fts MATCH ? ${sessionClause}
|
|
244
318
|
ORDER BY rank LIMIT ?`
|
|
245
319
|
const stmt = ctx.store.db.prepare(sql)
|
|
@@ -303,6 +377,8 @@ export interface GroupedSearchRequest {
|
|
|
303
377
|
/** 排序:缺省 relevance;'time'=会话 updated_at 降序、分数平手。 */
|
|
304
378
|
sortBy?: 'relevance' | 'time'
|
|
305
379
|
limit?: number
|
|
380
|
+
/** R3:true=snippet 取材切 redacted_search_text(命中/排序不变,fts 仍原文)。 */
|
|
381
|
+
redact?: boolean
|
|
306
382
|
}
|
|
307
383
|
|
|
308
384
|
/** 分组命中(wire 蛇形,会话级最强命中)。 */
|
|
@@ -320,6 +396,7 @@ export interface GroupedSearchHit {
|
|
|
320
396
|
* 会话分组全文搜索(op=search&group=session):BM25 候选窗 → 按会话取
|
|
321
397
|
* 加权最强命中 → 排序分页。数学/视界与 steward engine.search 逐字同构:
|
|
322
398
|
* score = −rank × weight(type),时间排序键是会话 updated_at(非命中 time)。
|
|
399
|
+
* snippet 锚定文本经 blocks 还原(redact 臂切 redacted_search_text_hash)。
|
|
323
400
|
*/
|
|
324
401
|
export function searchGrouped(ctx: QueryContext, request: GroupedSearchRequest): { query: string; hits: GroupedSearchHit[] } {
|
|
325
402
|
const match = sanitizeFtsQuery(request.query)
|
|
@@ -346,11 +423,13 @@ export function searchGrouped(ctx: QueryContext, request: GroupedSearchRequest):
|
|
|
346
423
|
dateParams.push(request.to)
|
|
347
424
|
}
|
|
348
425
|
const dateClause = dateClauses.join(' ')
|
|
426
|
+
const snippetCol = (request.redact === true ? REDACTED_FACE : PLAIN_FACE).searchTextHash
|
|
349
427
|
const sql = `SELECT seg.session_id AS session_id, seg.seq AS seq, seg.kind AS kind, seg.time AS time,
|
|
350
|
-
|
|
428
|
+
COALESCE(b.body, '') AS search_text, bm25(segments_fts) AS rank,
|
|
351
429
|
sess.title AS title, sess.last_time_ms AS updated_at
|
|
352
430
|
FROM segments_fts
|
|
353
431
|
JOIN segments seg ON seg.session_id = segments_fts.session_id AND seg.seq = segments_fts.seq
|
|
432
|
+
LEFT JOIN blocks b ON b.hash = seg.${snippetCol}
|
|
354
433
|
JOIN sessions sess ON sess.session_id = seg.session_id
|
|
355
434
|
WHERE segments_fts MATCH ?
|
|
356
435
|
AND seg.kind IN (${placeholders}) ${surfaceClause} ${archivedClause} ${dateClause}
|
package/src/sync.ts
CHANGED
|
@@ -164,7 +164,7 @@ export interface SyncReplaceMeta {
|
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
166
|
* 投影库端口。真身 = schema.ts 的 ProjectionStore(L1);单测注入内存 mock。
|
|
167
|
-
*
|
|
167
|
+
* 七个方法恰好是 sync 需要的全部写/读面——真身实现不得要求更多。
|
|
168
168
|
*/
|
|
169
169
|
export interface SyncStorePort {
|
|
170
170
|
/** 目录水位预查(stat 命中免解码的关键);无记录返回 undefined。 */
|
|
@@ -177,6 +177,14 @@ export interface SyncStorePort {
|
|
|
177
177
|
listProjectedDirs(): { dirId: string; sessionId: string }[]
|
|
178
178
|
/** 级联清除一个会话的投影(WAL 事务内 segments/fts/replaces/sessions 四表+常驻 Map)。 */
|
|
179
179
|
deleteSession(sessionId: string): void
|
|
180
|
+
/**
|
|
181
|
+
* R5(v5 字块字典水位):current=blocks 现 max(rowid, 空典=0),
|
|
182
|
+
* last=上轮持久化值(meta 缺失=0)。current<last=字典被清/换——
|
|
183
|
+
* hash 引用悬空,stat 命中也不得 skip(防静默缺块)。
|
|
184
|
+
*/
|
|
185
|
+
blocksWatermark(): { current: number; last: number }
|
|
186
|
+
/** 轮尾持久化本轮字典水位(下轮 skip 判定基线)。 */
|
|
187
|
+
setBlocksWatermark(value: number): void
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
export type SyncLog = (level: 'info' | 'warn', message: string, ...args: unknown[]) => void
|
|
@@ -252,6 +260,8 @@ interface SyncOneContext {
|
|
|
252
260
|
archivedSet: ReadonlySet<string>
|
|
253
261
|
exact: CanonicalTextTokenCounter | undefined
|
|
254
262
|
result: SyncResult
|
|
263
|
+
/** R5:本轮字典水位倒退(清/换)——stat 命中也强制重投。 */
|
|
264
|
+
dictEvicted: boolean
|
|
255
265
|
}
|
|
256
266
|
|
|
257
267
|
export class ProjectionSync {
|
|
@@ -297,13 +307,17 @@ export class ProjectionSync {
|
|
|
297
307
|
const discovered = discoverSessionLogs(dshHome, limit)
|
|
298
308
|
const exact = this.counterMode === 'off' ? undefined : resolveExactCounter()
|
|
299
309
|
const basis: SyncBasis = exact !== undefined ? 'ds-token' : 'chars/4'
|
|
310
|
+
// R5 水位对照:字典条目比上轮持久化时少=发生过驱逐(清典/换典),
|
|
311
|
+
// 而 artifact 的 size/mtime 未变——纯 stat skip 会留下悬空 hash 引用。
|
|
312
|
+
const dict = this.store.blocksWatermark()
|
|
313
|
+
const dictEvicted = dict.current < dict.last
|
|
300
314
|
const result: SyncResult = {
|
|
301
315
|
ok: true, busy: false, totalDiscovered: discovered.length,
|
|
302
316
|
updated: 0, skipped: 0, softSkips: [], failures: [], pruned: [], basis, durationMs: 0,
|
|
303
317
|
}
|
|
304
318
|
for (const found of discovered) {
|
|
305
319
|
try {
|
|
306
|
-
this.syncOne(found, { archivedSet, exact, result })
|
|
320
|
+
this.syncOne(found, { archivedSet, exact, result, dictEvicted })
|
|
307
321
|
} catch (error) {
|
|
308
322
|
result.failures.push({
|
|
309
323
|
sessionId: found.dirSessionId,
|
|
@@ -337,6 +351,8 @@ export class ProjectionSync {
|
|
|
337
351
|
}
|
|
338
352
|
}
|
|
339
353
|
result.durationMs = Date.now() - startedAt
|
|
354
|
+
// R5:轮尾持久化水位(重投已使字典增长;取循环后的现值写入)。
|
|
355
|
+
this.store.setBlocksWatermark(this.store.blocksWatermark().current)
|
|
340
356
|
this.lastResult = result
|
|
341
357
|
return result
|
|
342
358
|
} finally {
|
|
@@ -346,7 +362,8 @@ export class ProjectionSync {
|
|
|
346
362
|
|
|
347
363
|
private syncOne(found: DiscoveredSessionLog, ctx: SyncOneContext): void {
|
|
348
364
|
const record = this.store.getSessionByDir(found.dirSessionId)
|
|
349
|
-
const statHit =
|
|
365
|
+
const statHit = !ctx.dictEvicted
|
|
366
|
+
&& record !== undefined
|
|
350
367
|
&& record.artifactBytes === found.artifact.bytes
|
|
351
368
|
&& record.artifactMtimeMs === found.artifact.mtimeMs
|
|
352
369
|
// 归档翻转对 stat 命中的会话照常生效(免解码;新落库会话随 meta 携带)。
|