dsh-reference-session-collector 0.1.0 → 0.2.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/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { createHash } from "node:crypto";
4
5
  import { homedir } from "node:os";
5
6
  import zlib from "node:zlib";
6
- import { createHash } from "node:crypto";
7
7
  import { Tokenizer } from "@huggingface/tokenizers";
8
8
  //#region src/segment.ts
9
9
  /**
@@ -159,16 +159,56 @@ function parseTurnRange(raw) {
159
159
  };
160
160
  }
161
161
  /**
162
- * segments 明细。
163
- * @param turn - `turn=` 区间过滤(`a-b` 闭区间或单值)。
164
- * @param fields - `'search'` 返检索面字段(time/search_text/on_surface)。
162
+ * v5 读侧列集:hash 引用列名对(缺省原文臂 / redact=1 脱敏臂)——
163
+ * 常数字面量拼入 SQL,无注入面(R3「redact=1 切 redacted 列」)。
164
+ */
165
+ const PLAIN_FACE = {
166
+ textHash: "text_hash",
167
+ searchTextHash: "search_text_hash"
168
+ };
169
+ const REDACTED_FACE = {
170
+ textHash: "redacted_text_hash",
171
+ searchTextHash: "redacted_search_text_hash"
172
+ };
173
+ /** v4 segments DDL 15 列的还原 SELECT(v5 逐字段同形回归地基;列序一致;
174
+ * 取材列对 bt/bs/hb 由 faceJoins 按 redact 臂选定,本模板不感知)。 */
175
+ const DETAIL_COLUMNS = `s.session_id, s.seq, s.turn, s.step, s.kind, s.role, s.time,
176
+ COALESCE(bt.body, '') AS text, s.tokens,
177
+ COALESCE(bs.body, '') AS search_text,
178
+ s.on_surface, s.compressed,
179
+ CASE WHEN s.compressed = 1 THEN COALESCE(hb.body, '') ELSE NULL END AS surface_text,
180
+ s.surface_tokens, s.replaced_by`;
181
+ /** blocks/链头两跳 JOIN 子句(replaced_by 恒带 session_id,撞键防御)。 */
182
+ function faceJoins(face) {
183
+ return `FROM segments s
184
+ LEFT JOIN blocks bt ON bt.hash = s.${face.textHash}
185
+ LEFT JOIN blocks bs ON bs.hash = s.${face.searchTextHash}
186
+ LEFT JOIN segments h ON h.session_id = s.session_id AND h.seq = s.replaced_by AND s.compressed = 1
187
+ LEFT JOIN blocks hb ON hb.hash = h.${face.textHash}`;
188
+ }
189
+ /**
190
+ * segments 明细(v4 逐字段同形还原)。
191
+ * @param opts.turn - `turn=` 区间过滤(`a-b` 闭区间或单值)。
192
+ * @param opts.fields - `'search'` 返检索面字段(time/search_text/on_surface)。
193
+ * @param opts.tail - 尾窗 N:先取 seq 降序 N 行再翻升序(与会话文件真实尾部对拍)。
194
+ * @param opts.redact - 脱敏臂:text/search_text/surface_text 取 redacted_* 列。
165
195
  */
