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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "dsh-reference-session-collector",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
- "tag": "latest",
6
+ "tag": "beta",
7
7
  "access": "public"
8
8
  },
9
9
  "description": "DSH Web 插件:会话原文投影同步器(DRS)——只读扫描 ~/.dsh/sessions 会话日志,解码后投影进自有 SQLite(segments/replaces/FTS5),经 /api/dsh-reference-session-collector 提供 surface 标记、召回对账与内容检索面。绝不写会话文件。",
@@ -49,6 +49,7 @@
49
49
  "prepack": "npm run build",
50
50
  "typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
51
51
  "smoke": "node scripts/smoke-lib.mjs",
52
+ "smoke:real": "node scripts/rebuild-smoke.mjs",
52
53
  "test": "vitest run",
53
54
  "lint": "eslint src tests eslint.config.js",
54
55
  "check": "npm run typecheck && npm run lint && npm run test && npm run build && npm run smoke"
package/src/blocks.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 字块字典(schema v5,2026-10-20 用户定标「整条一起存相同字块」):
3
+ * segments 的 text/search_text/redacted_* 一律以 sha256 全串等值 hash 入
4
+ * 本字典,行内只存引用;同文=同 hash=单份存储(INSERT OR IGNORE 幂等)。
5
+ *
6
+ * 选型依据(真机实测):surface_text 1768 行仅 23 distinct(99.2%);
7
+ * text 11% / search_text 12% 整段重复;重复组字节跨度大→前缀键/分块切分
8
+ * 会撕裂等值判定,全串 hash 是唯一可靠粒度。hash 用 node:crypto sha256
9
+ * (零新增依赖;弃 blake3)。
10
+ *
11
+ * 纪律:字典只增不删(deleteSession 清行不清块——共享块归属不可知),
12
+ * 投影可重建、体积有上界(语料 distinct 集);GC 属明示不做项。
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+ import type { DatabaseSync } from 'node:sqlite'
16
+
17
+ /** blocks 表 DDL(单一事实源:schema.ts 与测试拼库都引本常量)。 */
18
+ export const BLOCKS_DDL = `CREATE TABLE IF NOT EXISTS blocks(
19
+ hash TEXT PRIMARY KEY, body TEXT NOT NULL, bytes INTEGER NOT NULL,
20
+ created_at TEXT DEFAULT (datetime('now')));`
21
+
22
+ /** sha256 hex(64 字符)——全串等值键。 */
23
+ export function hashOf(text: string): string {
24
+ return createHash('sha256').update(text, 'utf8').digest('hex')
25
+ }
26
+
27
+ export class BlocksStore {
28
+ private readonly insert: ReturnType<DatabaseSync['prepare']>
29
+
30
+ constructor(db: DatabaseSync) {
31
+ this.insert = db.prepare('INSERT OR IGNORE INTO blocks(hash, body, bytes) VALUES(?,?,?)')
32
+ }
33
+
34
+ /**
35
+ * 入字典并返回引用 hash;空文本不入典返回 null(hash 列存 NULL,读侧还原
36
+ * 为 ''——「未落行文本」与「空文本」在 v4 就靠此区分,语义不变)。
37
+ * 幂等:同文重复 ensure 只落一份(INSERT OR IGNORE + hash 主键)。
38
+ */
39
+ ensure(text: string): string | null {
40
+ if (text === '') return null
41
+ const hash = hashOf(text)
42
+ this.insert.run(hash, text, Buffer.byteLength(text, 'utf8'))
43
+ return hash
44
+ }
45
+ }
package/src/index.ts CHANGED
@@ -104,6 +104,9 @@ function isTrustedRequest(req: IncomingMessage, trustedHosts: readonly string[]
104
104
  return trustedHosts.some(trusted => trusted.toLowerCase() === host.toLowerCase())
105
105
  }
106
106
 
107
+ /** R5 水位在 meta 表的键(DDL 冻结不动,kv 面落)。 */
108
+ const BLOCKS_WATERMARK_META_KEY = 'blocks_watermark'
109
+
107
110
  /** sync 端口 → ProjectionStore 真身适配(dir 水位经常驻 Map 扫描)。 */
