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/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,14 +374,16 @@ 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
388
  const docs = ctx.store.db.prepare(sql).all(match, ...kinds, ...dateParams, SEARCH_SCAN_LIMIT);
332
389
  const bestBySession = /* @__PURE__ */ new Map();
@@ -357,22 +414,301 @@ function searchGrouped(ctx, request) {
357
414
  };
358
415
  }
359
416
  //#endregion
417
+ //#region src/blocks.ts
418
+ /**
419
+ * 字块字典(schema v5,2026-10-20 用户定标「整条一起存相同字块」):
420
+ * segments 的 text/search_text/redacted_* 一律以 sha256 全串等值 hash 入
421
+ * 本字典,行内只存引用;同文=同 hash=单份存储(INSERT OR IGNORE 幂等)。
422
+ *
423
+ * 选型依据(真机实测):surface_text 1768 行仅 23 distinct(99.2%);
424
+ * text 11% / search_text 12% 整段重复;重复组字节跨度大→前缀键/分块切分
425
+ * 会撕裂等值判定,全串 hash 是唯一可靠粒度。hash 用 node:crypto sha256
426
+ * (零新增依赖;弃 blake3)。
427
+ *
428
+ * 纪律:字典只增不删(deleteSession 清行不清块——共享块归属不可知),
429
+ * 投影可重建、体积有上界(语料 distinct 集);GC 属明示不做项。
430
+ */
431
+ /** blocks 表 DDL(单一事实源:schema.ts 与测试拼库都引本常量)。 */
432
+ const BLOCKS_DDL = `CREATE TABLE IF NOT EXISTS blocks(
433
+ hash TEXT PRIMARY KEY, body TEXT NOT NULL, bytes INTEGER NOT NULL,
434
+ created_at TEXT DEFAULT (datetime('now')));`;
435
+ /** sha256 hex(64 字符)——全串等值键。 */
436
+ function hashOf(text) {
437
+ return createHash("sha256").update(text, "utf8").digest("hex");
438
+ }
439
+ var BlocksStore = class {
440
+ insert;
441
+ constructor(db) {
442
+ this.insert = db.prepare("INSERT OR IGNORE INTO blocks(hash, body, bytes) VALUES(?,?,?)");
443
+ }
444
+ /**
445
+ * 入字典并返回引用 hash;空文本不入典返回 null(hash 列存 NULL,读侧还原
446
+ * 为 ''——「未落行文本」与「空文本」在 v4 就靠此区分,语义不变)。
447
+ * 幂等:同文重复 ensure 只落一份(INSERT OR IGNORE + hash 主键)。
448
+ */
449
+ ensure(text) {
450
+ if (text === "") return null;
451
+ const hash = hashOf(text);
452
+ this.insert.run(hash, text, Buffer.byteLength(text, "utf8"));
453
+ return hash;
454
+ }
455
+ };
456
+ //#endregion
457
+ //#region src/redact.ts
458
+ const PLACEHOLDER_VALUE_PATTERN = /^\[REDACTED:[A-Z_]+\]$/;
459
+ 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)`;
460
+ 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]+)`;
461
+ /** 安全值 allowlist:命中即放行(示例/占位/环境变量引用/类型名等,脱了只会毁记忆)。 */
462
+ 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;
463
+ const RULES = [
464
+ {
465
+ pattern: /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----[\s\S]*?(?:-----END \1-----|$)/gi,
466
+ kind: "PRIVATE_KEY",
467
+ priority: 100
468
+ },
469
+ {
470
+ pattern: /(?:^|[\r\n])[ \t]*(?:Proxy-)?Authorization[ \t]*:[ \t]*([^ \t\r\n][^\r\n]*)/gim,
471
+ group: 1,
472
+ kind: "AUTH_TOKEN",
473
+ priority: 95
474
+ },
475
+ {
476
+ pattern: /(?:^|[^A-Za-z0-9_-])(?:Bearer|Basic)\s+["']?([A-Za-z0-9._~+/=-]{16,})(?![A-Za-z0-9._~+/=-])/gi,
477
+ group: 1,
478
+ kind: "AUTH_TOKEN",
479
+ priority: 94
480
+ },
481
+ {
482
+ pattern: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{16,}\b/g,
483
+ kind: "AUTH_TOKEN",
484
+ priority: 94
485
+ },
486
+ {
487
+ pattern: /(?:^|[\r\n])[ \t]*(?:Set-Cookie|Cookie)\s*:\s*([^\r\n]+)/gim,
488
+ group: 1,
489
+ kind: "COOKIE",
490
+ priority: 92
491
+ },
492
+ {
493
+ pattern: new RegExp(String.raw`(?:^|[\r\n;,{}])[ \t]*(?:-[ \t]+)?(?:(?:export|const|let|var)[ \t]+)*["']?${SENSITIVE_KEY_SOURCE}["']?[ \t]*[:=][ \t]*(${CREDENTIAL_VALUE_SOURCE})`, "gim"),
494
+ group: 1,
495
+ kind: "CREDENTIAL",
496
+ priority: 85,
497
+ accept: (value) => !isSafeCredentialValue(value)
498
+ },
499
+ {
500
+ pattern: new RegExp(String.raw`--${SENSITIVE_KEY_SOURCE}(?![A-Za-z0-9_$-])(?:[ \t]+|=[ \t]*)(${CREDENTIAL_VALUE_SOURCE})`, "gi"),
501
+ group: 1,
502
+ kind: "CREDENTIAL",
503
+ priority: 85,
504
+ accept: (value) => !isSafeCredentialValue(value)
505
+ },
506
+ {
507
+ pattern: new RegExp(String.raw`[?&]${SENSITIVE_KEY_SOURCE}(?![A-Za-z0-9_$-])=([^&#\s"'<>]+)`, "gi"),
508
+ group: 1,
509
+ kind: "CREDENTIAL",
510
+ priority: 85,
511
+ accept: (value) => !isSafeCredentialValue(value)
512
+ },
513
+ {
514
+ pattern: /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/:@]*:([^\s/@]+)@/g,
515
+ group: 1,
516
+ kind: "CREDENTIAL",
517
+ priority: 85,
518
+ accept: (value) => !isSafeCredentialValue(value)
519
+ },
520
+ {
521
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}[A-Za-z0-9]\b/g,
522
+ kind: "API_KEY",
523
+ priority: 80
524
+ },
525
+ {
526
+ pattern: /\bsk_[A-Za-z0-9_-]{8,}\b/g,
527
+ kind: "API_KEY",
528
+ priority: 80
529
+ },
530
+ {
531
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
532
+ kind: "API_KEY",
533
+ priority: 80
534
+ },
535
+ {
536
+ pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
537
+ kind: "API_KEY",
538
+ priority: 80
539
+ },
540
+ {
541
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{16,}[A-Za-z0-9]\b/g,
542
+ kind: "API_KEY",
543
+ priority: 80
544
+ },
545
+ {
546
+ pattern: /\b(?:(?:sk|rk)_live_[A-Za-z0-9]{16,}|whsec_[A-Za-z0-9]{16,})\b/g,
547
+ kind: "API_KEY",
548
+ priority: 80
549
+ },
550
+ {
551
+ pattern: /\bglpat-[A-Za-z0-9_-]{16,}[A-Za-z0-9]\b/g,
552
+ kind: "API_KEY",
553
+ priority: 80
554
+ },
555
+ {
556
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
557
+ kind: "API_KEY",
558
+ priority: 80
559
+ },
560
+ {
561
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
562
+ kind: "API_KEY",
563
+ priority: 80
564
+ },
565
+ {
566
+ 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,
567
+ kind: "EMAIL",
568
+ priority: 70
569
+ },
570
+ {
571
+ 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,
572
+ kind: "OPAQUE_ID",
573
+ priority: 60
574
+ },
575
+ {
576
+ 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,
577
+ kind: "OPAQUE_ID",
578
+ priority: 60,
579
+ accept: (value) => /[A-Fa-f]/.test(value)
580
+ },
581
+ {
582
+ pattern: /(?<![A-Za-z0-9])[A-Za-z0-9]{24,}(?![A-Za-z0-9])/g,
583
+ kind: "OPAQUE_ID",
584
+ priority: 60,
585
+ accept: looksLikeHighEntropyOpaqueId
586
+ },
587
+ {
588
+ pattern: /(?<![A-Za-z0-9.])\+?(?:\d|\(\d)[\d ()-]*\d[Xx]?\)?(?![A-Za-z0-9]|\.\d)/g,
589
+ kind: "LONG_NUMBER",
590
+ priority: 50,
591
+ accept: (value) => {
592
+ if (value.replace(/\D/g, "").length < 9) return false;
593
+ const normalized = value.trim().replace(/[()]/g, "");
594
+ 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);
595
+ }
596
+ }
597
+ ];
598
+ /**
599
+ * 对文本执行 8 类脱敏。规则各自扫描 → 收集值级 span → 合并重叠 → 重拼插入占位符。
600
+ * 同一位置被多条规则命中时取更长/更高优先级的 span。
601
+ */
602
+ function redactSecrets(text) {
603
+ if (!text) return {
604
+ text,
605
+ counts: {},
606
+ redacted: false
607
+ };
608
+ const spans = [];
609
+ for (const rule of RULES) collectRuleSpans(text, rule, spans);
610
+ const selected = mergeOverlappingSpans(spans);
611
+ if (selected.length === 0) return {
612
+ text,
613
+ counts: {},
614
+ redacted: false
615
+ };
616
+ const counts = {};
617
+ const output = [];
618
+ let offset = 0;
619
+ for (const span of selected) {
620
+ output.push(text.slice(offset, span.start), `[REDACTED:${span.kind}]`);
621
+ counts[span.kind] = (counts[span.kind] ?? 0) + 1;
622
+ offset = span.end;
623
+ }
624
+ output.push(text.slice(offset));
625
+ return {
626
+ text: output.join(""),
627
+ counts,
628
+ redacted: true
629
+ };
630
+ }
631
+ function collectRuleSpans(text, rule, spans) {
632
+ rule.pattern.lastIndex = 0;
633
+ for (const match of text.matchAll(rule.pattern)) {
634
+ const value = match[rule.group ?? 0];
635
+ if (match.index === void 0 || !value || PLACEHOLDER_VALUE_PATTERN.test(value.trim())) continue;
636
+ if (rule.accept && !rule.accept(value)) continue;
637
+ const relativeStart = match[0].lastIndexOf(value);
638
+ if (relativeStart < 0) continue;
639
+ spans.push({
640
+ start: match.index + relativeStart,
641
+ end: match.index + relativeStart + value.length,
642
+ kind: rule.kind,
643
+ priority: rule.priority
644
+ });
645
+ }
646
+ }
647
+ function isSafeCredentialValue(rawValue) {
648
+ let value = rawValue.trim();
649
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1).trim();
650
+ return !value || SAFE_CREDENTIAL_VALUE_PATTERN.test(value);
651
+ }
652
+ /** 高熵判定:小写≥2 ∧ 大写≥2 ∧ 数字≥2 ∧ 字符类切换≥4 ∧ 最长连续字母段≤5 ∧ 去重字符≥12。 */
653
+ function looksLikeHighEntropyOpaqueId(value) {
654
+ let lowercase = 0;
655
+ let uppercase = 0;
656
+ let digits = 0;
657
+ let transitions = 0;
658
+ let letterRun = 0;
659
+ let longestLetterRun = 0;
660
+ let previousWasDigit;
661
+ const unique = /* @__PURE__ */ new Set();
662
+ for (const character of value) {
663
+ const isDigit = character >= "0" && character <= "9";
664
+ if (isDigit) {
665
+ digits += 1;
666
+ letterRun = 0;
667
+ } else {
668
+ if (character >= "a" && character <= "z") lowercase += 1;
669
+ else uppercase += 1;
670
+ letterRun += 1;
671
+ longestLetterRun = Math.max(longestLetterRun, letterRun);
672
+ }
673
+ if (previousWasDigit !== void 0 && previousWasDigit !== isDigit) transitions += 1;
674
+ previousWasDigit = isDigit;
675
+ unique.add(character);
676
+ }
677
+ return lowercase >= 2 && uppercase >= 2 && digits >= 2 && transitions >= 4 && longestLetterRun <= 5 && unique.size >= 12;
678
+ }
679
+ function mergeOverlappingSpans(spans) {
680
+ const sorted = [...spans].sort((left, right) => left.start - right.start || right.end - left.end || right.priority - left.priority);
681
+ const merged = [];
682
+ for (const span of sorted) {
683
+ const previous = merged.at(-1);
684
+ if (!previous || span.start >= previous.end) {
685
+ merged.push({ ...span });
686
+ continue;
687
+ }
688
+ previous.end = Math.max(previous.end, span.end);
689
+ }
690
+ return merged;
691
+ }
692
+ //#endregion
360
693
  //#region src/schema.ts
