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/src/surface.ts CHANGED
@@ -1,12 +1,21 @@
1
1
  /**
2
- * 查询面(T4.1):status / listSessions / sessionDetail / surfaceLedger /
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
- * sessionDetail 支持 `turn=` 区间过滤与 `fields=search`(返回 time/
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
- * 此臂);search 查询走 segment.ts 同构切词(中文可用)。
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
- * segments 明细。
130
- * @param turn - `turn=` 区间过滤(`a-b` 闭区间或单值)。
131
- * @param fields - `'search'` 返检索面字段(time/search_text/on_surface)。
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: { turn?: string; fields?: string } = {},
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
- ? 'SELECT * FROM segments WHERE session_id=? ORDER BY seq'
143
- : 'SELECT * FROM segments WHERE session_id=? AND turn BETWEEN ? AND ? ORDER BY seq'
144
- const rows = (range === undefined
145
- ? store.db.prepare(where).all(sessionId)
146
- : store.db.prepare(where).all(sessionId, range.min, range.max)) as unknown as Record<string, unknown>[]
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
- 'SELECT seq, turn, step, kind, role, time, tokens, text FROM segments WHERE session_id=? AND on_surface=1 ORDER BY seq',
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
- 'SELECT seq, replaced_by, surface_text, surface_tokens FROM segments WHERE session_id=? AND compressed=1 ORDER BY seq',
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
- /** FTS5 BM25 内容搜索(中英同构切词,走 sanitizeFtsQuery)。 */
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(s.search_text, 1, 200) AS snippet, s.time, s.on_surface, s.turn
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
- seg.search_text AS search_text, bm25(segments_fts) AS rank,
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
- * 五个方法恰好是 sync 需要的全部写/读面——真身实现不得要求更多。
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 = record !== undefined
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 携带)。