108
111
  export function createStorePort(store: ProjectionStore): SyncStorePort {
109
112
  return {
@@ -137,6 +140,16 @@ export function createStorePort(store: ProjectionStore): SyncStorePort {
137
140
  deleteSession(sessionId: string): void {
138
141
  store.deleteSession(sessionId)
139
142
  },
143
+ blocksWatermark(): { current: number; last: number } {
144
+ const current = Number((store.db.prepare('SELECT COALESCE(MAX(rowid), 0) AS m FROM blocks').get() as { m: number | bigint }).m)
145
+ const row = store.db.prepare('SELECT value FROM meta WHERE key=?').get(BLOCKS_WATERMARK_META_KEY) as { value: string } | undefined
146
+ const parsed = row === undefined ? 0 : Number(row.value)
147
+ return { current, last: Number.isFinite(parsed) ? parsed : 0 }
148
+ },
149
+ setBlocksWatermark(value: number): void {
150
+ store.db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
151
+ .run(BLOCKS_WATERMARK_META_KEY, String(value))
152
+ },
140
153
  }
141
154
  }
142
155
 
@@ -165,6 +178,22 @@ function resolveLatestSessionId(query: QueryContext): string | undefined {
165
178
  return bestId
166
179
  }
167
180
 
181
+ /**
182
+ * v5 参数解析(R3/R4):`redact` 仅 '1' 为真,其余/非法=0(缺省原文臂);
183
+ * `tail` 正整数生效,非法/≤0=忽略(全量)。
184
+ */
185
+ function parseRedact(url: URL): boolean {
186
+ return url.searchParams.get('redact') === '1'
187
+ }
188
+
189
+ function parseTail(url: URL): number | undefined {
190
+ const raw = url.searchParams.get('tail')
191
+ if (raw === null) return undefined
192
+ const parsed = Number(raw)
193
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined
194
+ return Math.floor(parsed)
195
+ }
196
+
168
197
  /**
169
198
  * op 分发:GET `?op=…`;POST `{op:'sync'}` 触发后台同步。
170
199
  * 未知 op / 缺参 → 400(不留空壳)。
@@ -216,9 +245,13 @@ export async function handleDrsRequest(deps: DrsHandlerDeps, req: IncomingMessag
216
245
  }
217
246
  const turn = url.searchParams.get('turn') ?? undefined
218
247
  const fields = url.searchParams.get('fields') ?? undefined
248
+ const redact = parseRedact(url)
249
+ const tail = parseTail(url)
219
250
  const detail = sessionDetail(deps.query, sessionId, {
220
251
  ...(turn === undefined ? {} : { turn }),
221
252
  ...(fields === undefined ? {} : { fields }),
253
+ ...(tail === undefined ? {} : { tail }),
254
+ ...(redact ? { redact: true } : {}),
222
255
  })
223
256
  if (detail.session === undefined) {
224
257
  writeJson(res, 200, { ok: true, session: null, rows: [] })
@@ -257,6 +290,7 @@ export async function handleDrsRequest(deps: DrsHandlerDeps, req: IncomingMessag
257
290
  writeJson(res, 400, { ok: false, error: 'q parameter required' })
258
291
  return
259
292
  }
293
+ const redact = parseRedact(url)
260
294
  // 分组臂(steward 直连):group=session 时走 searchGrouped,参数见
261
295
  // GroupedSearchRequest;缺省仍为逐文档平铺命中(CCI/既有调用不变)。
262
296
  if (url.searchParams.get('group') === 'session') {
@@ -275,6 +309,8 @@ export async function handleDrsRequest(deps: DrsHandlerDeps, req: IncomingMessag
275
309
  ...(url.searchParams.get('surface') === '0' ? { surfaceOnly: false } : {}),
276
310
  ...(url.searchParams.get('sort') === 'time' ? { sortBy: 'time' as const } : {}),
277
311
  ...(limitRaw !== null && Number.isFinite(Number(limitRaw)) ? { limit: Number(limitRaw) } : {}),
312
+ ...(url.searchParams.get('expand') === '1' ? { expand: true } : {}),
313
+ ...(redact ? { redact: true } : {}),
278
314
  })
279
315
  writeJson(res, 200, { ok: true, ...grouped })
280
316
  return
@@ -283,6 +319,7 @@ export async function handleDrsRequest(deps: DrsHandlerDeps, req: IncomingMessag
283
319
  writeJson(res, 200, { ok: true, ...search(deps.query, q, {
284
320
  ...(sessionId === '' ? {} : { session: sessionId }),
285
321
  ...(limitRaw === null ? {} : { limit: Number(limitRaw) }),
322
+ ...(redact ? { redact: true } : {}),
286
323
  }) })
287
324
  return
288
325
  }
package/src/redact.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * ⚠ VENDORED(回搬件)——源自 dsh-prime-memory `src/util/redact.ts`,
3
+ * 回搬时点 2026-10-20(schema v5 脱敏臂工程 task_1)。
4
+ * 回搬纪律:**文件级拷贝,禁止分叉**;memory 侧修复须整文件重拷并复核
5
+ * 对账(本文件除本头注外与源文件逐字节一致,tests/redact.spec.ts 同批
6
+ * 回搬仅改导入路径)。两侧共用同一引擎=脱敏口径天然一致(capture 单点
7
+ * 与 DRS 投影派生臂同源)。
8
+ */
9
+ /**
10
+ * §C 载荷脱敏(memorax-absorb Wave 2 / task_14)——捕获边界的密钥擦除。
11
+ *
12
+ * 用户在对话里贴入的密钥(PEM/Bearer/JWT/Cookie/厂商 API key/邮箱/长号/高熵 ID)
13
+ * 若原样进 L0/L1,会被逐轮召回注入上下文并在 UI 展示。本模块在捕获单点把它们
14
+ * 替换为类型化占位符 `[REDACTED:<KIND>]`——占位符保留类别语义,关键词检索仍可命中。
15
+ *
16
+ * 词表/正则/allowlist/span 合并算法以 memorax `payload-redaction.ts` 为基准逐条移植
17
+ * (全文快照见计划 evidence/payload-redaction-snapshot.ts;findings.md 有等价内联)。
18
+ *
19
+ * 契约(sanitize 同款,tests/redact.test.ts 钉住):
20
+ * - 纯函数,零 I/O 零时钟零随机;输入原样返回(未命中)= 引用不变无关紧要,字符串不可变;
21
+ * - **幂等**:已是 `[REDACTED:KIND]` 的值不会被二次匹配;
22
+ * - 安全值 allowlist 命中不脱(`example`/`change-me`/`$VAR`/`${{…}}` 等);
23
+ * - 本函数**永不抛错**的保证由调用方 try/catch 兜底(capture 接线层,§C 红线:失败放行原文)。
24
+ */
25
+
26
+ export type RedactionKind =
27
+ | 'PRIVATE_KEY'
28
+ | 'AUTH_TOKEN'
29
+ | 'COOKIE'
30
+ | 'API_KEY'
31
+ | 'CREDENTIAL'
32
+ | 'EMAIL'
33
+ | 'LONG_NUMBER'
34
+ | 'OPAQUE_ID';
35
+
36
+ export interface RedactionResult {
37
+ text: string;
38
+ counts: Partial<Record<RedactionKind, number>>;
39
+ redacted: boolean;
40
+ }
41
+
42
+ interface RedactionSpan {
43
+ start: number;
44
+ end: number;
45
+ kind: RedactionKind;
46
+ priority: number;
47
+ }
48
+
49
+ interface RedactionRule {
50
+ pattern: RegExp;
51
+ kind: RedactionKind;
52
+ priority: number;
53
+ /** 取匹配的第几个分组作为敏感值(缺省 0 = 整个匹配)。 */
54
+ group?: number;
55
+ /** 值级放行判定(返回 false = 不脱)。 */
56
+ accept?: (value: string) => boolean;
57
+ }
58
+
59
+ const PLACEHOLDER_VALUE_PATTERN = /^\[REDACTED:[A-Z_]+\]$/;
60
+ 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)`;
61
+ 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]+)`;
62
+ /** 安全值 allowlist:命中即放行(示例/占位/环境变量引用/类型名等,脱了只会毁记忆)。 */
63
+ const SAFE_CREDENTIAL_VALUE_PATTERN =
64
+ /^(?:\[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;
65
+
66
+ const RULES: readonly RedactionRule[] = [
67
+ {
68
+ // PEM 私钥块:至对应 END,截断文件时取到文末
69
+ pattern: /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----[\s\S]*?(?:-----END \1-----|$)/gi,
70
+ kind: 'PRIVATE_KEY',
71
+ priority: 100,
72
+ },
73
+ {
74
+ pattern: /(?:^|[\r\n])[ \t]*(?:Proxy-)?Authorization[ \t]*:[ \t]*([^ \t\r\n][^\r\n]*)/gim,
75
+ group: 1,
76
+ kind: 'AUTH_TOKEN',
77
+ priority: 95,
78
+ },
79
+ {
80
+ pattern: /(?:^|[^A-Za-z0-9_-])(?:Bearer|Basic)\s+["']?([A-Za-z0-9._~+/=-]{16,})(?![A-Za-z0-9._~+/=-])/gi,
81
+ group: 1,
82
+ kind: 'AUTH_TOKEN',
83
+ priority: 94,
84
+ },
85
+ {
86
+ // JWT 三段形态
87
+ pattern: /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{16,}\b/g,
88
+ kind: 'AUTH_TOKEN',
89
+ priority: 94,
90
+ },
91
+ {
92
+ pattern: /(?:^|[\r\n])[ \t]*(?:Set-Cookie|Cookie)\s*:\s*([^\r\n]+)/gim,
93
+ group: 1,
94
+ kind: 'COOKIE',
95
+ priority: 92,
96
+ },
97
+ {
98
+ // 声明式赋值:key: value / key = value(含 export/const/let/var 前缀与引号键)
99
+ pattern: new RegExp(
100
+ String.raw`(?:^|[\r\n;,{}])[ \t]*(?:-[ \t]+)?(?:(?:export|const|let|var)[ \t]+)*["']?${SENSITIVE_KEY_SOURCE}["']?[ \t]*[:=][ \t]*(${CREDENTIAL_VALUE_SOURCE})`,
101
+ 'gim',
102
+ ),
103
+ group: 1,
104
+ kind: 'CREDENTIAL',
105
+ priority: 85,
106
+ accept: (value) => !isSafeCredentialValue(value),
107
+ },
108
+ {
109
+ // CLI 形态:--api-key value / --api-key=value
110
+ pattern: new RegExp(
111
+ String.raw`--${SENSITIVE_KEY_SOURCE}(?![A-Za-z0-9_$-])(?:[ \t]+|=[ \t]*)(${CREDENTIAL_VALUE_SOURCE})`,
112
+ 'gi',
113
+ ),
114
+ group: 1,
115
+ kind: 'CREDENTIAL',
116
+ priority: 85,
117
+ accept: (value) => !isSafeCredentialValue(value),
118
+ },
119
+ {
120
+ // URL query 形态:?api_key=...
121
+ pattern: new RegExp(
122
+ String.raw`[?&]${SENSITIVE_KEY_SOURCE}(?![A-Za-z0-9_$-])=([^&#\s"'<>]+)`,
123
+ 'gi',
124
+ ),
125
+ group: 1,
126
+ kind: 'CREDENTIAL',
127
+ priority: 85,
128
+ accept: (value) => !isSafeCredentialValue(value),
129
+ },
130
+ {
131
+ // URL userinfo 形态:scheme://user:pass@
132
+ pattern: /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/:@]*:([^\s/@]+)@/g,
133
+ group: 1,
134
+ kind: 'CREDENTIAL',
135
+ priority: 85,
136
+ accept: (value) => !isSafeCredentialValue(value),
137
+ },
138
+ // ── 已知厂商 API key 形态(9 条) ──
139
+ { pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}[A-Za-z0-9]\b/g, kind: 'API_KEY', priority: 80 },
140
+ { pattern: /\bsk_[A-Za-z0-9_-]{8,}\b/g, kind: 'API_KEY', priority: 80 },
141
+ { pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, kind: 'API_KEY', priority: 80 },
142
+ { pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, kind: 'API_KEY', priority: 80 },
143
+ { pattern: /\bxox[baprs]-[A-Za-z0-9-]{16,}[A-Za-z0-9]\b/g, kind: 'API_KEY', priority: 80 },
144
+ { pattern: /\b(?:(?:sk|rk)_live_[A-Za-z0-9]{16,}|whsec_[A-Za-z0-9]{16,})\b/g, kind: 'API_KEY', priority: 80 },
145
+ { pattern: /\bglpat-[A-Za-z0-9_-]{16,}[A-Za-z0-9]\b/g, kind: 'API_KEY', priority: 80 },
146
+ { pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, kind: 'API_KEY', priority: 80 },
147
+ { pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, kind: 'API_KEY', priority: 80 },
148
+ {
149
+ // 邮箱(前后向断言防误切)
150
+ 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,
151
+ kind: 'EMAIL',
152
+ priority: 70,
153
+ },
154
+ {
155
+ // UUID
156
+ 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,
157
+ kind: 'OPAQUE_ID',
158
+ priority: 60,
159
+ },
160
+ {
161
+ // hex 64/40/32(含字母才脱——纯数字是普通长号,交 LONG_NUMBER)
162
+ 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,
163
+ kind: 'OPAQUE_ID',
164
+ priority: 60,
165
+ accept: (value) => /[A-Fa-f]/.test(value),
166
+ },
167
+ {
168
+ // ≥24 位字母数字高熵启发式(熵参数见 looksLikeHighEntropyOpaqueId)
169
+ pattern: /(?<![A-Za-z0-9])[A-Za-z0-9]{24,}(?![A-Za-z0-9])/g,
170
+ kind: 'OPAQUE_ID',
171
+ priority: 60,
172
+ accept: looksLikeHighEntropyOpaqueId,
173
+ },
174
+ {
175
+ // ≥9 位数字串(含电话形);日期形放行
176
+ pattern: /(?<![A-Za-z0-9.])\+?(?:\d|\(\d)[\d ()-]*\d[Xx]?\)?(?![A-Za-z0-9]|\.\d)/g,
177
+ kind: 'LONG_NUMBER',
178
+ priority: 50,
179
+ accept: (value) => {
180
+ if (value.replace(/\D/g, '').length < 9) return false;
181
+ const normalized = value.trim().replace(/[()]/g, '');
182
+ return (
183
+ !/^(?:19|20)\d{2}-\d{1,2}-\d{1,2}(?:[ T-]\d{1,2})?$/.test(normalized) &&
184
+ !/^(?:19|20)\d{2}\s+\d{1,2}\s+\d{1,2}(?:\s+\d{1,2})?$/.test(normalized)
185
+ );
186
+ },
187
+ },
188
+ ];
189
+
190
+ /**
191
+ * 对文本执行 8 类脱敏。规则各自扫描 → 收集值级 span → 合并重叠 → 重拼插入占位符。
192
+ * 同一位置被多条规则命中时取更长/更高优先级的 span。
193
+ */
194
+ export function redactSecrets(text: string): RedactionResult {
195
+ if (!text) return { text, counts: {}, redacted: false };
196
+ const spans: RedactionSpan[] = [];
197
+ for (const rule of RULES) collectRuleSpans(text, rule, spans);
198
+ const selected = mergeOverlappingSpans(spans);
199
+ if (selected.length === 0) return { text, counts: {}, redacted: false };
200
+ const counts: Partial<Record<RedactionKind, number>> = {};
201
+ const output: string[] = [];
202
+ let offset = 0;
203
+ for (const span of selected) {
204
+ output.push(text.slice(offset, span.start), `[REDACTED:${span.kind}]`);
205
+ counts[span.kind] = (counts[span.kind] ?? 0) + 1;
206
+ offset = span.end;
207
+ }
208
+ output.push(text.slice(offset));
209
+ return { text: output.join(''), counts, redacted: true };
210
+ }
211
+
212
+ function collectRuleSpans(text: string, rule: RedactionRule, spans: RedactionSpan[]): void {
213
+ rule.pattern.lastIndex = 0;
214
+ for (const match of text.matchAll(rule.pattern)) {
215
+ const value = match[rule.group ?? 0];
216
+ if (match.index === undefined || !value || PLACEHOLDER_VALUE_PATTERN.test(value.trim())) continue;
217
+ if (rule.accept && !rule.accept(value)) continue;
218
+ const relativeStart = match[0].lastIndexOf(value);
219
+ if (relativeStart < 0) continue;
220
+ spans.push({
221
+ start: match.index + relativeStart,
222
+ end: match.index + relativeStart + value.length,
223
+ kind: rule.kind,
224
+ priority: rule.priority,
225
+ });
226
+ }
227
+ }
228
+
229
+ function isSafeCredentialValue(rawValue: string): boolean {
230
+ let value = rawValue.trim();
231
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
232
+ value = value.slice(1, -1).trim();
233
+ }
234
+ return !value || SAFE_CREDENTIAL_VALUE_PATTERN.test(value);
235
+ }
236
+
237
+ /** 高熵判定:小写≥2 ∧ 大写≥2 ∧ 数字≥2 ∧ 字符类切换≥4 ∧ 最长连续字母段≤5 ∧ 去重字符≥12。 */
238
+ function looksLikeHighEntropyOpaqueId(value: string): boolean {
239
+ let lowercase = 0;
240
+ let uppercase = 0;
241
+ let digits = 0;
242
+ let transitions = 0;
243
+ let letterRun = 0;
244
+ let longestLetterRun = 0;
245
+ let previousWasDigit: boolean | undefined;
246
+ const unique = new Set<string>();
247
+ for (const character of value) {
248
+ const isDigit = character >= '0' && character <= '9';
249
+ if (isDigit) {
250
+ digits += 1;
251
+ letterRun = 0;
252
+ } else {
253
+ if (character >= 'a' && character <= 'z') lowercase += 1;
254
+ else uppercase += 1;
255
+ letterRun += 1;
256
+ longestLetterRun = Math.max(longestLetterRun, letterRun);
257
+ }
258
+ if (previousWasDigit !== undefined && previousWasDigit !== isDigit) transitions += 1;
259
+ previousWasDigit = isDigit;
260
+ unique.add(character);
261
+ }
262
+ return lowercase >= 2 && uppercase >= 2 && digits >= 2 && transitions >= 4 && longestLetterRun <= 5 && unique.size >= 12;
263
+ }
264
+
265
+ function mergeOverlappingSpans(spans: RedactionSpan[]): RedactionSpan[] {
266
+ const sorted = [...spans].sort(
267
+ (left, right) => left.start - right.start || right.end - left.end || right.priority - left.priority,
268
+ );
269
+ const merged: RedactionSpan[] = [];
270
+ for (const span of sorted) {
271
+ const previous = merged.at(-1);
272
+ if (!previous || span.start >= previous.end) {
273
+ merged.push({ ...span });
274
+ continue;
275
+ }
276
+ previous.end = Math.max(previous.end, span.end);
277
+ }
278
+ return merged;
279
+ }
package/src/schema.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * 投影库装配(spec §DDL):node:sqlite 四表一虚表 + WAL/mmap 缓存层 1 +
3
- * sessions 汇总常驻 Map(缓存层 2)。写纪律:每 session 全量重写式替换
4
- * (事务内 DELETE+INSERT),幂等。
2
+ * 投影库装配(spec §DDL):node:sqlite 五表一虚表(sessions/segments/replaces/
3
+ * blocks/meta + segments_fts)+ WAL/mmap 缓存层 1 + sessions 汇总常驻 Map
4
+ * (缓存层 2)。写纪律:每 session 全量重写式替换(事务内 DELETE+INSERT),
5
+ * 幂等;字块一律走 blocks 字典 hash 引用(v5),脱敏派生臂同源生成。
5
6
  *