361
694
  /**
362
- * 投影库装配(spec §DDL):node:sqlite 四表一虚表 + WAL/mmap 缓存层 1 +
363
- * sessions 汇总常驻 Map(缓存层 2)。写纪律:每 session 全量重写式替换
364
- * (事务内 DELETE+INSERT),幂等。
695
+ * 投影库装配(spec §DDL):node:sqlite 五表一虚表(sessions/segments/replaces/
696
+ * blocks/meta + segments_fts)+ WAL/mmap 缓存层 1 + sessions 汇总常驻 Map
697
+ * (缓存层 2)。写纪律:每 session 全量重写式替换(事务内 DELETE+INSERT),
698
+ * 幂等;字块一律走 blocks 字典 hash 引用(v5),脱敏派生臂同源生成。
365
699
  *
366
700
  * 本库只落在 ~/.dsh-reference-session-collector/projection.db ——绝不触碰会话文件。
367
701
  */
368
702
  /** schema 版本(meta 表;断裂式迁移的比对基线)。
369
703
  * 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";
704
+ * v3=search_text 视界变更(剥离宿主注入样板段);
705
+ * v4=账目双修(data.content 双臂 + replaces.live);
706
+ * v5=字块共享 + 脱敏派生臂(2026-10-20 用户定标):text/search_text/
707
+ * surface_text 实列退役→blocks 字典 hash 引用(surface 经 replaced_by
708
+ * 链派生);新 redacted_text_hash/redacted_search_text_hash(redactSecrets
709
+ * 派生臂,CCI 口径 text 的 token 数学不动);旧库列形状断裂→DROP 重建
710
+ * + VACUUM(投影可重建,丢行无损)。 */
711
+ const PROJECTION_SCHEMA_VERSION = "5";
376
712
  const DDL = `
377
713
  CREATE TABLE IF NOT EXISTS sessions(
378
714
  session_id TEXT PRIMARY KEY, dir_id TEXT NOT NULL, title TEXT DEFAULT '',
@@ -391,11 +727,12 @@ CREATE TABLE IF NOT EXISTS segments(
391
727
  session_id TEXT NOT NULL, seq INTEGER NOT NULL, turn INTEGER DEFAULT 0,
392
728
  step INTEGER DEFAULT 0, kind TEXT NOT NULL, role TEXT DEFAULT '',
393
729
  time INTEGER DEFAULT 0,
394
- text TEXT DEFAULT '', tokens INTEGER DEFAULT 0,
395
- search_text TEXT DEFAULT '',
730
+ text_hash TEXT, tokens INTEGER DEFAULT 0,
731
+ search_text_hash TEXT,
732
+ redacted_text_hash TEXT, redacted_search_text_hash TEXT,
396
733
  on_surface INTEGER DEFAULT 1,
397
734
  compressed INTEGER DEFAULT 0,
398
- surface_text TEXT, surface_tokens INTEGER, replaced_by INTEGER,
735
+ surface_tokens INTEGER, replaced_by INTEGER,
399
736
  PRIMARY KEY(session_id, seq)) WITHOUT ROWID;
400
737
  CREATE INDEX IF NOT EXISTS idx_segments_turn ON segments(session_id, turn, step);
401
738
  CREATE TABLE IF NOT EXISTS replaces(
@@ -410,6 +747,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS segments_fts USING fts5(
410
747
  index_text, session_id UNINDEXED, seq UNINDEXED, kind UNINDEXED,
411
748
  tokenize='unicode61');
412
749
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
750
+ ${BLOCKS_DDL}
413
751
  `;