166
196
  function sessionDetail(ctx, sessionId, opts = {}) {
167
197
  const store = ctx.store;
168
198
  const session = store.summaries.get(sessionId);
199
+ const face = opts.redact === true ? REDACTED_FACE : PLAIN_FACE;
169
200
  const range = parseTurnRange(opts.turn);
170
- const where = range === void 0 ? "SELECT * FROM segments WHERE session_id=? ORDER BY seq" : "SELECT * FROM segments WHERE session_id=? AND turn BETWEEN ? AND ? ORDER BY seq";
171
- const rows = range === void 0 ? store.db.prepare(where).all(sessionId) : store.db.prepare(where).all(sessionId, range.min, range.max);
201
+ const tail = typeof opts.tail === "number" && Number.isFinite(opts.tail) && opts.tail > 0 ? Math.floor(opts.tail) : void 0;
202
+ const where = range === void 0 ? "WHERE s.session_id=?" : "WHERE s.session_id=? AND s.turn BETWEEN ? AND ?";
203
+ const params = range === void 0 ? [sessionId] : [
204
+ sessionId,
205
+ range.min,
206
+ range.max
207
+ ];
208
+ const order = tail === void 0 ? "ORDER BY s.seq ASC" : "ORDER BY s.seq DESC LIMIT ?";
209
+ if (tail !== void 0) params.push(tail);
210
+ const fetched = store.db.prepare(`SELECT ${DETAIL_COLUMNS} ${faceJoins(face)} ${where} ${order}`).all(...params);
211
+ const rows = tail === void 0 ? fetched : fetched.reverse();
172
212
  if (opts.fields !== "search") return {
173
213
  session,
174
214
  rows
@@ -194,8 +234,17 @@ function surfaceLedger(ctx, sessionId) {
194
234
  if (cached !== void 0) return cached;
195
235
  const value = {
196
236
  session_id: sessionId,
197
- on_surface: store.db.prepare("SELECT seq, turn, step, kind, role, time, tokens, text FROM segments WHERE session_id=? AND on_surface=1 ORDER BY seq").all(sessionId),
198
- compressed_pointers: store.db.prepare("SELECT seq, replaced_by, surface_text, surface_tokens FROM segments WHERE session_id=? AND compressed=1 ORDER BY seq").all(sessionId)
237
+ on_surface: store.db.prepare(`SELECT s.seq, s.turn, s.step, s.kind, s.role, s.time, s.tokens,
238
+ COALESCE(b.body, '') AS text
239
+ FROM segments s
240
+ LEFT JOIN blocks b ON b.hash = s.text_hash
241
+ WHERE s.session_id=? AND s.on_surface=1 ORDER BY s.seq`).all(sessionId),
242
+ compressed_pointers: store.db.prepare(`SELECT s.seq, s.replaced_by,
243
+ COALESCE(hb.body, '') AS surface_text, s.surface_tokens
244
+ FROM segments s
245
+ LEFT JOIN segments h ON h.session_id = s.session_id AND h.seq = s.replaced_by
246
+ LEFT JOIN blocks hb ON hb.hash = h.text_hash
247
+ WHERE s.session_id=? AND s.compressed=1 ORDER BY s.seq`).all(sessionId)
199
248
  };
200
249
  ctx.surfaceLru.set(key, value);
201
250
  return value;
@@ -239,7 +288,11 @@ function recallReport(ctx, sessionId) {
239
288
  ctx.recallLru.set(key, value);
240
289
  return value;
241
290
  }
242
- /** FTS5 BM25 内容搜索(中英同构切词,走 sanitizeFtsQuery)。 */
291
+ /**
292
+ * FTS5 BM25 内容搜索(中英同构切词,走 sanitizeFtsQuery)。
293
+ * v5:fts 仍索引原文(R3);snippet 经 blocks 还原,`redact` 只切 snippet
294
+ * 取材列(redacted_search_text_hash),命中/排序语义两臂一致。
295
+ */
243
296
  function search(ctx, query, opts = {}) {
244
297
  const match = sanitizeFtsQuery(query);
245
298
  if (match === "") return {
@@ -249,9 +302,10 @@ function search(ctx, query, opts = {}) {
249
302
  };
250
303
  const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 20)), 100);
251
304
  const sql = `SELECT segments_fts.session_id AS session_id, segments_fts.seq AS seq, segments_fts.kind AS kind,
252
- bm25(segments_fts) AS rank, substr(s.search_text, 1, 200) AS snippet, s.time, s.on_surface, s.turn
305
+ bm25(segments_fts) AS rank, substr(COALESCE(b.body, ''), 1, 200) AS snippet, s.time, s.on_surface, s.turn
253
306
  FROM segments_fts
254
307
  JOIN segments s ON s.session_id = segments_fts.session_id AND s.seq = segments_fts.seq
308
+ LEFT JOIN blocks b ON b.hash = s.${(opts.redact === true ? REDACTED_FACE : PLAIN_FACE).searchTextHash}
255
309
  WHERE segments_fts MATCH ? ${opts.session !== void 0 && opts.session !== "" ? "AND segments_fts.session_id = ?" : ""}
256
310
  ORDER BY rank LIMIT ?`;
257
311
  const stmt = ctx.store.db.prepare(sql);
@@ -297,6 +351,7 @@ function buildSearchSnippet(text, query, max = SEARCH_SNIPPET_CHARS) {
297
351
  * 会话分组全文搜索(op=search&group=session):BM25 候选窗 → 按会话取
298
352
  * 加权最强命中 → 排序分页。数学/视界与 steward engine.search 逐字同构:
299
353
  * score = −rank × weight(type),时间排序键是会话 updated_at(非命中 time)。
354
+ * snippet 锚定文本经 blocks 还原(redact 臂切 redacted_search_text_hash)。
300
355
  */
301
356
  function searchGrouped(ctx, request) {
302
357
  const match = sanitizeFtsQuery(request.query);
@@ -319,27 +374,31 @@ function searchGrouped(ctx, request) {
319
374
  dateClauses.push("AND sess.last_time_ms < ?");
320
375
  dateParams.push(request.to);
321
376
  }
377
+ const dateClause = dateClauses.join(" ");
322
378
  const sql = `SELECT seg.session_id AS session_id, seg.seq AS seq, seg.kind AS kind, seg.time AS time,
323
- seg.search_text AS search_text, bm25(segments_fts) AS rank,
379
+ COALESCE(b.body, '') AS search_text, bm25(segments_fts) AS rank,
324
380
  sess.title AS title, sess.last_time_ms AS updated_at
325
381
  FROM segments_fts
326
382
  JOIN segments seg ON seg.session_id = segments_fts.session_id AND seg.seq = segments_fts.seq
383
+ LEFT JOIN blocks b ON b.hash = seg.${(request.redact === true ? REDACTED_FACE : PLAIN_FACE).searchTextHash}
327
384
  JOIN sessions sess ON sess.session_id = seg.session_id
328
385
  WHERE segments_fts MATCH ?
329
- AND seg.kind IN (${placeholders}) ${surfaceClause} ${archivedClause} ${dateClauses.join(" ")}
386
+ AND seg.kind IN (${placeholders}) ${surfaceClause} ${archivedClause} ${dateClause}
330
387
  ORDER BY rank LIMIT ?`;
331
- const docs = ctx.store.db.prepare(sql).all(match, ...kinds, ...dateParams, SEARCH_SCAN_LIMIT);
332
- const bestBySession = /* @__PURE__ */ new Map();
333
- for (const doc of docs) {
334
- const weight = SEARCH_TYPE_WEIGHT[doc.kind] ?? 1;
335
- const score = -Number(doc.rank) * weight;
336
- const best = bestBySession.get(doc.session_id);
337
- if (best === void 0 || score > best.score) bestBySession.set(doc.session_id, {
338
- doc,
339
- score
340
- });
388
+ const scored = ctx.store.db.prepare(sql).all(match, ...kinds, ...dateParams, SEARCH_SCAN_LIMIT).map((doc) => ({
389
+ doc,
390
+ score: -Number(doc.rank) * (SEARCH_TYPE_WEIGHT[doc.kind] ?? 1)
391
+ }));
392
+ let grouped;
393
+ if (request.expand === true) grouped = scored;
394
+ else {
395
+ const bestBySession = /* @__PURE__ */ new Map();
396
+ for (const entry of scored) {
397
+ const best = bestBySession.get(entry.doc.session_id);
398
+ if (best === void 0 || entry.score > best.score) bestBySession.set(entry.doc.session_id, entry);
399
+ }
400
+ grouped = [...bestBySession.values()];
341
401
  }
342
- const grouped = [...bestBySession.values()];
343
402
  if (request.sortBy === "time") grouped.sort((a, b) => b.doc.updated_at - a.doc.updated_at || b.score - a.score);
344
403
  else grouped.sort((a, b) => b.score - a.score);
345
404
  const hits = grouped.slice(0, limit).map(({ doc }) => ({
@@ -357,22 +416,301 @@ function searchGrouped(ctx, request) {
357
416
  };
358
417
  }
359
418
  //#endregion
419
+ //#region src/blocks.ts
420
+ /**
421
+ * 字块字典(schema v5,2026-10-20 用户定标「整条一起存相同字块」):
422
+ * segments 的 text/search_text/redacted_* 一律以 sha256 全串等值 hash 入
423
+ * 本字典,行内只存引用;同文=同 hash=单份存储(INSERT OR IGNORE 幂等)。
424
+ *
425
+ * 选型依据(真机实测):surface_text 1768 行仅 23 distinct(99.2%);
426
+ * text 11% / search_text 12% 整段重复;重复组字节跨度大→前缀键/分块切分
427
+ * 会撕裂等值判定,全串 hash 是唯一可靠粒度。hash 用 node:crypto sha256
428
+ * (零新增依赖;弃 blake3)。
429
+ *
430
+ * 纪律:字典只增不删(deleteSession 清行不清块——共享块归属不可知),
431
+ * 投影可重建、体积有上界(语料 distinct 集);GC 属明示不做项。
432
+ */
433
+ /** blocks 表 DDL(单一事实源:schema.ts 与测试拼库都引本常量)。 */
434
+ const BLOCKS_DDL = `CREATE TABLE IF NOT EXISTS blocks(
435
+ hash TEXT PRIMARY KEY, body TEXT NOT NULL, bytes INTEGER NOT NULL,
436
+ created_at TEXT DEFAULT (datetime('now')));`;
437
+ /** sha256 hex(64 字符)——全串等值键。 */
438
+ function hashOf(text) {
439
+ return createHash("sha256").update(text, "utf8").digest("hex");
440
+ }
441
+ var BlocksStore = class {
442
+ insert;
443
+ constructor(db) {
444
+ this.insert = db.prepare("INSERT OR IGNORE INTO blocks(hash, body, bytes) VALUES(?,?,?)");
445
+ }
446
+ /**
447
+ * 入字典并返回引用 hash;空文本不入典返回 null(hash 列存 NULL,读侧还原
448
+ * 为 ''——「未落行文本」与「空文本」在 v4 就靠此区分,语义不变)。
449
+ * 幂等:同文重复 ensure 只落一份(INSERT OR IGNORE + hash 主键)。
450
+ */
451
+ ensure(text) {
452
+ if (text === "") return null;
453
+ const hash = hashOf(text);
454
+ this.insert.run(hash, text, Buffer.byteLength(text, "utf8"));
455
+ return hash;
456
+ }
457
+ };
458
+ //#endregion
459
+ //#region src/redact.ts
460
+ const PLACEHOLDER_VALUE_PATTERN = /^\[REDACTED:[A-Z_]+\]$/;
461
+ const SENSITIVE_KEY_SOURCE = String.raw`(?:[A-Za-z_$][A-Za-z0-9_$-]*)?(?:password|passwd|pwd|client[-_$]?secret|consumer[-_$]?secret|api[-_$]?key|access[-_$]?token|refresh[-_$]?token|auth[-_$]?token|id[-_$]?token|private[-_$]?key|secret[-_$]?access[-_$]?key|secret[-_$]?key|credential|secret|token)`;
462
+ const CREDENTIAL_VALUE_SOURCE = String.raw`(?:\[REDACTED:[A-Z_]+\]|\$\{\{[^\r\n]*?\}\}|\{\{[^\r\n]*?\}\}|\$\{[^}\r\n]+\}|\$[A-Za-z_][A-Za-z0-9_]*|"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^,;#}\]\)\r\n]+)`;
463
+ /** 安全值 allowlist:命中即放行(示例/占位/环境变量引用/类型名等,脱了只会毁记忆)。 */
464
+ const SAFE_CREDENTIAL_VALUE_PATTERN = /^(?:\[REDACTED:[A-Z_]+\]|\$\{\{[^\r\n]*\}\}|\{\{[^\r\n]*\}\}|\$\{[A-Z_][A-Z0-9_]*\}|\$[A-Z_][A-Z0-9_]*|(?:process\.)?env\.[A-Z_][A-Z0-9_]*|<[^>]+>|--\S+|x{2,}|\*{2,}|\.{3}|sk[_-]x+|example|sample|change-?me|(?:your|replace)[-_][a-z0-9_-]+|string|str|number|boolean|unknown|any|null|undefined|none)$/i;
465
+ const RULES = [
466
+ {
467
+ pattern: /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----[\s\S]*?(?:-----END \1-----|$)/gi,
468
+ kind: "PRIVATE_KEY",
469
+ priority: 100
470
+ },
471
+ {
472
+ pattern: /(?:^|[\r\n])[ \t]*(?:Proxy-)?Authorization[ \t]*:[ \t]*([^ \t\r\n][^\r\n]*)/gim,
473
+ group: 1,
474
+ kind: "AUTH_TOKEN",
475
+ priority: 95
476
+ },
477
+ {
478
+ pattern: /(?:^|[^A-Za-z0-9_-])(?:Bearer|Basic)\s+["']?([A-Za-z0-9._~+/=-]{16,})(?![A-Za-z0-9._~+/=-])/gi,
479
+ group: 1,
480
+ kind: "AUTH_TOKEN",
481
+ priority: 94
482
+ },
483
+ {
484
+ pattern: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{16,}\b/g,
485
+ kind: "AUTH_TOKEN",
486
+ priority: 94
487
+ },
488
+ {
489
+ pattern: /(?:^|[\r\n])[ \t]*(?:Set-Cookie|Cookie)\s*:\s*([^\r\n]+)/gim,
490
+ group: 1,
491
+ kind: "COOKIE",
492
+ priority: 92
493
+ },
494
+ {
495
+ pattern: new RegExp(String.raw`(?:^|[\r\n;,{}])[ \t]*(?:-[ \t]+)?(?:(?:export|const|let|var)[ \t]+)*["']?${SENSITIVE_KEY_SOURCE}["']?[ \t]*[:=][ \t]*(${CREDENTIAL_VALUE_SOURCE})`, "gim"),
496
+ group: 1,
497
+ kind: "CREDENTIAL",
498
+ priority: 85,
499
+ accept: (value) => !isSafeCredentialValue(value)
500
+ },
501
+ {
502
+ pattern: new RegExp(String.raw`--${SENSITIVE_KEY_SOURCE}(?![A-Za-z0-9_$-])(?:[ \t]+|=[ \t]*)(${CREDENTIAL_VALUE_SOURCE})`, "gi"),
503
+ group: 1,
504
+ kind: "CREDENTIAL",
505
+ priority: 85,
506
+ accept: (value) => !isSafeCredentialValue(value)
507
+ },
508
+ {
509
+ pattern: new RegExp(String.raw`[?&]${SENSITIVE_KEY_SOURCE}(?![A-Za-z0-9_$-])=([^&#\s"'<>]+)`, "gi"),
510
+ group: 1,
511
+ kind: "CREDENTIAL",
512
+ priority: 85,
513
+ accept: (value) => !isSafeCredentialValue(value)
514
+ },
515
+ {
516
+ pattern: /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/:@]*:([^\s/@]+)@/g,
517
+ group: 1,
518
+ kind: "CREDENTIAL",
519
+ priority: 85,
520
+ accept: (value) => !isSafeCredentialValue(value)
521
+ },
522
+ {
523
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}[A-Za-z0-9]\b/g,
524
+ kind: "API_KEY",
525
+ priority: 80
526
+ },
527
+ {
528
+ pattern: /\bsk_[A-Za-z0-9_-]{8,}\b/g,
529
+ kind: "API_KEY",
530
+ priority: 80
531
+ },
532
+ {
533
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
534
+ kind: "API_KEY",
535
+ priority: 80
536
+ },
537
+ {
538
+ pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
539
+ kind: "API_KEY",
540
+ priority: 80
541
+ },
542
+ {
543
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{16,}[A-Za-z0-9]\b/g,
544
+ kind: "API_KEY",
545
+ priority: 80
546
+ },
547
+ {
548
+ pattern: /\b(?:(?:sk|rk)_live_[A-Za-z0-9]{16,}|whsec_[A-Za-z0-9]{16,})\b/g,
549
+ kind: "API_KEY",
550
+ priority: 80
551
+ },
552
+ {
553
+ pattern: /\bglpat-[A-Za-z0-9_-]{16,}[A-Za-z0-9]\b/g,
554
+ kind: "API_KEY",
555
+ priority: 80
556
+ },
557
+ {
558
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
559
+ kind: "API_KEY",
560
+ priority: 80
561
+ },
562
+ {
563
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
564
+ kind: "API_KEY",
565
+ priority: 80
566
+ },
567
+ {
568
+ pattern: /(?<![A-Z0-9._%+-])[A-Z0-9._%+-]{1,64}@[A-Z0-9](?:[A-Z0-9.-]{0,251}[A-Z0-9])?\.[A-Z]{2,63}(?![A-Z0-9-]|\.[A-Z0-9])/gi,
569
+ kind: "EMAIL",
570
+ priority: 70
571
+ },
572
+ {
573
+ pattern: /(?<![A-Za-z0-9])[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}(?![A-Za-z0-9])/g,
574
+ kind: "OPAQUE_ID",
575
+ priority: 60
576
+ },
577
+ {
578
+ pattern: /(?<![A-Za-z0-9])(?:[0-9A-Fa-f]{64}|[0-9A-Fa-f]{40}|[0-9A-Fa-f]{32})(?![A-Za-z0-9])/g,
579
+ kind: "OPAQUE_ID",
580
+ priority: 60,
581
+ accept: (value) => /[A-Fa-f]/.test(value)
582
+ },
583
+ {
584
+ pattern: /(?<![A-Za-z0-9])[A-Za-z0-9]{24,}(?![A-Za-z0-9])/g,
585
+ kind: "OPAQUE_ID",
586
+ priority: 60,
587
+ accept: looksLikeHighEntropyOpaqueId
588
+ },
589
+ {
590
+ pattern: /(?<![A-Za-z0-9.])\+?(?:\d|\(\d)[\d ()-]*\d[Xx]?\)?(?![A-Za-z0-9]|\.\d)/g,
591
+ kind: "LONG_NUMBER",
592
+ priority: 50,
593
+ accept: (value) => {
594
+ if (value.replace(/\D/g, "").length < 9) return false;
595
+ const normalized = value.trim().replace(/[()]/g, "");
596
+ return !/^(?:19|20)\d{2}-\d{1,2}-\d{1,2}(?:[ T-]\d{1,2})?$/.test(normalized) && !/^(?:19|20)\d{2}\s+\d{1,2}\s+\d{1,2}(?:\s+\d{1,2})?$/.test(normalized);
597
+ }
598
+ }
599
+ ];
600
+ /**
601
+ * 对文本执行 8 类脱敏。规则各自扫描 → 收集值级 span → 合并重叠 → 重拼插入占位符。
602
+ * 同一位置被多条规则命中时取更长/更高优先级的 span。
603
+ */
604
+ function redactSecrets(text) {
605
+ if (!text) return {
606
+ text,
607
+ counts: {},
608
+ redacted: false
609
+ };
610
+ const spans = [];
611
+ for (const rule of RULES) collectRuleSpans(text, rule, spans);
612
+ const selected = mergeOverlappingSpans(spans);
613
+ if (selected.length === 0) return {
614
+ text,
615
+ counts: {},
616
+ redacted: false
617
+ };
618
+ const counts = {};
619
+ const output = [];
620
+ let offset = 0;
621
+ for (const span of selected) {
622
+ output.push(text.slice(offset, span.start), `[REDACTED:${span.kind}]`);
623
+ counts[span.kind] = (counts[span.kind] ?? 0) + 1;
624
+ offset = span.end;
625
+ }
626
+ output.push(text.slice(offset));
627
+ return {
628
+ text: output.join(""),
629
+ counts,
630
+ redacted: true
631
+ };
632
+ }
633
+ function collectRuleSpans(text, rule, spans) {
634
+ rule.pattern.lastIndex = 0;
635
+ for (const match of text.matchAll(rule.pattern)) {
636
+ const value = match[rule.group ?? 0];
637
+ if (match.index === void 0 || !value || PLACEHOLDER_VALUE_PATTERN.test(value.trim())) continue;
638
+ if (rule.accept && !rule.accept(value)) continue;
639
+ const relativeStart = match[0].lastIndexOf(value);
640
+ if (relativeStart < 0) continue;
641
+ spans.push({
642
+ start: match.index + relativeStart,
643
+ end: match.index + relativeStart + value.length,
644
+ kind: rule.kind,
645
+ priority: rule.priority
646
+ });
647
+ }
648
+ }
649
+ function isSafeCredentialValue(rawValue) {
650
+ let value = rawValue.trim();
651
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1).trim();
652
+ return !value || SAFE_CREDENTIAL_VALUE_PATTERN.test(value);
653
+ }
654
+ /** 高熵判定:小写≥2 ∧ 大写≥2 ∧ 数字≥2 ∧ 字符类切换≥4 ∧ 最长连续字母段≤5 ∧ 去重字符≥12。 */
655
+ function looksLikeHighEntropyOpaqueId(value) {
656
+ let lowercase = 0;
657
+ let uppercase = 0;
658
+ let digits = 0;
659
+ let transitions = 0;
660
+ let letterRun = 0;
661
+ let longestLetterRun = 0;
662
+ let previousWasDigit;
663
+ const unique = /* @__PURE__ */ new Set();
664
+ for (const character of value) {
665
+ const isDigit = character >= "0" && character <= "9";
666
+ if (isDigit) {
667
+ digits += 1;
668
+ letterRun = 0;
669
+ } else {
670
+ if (character >= "a" && character <= "z") lowercase += 1;
671
+ else uppercase += 1;
672
+ letterRun += 1;
673
+ longestLetterRun = Math.max(longestLetterRun, letterRun);
674
+ }
675
+ if (previousWasDigit !== void 0 && previousWasDigit !== isDigit) transitions += 1;
676
+ previousWasDigit = isDigit;
677
+ unique.add(character);
678
+ }
679
+ return lowercase >= 2 && uppercase >= 2 && digits >= 2 && transitions >= 4 && longestLetterRun <= 5 && unique.size >= 12;
680
+ }
681
+ function mergeOverlappingSpans(spans) {
682
+ const sorted = [...spans].sort((left, right) => left.start - right.start || right.end - left.end || right.priority - left.priority);
683
+ const merged = [];
684
+ for (const span of sorted) {
685
+ const previous = merged.at(-1);
686
+ if (!previous || span.start >= previous.end) {
687
+ merged.push({ ...span });
688
+ continue;
689
+ }
690
+ previous.end = Math.max(previous.end, span.end);
691
+ }
692
+ return merged;
693
+ }
694
+ //#endregion
360
695
  //#region src/schema.ts
361
696
  /**
362
- * 投影库装配(spec §DDL):node:sqlite 四表一虚表 + WAL/mmap 缓存层 1 +
363
- * sessions 汇总常驻 Map(缓存层 2)。写纪律:每 session 全量重写式替换
364
- * (事务内 DELETE+INSERT),幂等。
697
+ * 投影库装配(spec §DDL):node:sqlite 五表一虚表(sessions/segments/replaces/
698
+ * blocks/meta + segments_fts)+ WAL/mmap 缓存层 1 + sessions 汇总常驻 Map
699
+ * (缓存层 2)。写纪律:每 session 全量重写式替换(事务内 DELETE+INSERT),
700
+ * 幂等;字块一律走 blocks 字典 hash 引用(v5),脱敏派生臂同源生成。
365
701
  *
366
702
  * 本库只落在 ~/.dsh-reference-session-collector/projection.db ——绝不触碰会话文件。
367
703
  */
368
704
  /** schema 版本(meta 表;断裂式迁移的比对基线)。
369
705
  * v2=加 sessions.format_generation;
370
- * v3=search_text 视界变更(剥离宿主注入样板段,strip-injected horizon v2);
371
- * v4=账目双修(真机取证 2026-10-20):① text/tokens 视界补 data.content 双臂
372
- * (checkpoint 替身/用户消息旧视界全计 0);② replaces 加 live 列——替身行
373
- * 被后续区间再覆盖时 delta 清除不入 gross(用户定标:checkpoint 只影响其后
374
- * 的积分)。旧行两项口径均错,整库清空重建(投影可重建,丢行无损)。 */
375
- const PROJECTION_SCHEMA_VERSION = "4";
706
+ * v3=search_text 视界变更(剥离宿主注入样板段);
707
+ * v4=账目双修(data.content 双臂 + replaces.live);
708
+ * v5=字块共享 + 脱敏派生臂(2026-10-20 用户定标):text/search_text/
709
+ * surface_text 实列退役→blocks 字典 hash 引用(surface 经 replaced_by
710
+ * 链派生);新 redacted_text_hash/redacted_search_text_hash(redactSecrets
711
+ * 派生臂,CCI 口径 text 的 token 数学不动);旧库列形状断裂→DROP 重建
712
+ * + VACUUM(投影可重建,丢行无损)。 */
713
+ const PROJECTION_SCHEMA_VERSION = "5";
376
714
  const DDL = `
377
715
  CREATE TABLE IF NOT EXISTS sessions(
378
716
  session_id TEXT PRIMARY KEY, dir_id TEXT NOT NULL, title TEXT DEFAULT '',
@@ -391,11 +729,12 @@ CREATE TABLE IF NOT EXISTS segments(
391
729
  session_id TEXT NOT NULL, seq INTEGER NOT NULL, turn INTEGER DEFAULT 0,
392
730
  step INTEGER DEFAULT 0, kind TEXT NOT NULL, role TEXT DEFAULT '',
393
731
  time INTEGER DEFAULT 0,
394
- text TEXT DEFAULT '', tokens INTEGER DEFAULT 0,
395
- search_text TEXT DEFAULT '',
732
+ text_hash TEXT, tokens INTEGER DEFAULT 0,
733
+ search_text_hash TEXT,
734
+ redacted_text_hash TEXT, redacted_search_text_hash TEXT,
396
735
  on_surface INTEGER DEFAULT 1,
397
736
  compressed INTEGER DEFAULT 0,
398
- surface_text TEXT, surface_tokens INTEGER, replaced_by INTEGER,
737
+ surface_tokens INTEGER, replaced_by INTEGER,
399
738
  PRIMARY KEY(session_id, seq)) WITHOUT ROWID;
400
739
  CREATE INDEX IF NOT EXISTS idx_segments_turn ON segments(session_id, turn, step);
401
740
  CREATE TABLE IF NOT EXISTS replaces(
@@ -410,6 +749,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS segments_fts USING fts5(
410
749
  index_text, session_id UNINDEXED, seq UNINDEXED, kind UNINDEXED,
411
750
  tokenize='unicode61');
412
751
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
752
+ ${BLOCKS_DDL}
413
753
  `;
414
754
  function summaryToRow(s, artifactBytes, artifactMtimeMs, extra) {
415
755
  return {
@@ -451,24 +791,21 @@ PRAGMA cache_size=-32000;
451
791
  PRAGMA mmap_size=268435456;
452
792
  PRAGMA application_id=0x44525343;
453
793
  `);
454
- db.exec(DDL);
455
- if (!db.prepare("PRAGMA table_info(sessions)").all().some((c) => c.name === "format_generation")) db.exec("ALTER TABLE sessions ADD COLUMN format_generation INTEGER DEFAULT -1");
456
- if (!db.prepare("PRAGMA table_info(replaces)").all().some((c) => c.name === "live")) db.exec("ALTER TABLE replaces ADD COLUMN live INTEGER NOT NULL DEFAULT 1");
457
- const previous = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
458
- if (previous !== void 0 && previous.value !== "4") {
459
- db.exec("BEGIN");
460
- try {
461
- db.prepare("DELETE FROM sessions").run();
462
- db.prepare("DELETE FROM segments").run();
463
- db.prepare("DELETE FROM replaces").run();
464
- db.prepare("DELETE FROM segments_fts").run();
465
- db.exec("COMMIT");
466
- } catch (error) {
467
- db.exec("ROLLBACK");
468
- throw error;
469
- }
794
+ let previous;
795
+ try {
796
+ previous = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
797
+ } catch {
798
+ previous = void 0;
470
799
  }
471
- db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run("schema_version", "4");
800
+ if (previous !== void 0 && previous.value !== "5") db.exec(`DROP TABLE IF EXISTS segments_fts;
801
+ DROP TABLE IF EXISTS segments;
802
+ DROP TABLE IF EXISTS replaces;
803
+ DROP TABLE IF EXISTS sessions;
804
+ DROP TABLE IF EXISTS blocks;
805
+ DROP TABLE IF EXISTS meta`);
806
+ db.exec(DDL);
807
+ if (previous !== void 0 && previous.value !== "5") db.exec("VACUUM");
808
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run("schema_version", "5");
472
809
  const summaries = /* @__PURE__ */ new Map();
473
810
  const loadAll = () => {
474
811
  summaries.clear();
@@ -477,9 +814,11 @@ PRAGMA application_id=0x44525343;
477
814
  };
478
815
  loadAll();
479
816
  const insertSegment = db.prepare(`INSERT INTO segments(
480
- session_id, seq, turn, step, kind, role, time, text, tokens, search_text,
481
- on_surface, compressed, surface_text, surface_tokens, replaced_by)
482
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
817
+ session_id, seq, turn, step, kind, role, time, text_hash, tokens, search_text_hash,
818
+ redacted_text_hash, redacted_search_text_hash,
819
+ on_surface, compressed, surface_tokens, replaced_by)
820
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
821
+ const blocks = new BlocksStore(db);
483
822
  const insertReplace = db.prepare(`INSERT INTO replaces(
484
823
  session_id, seq, start_seq, end_seq, a_tokens, b_tokens, kind, turn, step, live)
485
824
  VALUES(?,?,?,?,?,?,?,?,?,?)`);
@@ -511,7 +850,11 @@ PRAGMA application_id=0x44525343;
511
850
  db.prepare("DELETE FROM segments WHERE session_id=?").run(row.session_id);
512
851
  db.prepare("DELETE FROM replaces WHERE session_id=?").run(row.session_id);
513
852
  db.prepare("DELETE FROM segments_fts WHERE session_id=?").run(row.session_id);
514
- for (const seg of segments) insertSegment.run(row.session_id, seg.seq, seg.turn, seg.step, seg.kind, seg.role, seg.time, seg.text, seg.tokens, seg.searchText, seg.onSurface, seg.compressed, seg.surfaceText, seg.surfaceTokens, seg.replacedBy);
853
+ for (const seg of segments) {
854
+ const redText = redactSecrets(seg.text).text;
855
+ const redSearch = redactSecrets(seg.searchText).text;
856
+ insertSegment.run(row.session_id, seg.seq, seg.turn, seg.step, seg.kind, seg.role, seg.time, blocks.ensure(seg.text), seg.tokens, blocks.ensure(seg.searchText), blocks.ensure(redText), blocks.ensure(redSearch), seg.onSurface, seg.compressed, seg.surfaceTokens, seg.replacedBy);
857
+ }
515
858
  for (const rep of replaces) insertReplace.run(row.session_id, rep.seq, rep.startSeq, rep.endSeq, rep.aTokens, rep.bTokens, rep.kind, rep.turn, rep.step, rep.live);
516
859
  for (const seg of segments) {
517
860
  if (seg.searchText === "") continue;
@@ -1656,6 +1999,8 @@ var ProjectionSync = class {
1656
1999
  const discovered = discoverSessionLogs(dshHome, limit);
1657
2000
  const exact = this.counterMode === "off" ? void 0 : resolveExactCounter();
1658
2001
  const basis = exact !== void 0 ? "ds-token" : "chars/4";
2002
+ const dict = this.store.blocksWatermark();
2003
+ const dictEvicted = dict.current < dict.last;
1659
2004
  const result = {
1660
2005
  ok: true,
1661
2006
  busy: false,
@@ -1673,7 +2018,8 @@ var ProjectionSync = class {
1673
2018
  this.syncOne(found, {
1674
2019
  archivedSet,
1675
2020
  exact,
1676
- result
2021
+ result,
2022
+ dictEvicted
1677
2023
  });
1678
2024
  } catch (error) {
1679
2025
  result.failures.push({
@@ -1704,6 +2050,7 @@ var ProjectionSync = class {
1704
2050
  }
1705
2051
  }
1706
2052
  result.durationMs = Date.now() - startedAt;
2053
+ this.store.setBlocksWatermark(this.store.blocksWatermark().current);
1707
2054
  this.lastResult = result;
1708
2055
  return result;
1709
2056
  } finally {
@@ -1712,7 +2059,7 @@ var ProjectionSync = class {
1712
2059
  }
1713
2060
  syncOne(found, ctx) {
1714
2061
  const record = this.store.getSessionByDir(found.dirSessionId);
1715
- const statHit = record !== void 0 && record.artifactBytes === found.artifact.bytes && record.artifactMtimeMs === found.artifact.mtimeMs;
2062
+ const statHit = !ctx.dictEvicted && record !== void 0 && record.artifactBytes === found.artifact.bytes && record.artifactMtimeMs === found.artifact.mtimeMs;
1716
2063
  if (record !== void 0) {
1717
2064
  const archived = ctx.archivedSet.has(record.sessionId) ? 1 : 0;
1718
2065
  if (record.archived !== archived) this.store.setSessionArchived(record.sessionId, archived);
@@ -1810,6 +2157,8 @@ function isTrustedRequest(req, trustedHosts) {
1810
2157
  if (typeof host !== "string") return false;
1811
2158
  return trustedHosts.some((trusted) => trusted.toLowerCase() === host.toLowerCase());
1812
2159
  }
2160
+ /** R5 水位在 meta 表的键(DDL 冻结不动,kv 面落)。 */
2161
+ const BLOCKS_WATERMARK_META_KEY = "blocks_watermark";
1813
2162
  /** sync 端口 → ProjectionStore 真身适配(dir 水位经常驻 Map 扫描)。 */
1814
2163
  function createStorePort(store) {
1815
2164
  return {
@@ -1840,6 +2189,18 @@ function createStorePort(store) {
1840
2189
  },
1841
2190
  deleteSession(sessionId) {
1842
2191
  store.deleteSession(sessionId);
2192
+ },
2193
+ blocksWatermark() {
2194
+ const current = Number(store.db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM blocks").get().m);
2195
+ const row = store.db.prepare("SELECT value FROM meta WHERE key=?").get(BLOCKS_WATERMARK_META_KEY);
2196
+ const parsed = row === void 0 ? 0 : Number(row.value);
2197
+ return {
2198
+ current,
2199
+ last: Number.isFinite(parsed) ? parsed : 0
2200
+ };
2201
+ },
2202
+ setBlocksWatermark(value) {
2203
+ store.db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(BLOCKS_WATERMARK_META_KEY, String(value));
1843
2204
  }
1844
2205
  };
1845
2206
  }
@@ -1859,6 +2220,20 @@ function resolveLatestSessionId(query) {
1859
2220
  return bestId;
1860
2221
  }
1861
2222
  /**
2223
+ * v5 参数解析(R3/R4):`redact` 仅 '1' 为真,其余/非法=0(缺省原文臂);
2224
+ * `tail` 正整数生效,非法/≤0=忽略(全量)。
2225
+ */
2226
+ function parseRedact(url) {
2227
+ return url.searchParams.get("redact") === "1";
2228
+ }
2229
+ function parseTail(url) {
2230
+ const raw = url.searchParams.get("tail");
2231
+ if (raw === null) return void 0;
2232
+ const parsed = Number(raw);
2233
+ if (!Number.isFinite(parsed) || parsed <= 0) return void 0;
2234
+ return Math.floor(parsed);
2235
+ }
2236
+ /**
1862
2237
  * op 分发:GET `?op=…`;POST `{op:'sync'}` 触发后台同步。
1863
2238
  * 未知 op / 缺参 → 400(不留空壳)。
1864
2239
  */
@@ -1924,9 +2299,13 @@ async function handleDrsRequest(deps, req, res) {
1924
2299
  }
1925
2300
  const turn = url.searchParams.get("turn") ?? void 0;
1926
2301
  const fields = url.searchParams.get("fields") ?? void 0;
2302
+ const redact = parseRedact(url);
2303
+ const tail = parseTail(url);
1927
2304
  const detail = sessionDetail(deps.query, sessionId, {
1928
2305
  ...turn === void 0 ? {} : { turn },
1929
- ...fields === void 0 ? {} : { fields }
2306
+ ...fields === void 0 ? {} : { fields },
2307
+ ...tail === void 0 ? {} : { tail },
2308
+ ...redact ? { redact: true } : {}
1930
2309
  });
1931
2310
  if (detail.session === void 0) {
1932
2311
  writeJson(res, 200, {
@@ -1993,6 +2372,7 @@ async function handleDrsRequest(deps, req, res) {
1993
2372
  });
1994
2373
  return;
1995
2374
  }
2375
+ const redact = parseRedact(url);
1996
2376
  if (url.searchParams.get("group") === "session") {
1997
2377
  const kindsRaw = url.searchParams.get("kinds") ?? "";
1998
2378
  const archivedRaw = url.searchParams.get("archived");
@@ -2009,7 +2389,9 @@ async function handleDrsRequest(deps, req, res) {
2009
2389
  ...toRaw !== null && Number.isFinite(Number(toRaw)) ? { to: Number(toRaw) } : {},
2010
2390
  ...url.searchParams.get("surface") === "0" ? { surfaceOnly: false } : {},
2011
2391
  ...url.searchParams.get("sort") === "time" ? { sortBy: "time" } : {},
2012
- ...limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {}
2392
+ ...limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {},
2393
+ ...url.searchParams.get("expand") === "1" ? { expand: true } : {},
2394
+ ...redact ? { redact: true } : {}
2013
2395
  })
2014
2396
  });
2015
2397
  return;
@@ -2019,7 +2401,8 @@ async function handleDrsRequest(deps, req, res) {
2019
2401
  ok: true,
2020
2402
  ...search(deps.query, q, {
2021
2403
  ...sessionId === "" ? {} : { session: sessionId },
2022
- ...limitRaw === null ? {} : { limit: Number(limitRaw) }
2404
+ ...limitRaw === null ? {} : { limit: Number(limitRaw) },
2405
+ ...redact ? { redact: true } : {}
2023
2406
  })
2024
2407
  });
2025
2408
  return;