6
7
  * 本库只落在 ~/.dsh-reference-session-collector/projection.db ——绝不触碰会话文件。
7
8
  */
@@ -10,15 +11,19 @@ import { mkdirSync } from 'node:fs'
10
11
  import { join } from 'node:path'
11
12
  import type { SessionProjection, SessionSummary } from './projection.ts'
12
13
  import { segmentForIndex } from './segment.ts'
14
+ import { BlocksStore, BLOCKS_DDL } from './blocks.ts'
15
+ import { redactSecrets } from './redact.ts'
13
16
 
14
17
  /** schema 版本(meta 表;断裂式迁移的比对基线)。
15
18
  * v2=加 sessions.format_generation;
16
- * v3=search_text 视界变更(剥离宿主注入样板段,strip-injected horizon v2);
17
- * v4=账目双修(真机取证 2026-10-20):① text/tokens 视界补 data.content 双臂
18
- * (checkpoint 替身/用户消息旧视界全计 0);② replaces 加 live 列——替身行
19
- * 被后续区间再覆盖时 delta 清除不入 gross(用户定标:checkpoint 只影响其后
20
- * 的积分)。旧行两项口径均错,整库清空重建(投影可重建,丢行无损)。 */
21
- export const PROJECTION_SCHEMA_VERSION = '4'
19
+ * v3=search_text 视界变更(剥离宿主注入样板段);
20
+ * v4=账目双修(data.content 双臂 + replaces.live);
21
+ * v5=字块共享 + 脱敏派生臂(2026-10-20 用户定标):text/search_text/
22
+ * surface_text 实列退役→blocks 字典 hash 引用(surface 经 replaced_by
23
+ * 链派生);新 redacted_text_hash/redacted_search_text_hash(redactSecrets
24
+ * 派生臂,CCI 口径 text 的 token 数学不动);旧库列形状断裂→DROP 重建
25
+ * + VACUUM(投影可重建,丢行无损)。 */
26
+ export const PROJECTION_SCHEMA_VERSION = '5'
22
27
 