414
752
  function summaryToRow(s, artifactBytes, artifactMtimeMs, extra) {
415
753
  return {
@@ -451,24 +789,21 @@ PRAGMA cache_size=-32000;
451
789
  PRAGMA mmap_size=268435456;
452
790
  PRAGMA application_id=0x44525343;
453
791
  `);
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
- }
792
+ let previous;
793
+ try {
794
+ previous = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
795
+ } catch {
796
+ previous = void 0;
470
797
  }
471
- db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run("schema_version", "4");
798
+ if (previous !== void 0 && previous.value !== "5") db.exec(`DROP TABLE IF EXISTS segments_fts;
799
+ DROP TABLE IF EXISTS segments;
800
+ DROP TABLE IF EXISTS replaces;
801
+ DROP TABLE IF EXISTS sessions;
802
+ DROP TABLE IF EXISTS blocks;
803
+ DROP TABLE IF EXISTS meta`);
804
+ db.exec(DDL);
805
+ if (previous !== void 0 && previous.value !== "5") db.exec("VACUUM");
806
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run("schema_version", "5");
472
807
  const summaries = /* @__PURE__ */ new Map();
473
808
  const loadAll = () => {
474
809
  summaries.clear();
@@ -477,9 +812,11 @@ PRAGMA application_id=0x44525343;
477
812
  };
478
813
  loadAll();
479
814
  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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
815
+ session_id, seq, turn, step, kind, role, time, text_hash, tokens, search_text_hash,
816
+ redacted_text_hash, redacted_search_text_hash,
817
+ on_surface, compressed, surface_tokens, replaced_by)
818
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
819
+ const blocks = new BlocksStore(db);
483
820
  const insertReplace = db.prepare(`INSERT INTO replaces(
484
821
  session_id, seq, start_seq, end_seq, a_tokens, b_tokens, kind, turn, step, live)
485
822
  VALUES(?,?,?,?,?,?,?,?,?,?)`);
@@ -511,7 +848,11 @@ PRAGMA application_id=0x44525343;
511
848
  db.prepare("DELETE FROM segments WHERE session_id=?").run(row.session_id);
512
849
  db.prepare("DELETE FROM replaces WHERE session_id=?").run(row.session_id);
513
850
  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);
851
+ for (const seg of segments) {
852
+ const redText = redactSecrets(seg.text).text;
853
+ const redSearch = redactSecrets(seg.searchText).text;
854
+ 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);
855
+ }
515
856
  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
857
  for (const seg of segments) {
517
858
  if (seg.searchText === "") continue;
@@ -1656,6 +1997,8 @@ var ProjectionSync = class {
1656
1997
  const discovered = discoverSessionLogs(dshHome, limit);
1657
1998
  const exact = this.counterMode === "off" ? void 0 : resolveExactCounter();
1658
1999
  const basis = exact !== void 0 ? "ds-token" : "chars/4";
2000
+ const dict = this.store.blocksWatermark();
2001
+ const dictEvicted = dict.current < dict.last;
1659
2002
  const result = {
1660
2003
  ok: true,
1661
2004
  busy: false,
@@ -1673,7 +2016,8 @@ var ProjectionSync = class {
1673
2016
  this.syncOne(found, {
1674
2017
  archivedSet,
1675
2018
  exact,
1676
- result
2019
+ result,
2020
+ dictEvicted
1677
2021
  });
1678
2022
  } catch (error) {
1679
2023
  result.failures.push({
@@ -1704,6 +2048,7 @@ var ProjectionSync = class {
1704
2048
  }
1705
2049
  }
1706
2050
  result.durationMs = Date.now() - startedAt;
2051
+ this.store.setBlocksWatermark(this.store.blocksWatermark().current);
1707
2052
  this.lastResult = result;
1708
2053
  return result;
1709
2054
  } finally {
@@ -1712,7 +2057,7 @@ var ProjectionSync = class {
1712
2057
  }
1713
2058
  syncOne(found, ctx) {
1714
2059
  const record = this.store.getSessionByDir(found.dirSessionId);
1715
- const statHit = record !== void 0 && record.artifactBytes === found.artifact.bytes && record.artifactMtimeMs === found.artifact.mtimeMs;
2060
+ const statHit = !ctx.dictEvicted && record !== void 0 && record.artifactBytes === found.artifact.bytes && record.artifactMtimeMs === found.artifact.mtimeMs;
1716
2061
  if (record !== void 0) {
1717
2062
  const archived = ctx.archivedSet.has(record.sessionId) ? 1 : 0;
1718
2063
  if (record.archived !== archived) this.store.setSessionArchived(record.sessionId, archived);
@@ -1810,6 +2155,8 @@ function isTrustedRequest(req, trustedHosts) {
1810
2155
  if (typeof host !== "string") return false;
1811
2156
  return trustedHosts.some((trusted) => trusted.toLowerCase() === host.toLowerCase());
1812
2157
  }
2158
+ /** R5 水位在 meta 表的键(DDL 冻结不动,kv 面落)。 */
2159
+ const BLOCKS_WATERMARK_META_KEY = "blocks_watermark";
1813
2160
  /** sync 端口 → ProjectionStore 真身适配(dir 水位经常驻 Map 扫描)。 */
1814
2161
  function createStorePort(store) {
1815
2162
  return {
@@ -1840,6 +2187,18 @@ function createStorePort(store) {
1840
2187
  },
1841
2188
  deleteSession(sessionId) {
1842
2189
  store.deleteSession(sessionId);
2190
+ },
2191
+ blocksWatermark() {
2192
+ const current = Number(store.db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM blocks").get().m);
2193
+ const row = store.db.prepare("SELECT value FROM meta WHERE key=?").get(BLOCKS_WATERMARK_META_KEY);
2194
+ const parsed = row === void 0 ? 0 : Number(row.value);
2195
+ return {
2196
+ current,
2197
+ last: Number.isFinite(parsed) ? parsed : 0
2198
+ };
2199
+ },
2200
+ setBlocksWatermark(value) {
2201
+ 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
2202
  }
1844
2203
  };
1845
2204
  }
@@ -1859,6 +2218,20 @@ function resolveLatestSessionId(query) {
1859
2218
  return bestId;
1860
2219
  }
1861
2220
  /**
2221
+ * v5 参数解析(R3/R4):`redact` 仅 '1' 为真,其余/非法=0(缺省原文臂);
2222
+ * `tail` 正整数生效,非法/≤0=忽略(全量)。
2223
+ */
2224
+ function parseRedact(url) {
2225
+ return url.searchParams.get("redact") === "1";
2226
+ }
2227
+ function parseTail(url) {
2228
+ const raw = url.searchParams.get("tail");
2229
+ if (raw === null) return void 0;
2230
+ const parsed = Number(raw);
2231
+ if (!Number.isFinite(parsed) || parsed <= 0) return void 0;
2232
+ return Math.floor(parsed);
2233
+ }
2234
+ /**
1862
2235
  * op 分发:GET `?op=…`;POST `{op:'sync'}` 触发后台同步。
1863
2236
  * 未知 op / 缺参 → 400(不留空壳)。
1864
2237
  */
@@ -1924,9 +2297,13 @@ async function handleDrsRequest(deps, req, res) {
1924
2297
  }
1925
2298
  const turn = url.searchParams.get("turn") ?? void 0;
1926
2299
  const fields = url.searchParams.get("fields") ?? void 0;
2300
+ const redact = parseRedact(url);
2301
+ const tail = parseTail(url);
1927
2302
  const detail = sessionDetail(deps.query, sessionId, {
1928
2303
  ...turn === void 0 ? {} : { turn },
1929
- ...fields === void 0 ? {} : { fields }
2304
+ ...fields === void 0 ? {} : { fields },
2305
+ ...tail === void 0 ? {} : { tail },
2306
+ ...redact ? { redact: true } : {}
1930
2307
  });
1931
2308
  if (detail.session === void 0) {
1932
2309
  writeJson(res, 200, {
@@ -1993,6 +2370,7 @@ async function handleDrsRequest(deps, req, res) {
1993
2370
  });
1994
2371
  return;
1995
2372
  }
2373
+ const redact = parseRedact(url);
1996
2374
  if (url.searchParams.get("group") === "session") {
1997
2375
  const kindsRaw = url.searchParams.get("kinds") ?? "";
1998
2376
  const archivedRaw = url.searchParams.get("archived");
@@ -2009,7 +2387,8 @@ async function handleDrsRequest(deps, req, res) {
2009
2387
  ...toRaw !== null && Number.isFinite(Number(toRaw)) ? { to: Number(toRaw) } : {},
2010
2388
  ...url.searchParams.get("surface") === "0" ? { surfaceOnly: false } : {},
2011
2389
  ...url.searchParams.get("sort") === "time" ? { sortBy: "time" } : {},
2012
- ...limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {}
2390
+ ...limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {},
2391
+ ...redact ? { redact: true } : {}
2013
2392
  })
2014
2393
  });
2015
2394
  return;
@@ -2019,7 +2398,8 @@ async function handleDrsRequest(deps, req, res) {
2019
2398
  ok: true,
2020
2399
  ...search(deps.query, q, {
2021
2400
  ...sessionId === "" ? {} : { session: sessionId },
2022
- ...limitRaw === null ? {} : { limit: Number(limitRaw) }
2401
+ ...limitRaw === null ? {} : { limit: Number(limitRaw) },
2402
+ ...redact ? { redact: true } : {}
2023
2403
  })
2024
2404
  });
2025
2405
  return;