23
28
  /** sessions 表原始行(snake_case 与 DDL 一致)。 */
24
29
  export interface SessionRow {
@@ -88,11 +93,12 @@ CREATE TABLE IF NOT EXISTS segments(
88
93
  session_id TEXT NOT NULL, seq INTEGER NOT NULL, turn INTEGER DEFAULT 0,
89
94
  step INTEGER DEFAULT 0, kind TEXT NOT NULL, role TEXT DEFAULT '',
90
95
  time INTEGER DEFAULT 0,
91
- text TEXT DEFAULT '', tokens INTEGER DEFAULT 0,
92
- search_text TEXT DEFAULT '',
96
+ text_hash TEXT, tokens INTEGER DEFAULT 0,
97
+ search_text_hash TEXT,
98
+ redacted_text_hash TEXT, redacted_search_text_hash TEXT,
93
99
  on_surface INTEGER DEFAULT 1,
94
100
  compressed INTEGER DEFAULT 0,
95
- surface_text TEXT, surface_tokens INTEGER, replaced_by INTEGER,
101
+ surface_tokens INTEGER, replaced_by INTEGER,
96
102
  PRIMARY KEY(session_id, seq)) WITHOUT ROWID;
97
103
  CREATE INDEX IF NOT EXISTS idx_segments_turn ON segments(session_id, turn, step);
98
104
  CREATE TABLE IF NOT EXISTS replaces(
@@ -107,6 +113,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS segments_fts USING fts5(
107
113
  index_text, session_id UNINDEXED, seq UNINDEXED, kind UNINDEXED,
108
114
  tokenize='unicode61');
109
115
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
116
+ ${BLOCKS_DDL}
110
117
  `
111
118
 
112
119
  function summaryToRow(s: SessionSummary, artifactBytes: number, artifactMtimeMs: number, extra?: ReplaceSessionExtra): SessionRow {
@@ -150,32 +157,25 @@ PRAGMA cache_size=-32000;
150
157
  PRAGMA mmap_size=268435456;
151
158
  PRAGMA application_id=0x44525343;
152
159
  `)
153
- db.exec(DDL)
154
- // 断裂式迁移:旧库 schema_version 存在且与新串不同 → 整库清空(投影可重建)。
155
- // v1→v2 时代旧库先补列再判(列检查幂等安全)。
156
- const cols = db.prepare('PRAGMA table_info(sessions)').all() as unknown as { name: string }[]
157
- if (!cols.some(c => c.name === 'format_generation')) {
158
- db.exec('ALTER TABLE sessions ADD COLUMN format_generation INTEGER DEFAULT -1')
159
- }
160
- // v3→v4 旧库补列(列检查幂等安全;行数据随后被版本重置清空)。
161
- const repCols = db.prepare('PRAGMA table_info(replaces)').all() as unknown as { name: string }[]
162
- if (!repCols.some(c => c.name === 'live')) {
163
- db.exec('ALTER TABLE replaces ADD COLUMN live INTEGER NOT NULL DEFAULT 1')
160
+ // 断裂式重置(v5 起):旧库版本串不符 → 先 DROP 全建再跑 DDL(segments 列
161
+ // 形状断裂,ALTER 不可补;若先 DDL,legacy 旧表会让 CREATE INDEX 引用缺失列
162
+ // 直接炸)。重建后 VACUUM 一次回收碎片。投影可重建,丢行无损。
163
+ let previous: { value: string } | undefined
164
+ try {
165
+ previous = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as { value: string } | undefined
166
+ } catch {
167
+ previous = undefined // 新库:meta 尚未存在
164
168
  }
165
- const previous = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as { value: string } | undefined
166
169
  if (previous !== undefined && previous.value !== PROJECTION_SCHEMA_VERSION) {
167
- db.exec('BEGIN')
168
- try {
169
- db.prepare('DELETE FROM sessions').run()
170
- db.prepare('DELETE FROM segments').run()
171
- db.prepare('DELETE FROM replaces').run()
172
- db.prepare('DELETE FROM segments_fts').run()
173
- db.exec('COMMIT')
174
- } catch (error) {
175
- db.exec('ROLLBACK')
176
- throw error
177
- }
170
+ db.exec(`DROP TABLE IF EXISTS segments_fts;
171
+ DROP TABLE IF EXISTS segments;
172
+ DROP TABLE IF EXISTS replaces;
173
+ DROP TABLE IF EXISTS sessions;
174
+ DROP TABLE IF EXISTS blocks;
175
+ DROP TABLE IF EXISTS meta`)
178
176
  }
177
+ db.exec(DDL)
178
+ if (previous !== undefined && previous.value !== PROJECTION_SCHEMA_VERSION) db.exec('VACUUM')
179
179
  db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
180
180
  .run('schema_version', PROJECTION_SCHEMA_VERSION)
181
181
 
@@ -188,9 +188,11 @@ PRAGMA application_id=0x44525343;
188
188
  loadAll()
189
189
 
190
190
  const insertSegment = db.prepare(`INSERT INTO segments(
191
- session_id, seq, turn, step, kind, role, time, text, tokens, search_text,
192
- on_surface, compressed, surface_text, surface_tokens, replaced_by)
193
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
191
+ session_id, seq, turn, step, kind, role, time, text_hash, tokens, search_text_hash,
192
+ redacted_text_hash, redacted_search_text_hash,
193
+ on_surface, compressed, surface_tokens, replaced_by)
194
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
195
+ const blocks = new BlocksStore(db)
194
196
  const insertReplace = db.prepare(`INSERT INTO replaces(
195
197
  session_id, seq, start_seq, end_seq, a_tokens, b_tokens, kind, turn, step, live)
196
198
  VALUES(?,?,?,?,?,?,?,?,?,?)`)
@@ -224,9 +226,14 @@ PRAGMA application_id=0x44525343;
224
226
  db.prepare('DELETE FROM replaces WHERE session_id=?').run(row.session_id)
225
227
  db.prepare('DELETE FROM segments_fts WHERE session_id=?').run(row.session_id)
226
228
  for (const seg of segments) {
229
+ // v5 写路径:原文/检索文入典(地面真值不动);redacted 臂同源
230
+ // redactSecrets 派生——未命中行与原文同串同 hash,字典自动共块。
231
+ const redText = redactSecrets(seg.text).text
232
+ const redSearch = redactSecrets(seg.searchText).text
227
233
  insertSegment.run(row.session_id, seg.seq, seg.turn, seg.step, seg.kind, seg.role,
228
- seg.time, seg.text, seg.tokens, seg.searchText, seg.onSurface, seg.compressed,
229
- seg.surfaceText, seg.surfaceTokens, seg.replacedBy)
234
+ seg.time, blocks.ensure(seg.text), seg.tokens, blocks.ensure(seg.searchText),
235
+ blocks.ensure(redText), blocks.ensure(redSearch),
236
+ seg.onSurface, seg.compressed, seg.surfaceTokens, seg.replacedBy)
230
237
  }
231
238
  for (const rep of replaces) {
232
239
  insertReplace.run(row.session_id, rep.seq, rep.startSeq, rep.endSeq,
@@ -264,6 +271,7 @@ PRAGMA application_id=0x44525343;
264
271
  db.prepare('DELETE FROM segments_fts WHERE session_id=?').run(sessionId)
265
272
  db.prepare('DELETE FROM replaces WHERE session_id=?').run(sessionId)
266
273
  const head = db.prepare('DELETE FROM sessions WHERE session_id=?').run(sessionId)
274
+ // v5 纪律:blocks 只增不删(共享块归属不可知;字典体积有上界=语料 distinct 集)。
267
275
  db.exec('COMMIT')
268
276
  if (!existed && Number(head.changes ?? 0) === 0) return false
269
277
  } catch (error) {