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/dsh.plugin.json +1 -1
- package/lib/index.d.ts +50 -15
- package/lib/index.js +444 -61
- package/package.json +3 -2
- package/src/blocks.ts +45 -0
- package/src/index.ts +37 -0
- package/src/redact.ts +279 -0
- package/src/schema.ts +48 -40
- package/src/surface.ts +117 -27
- package/src/sync.ts +20 -3
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-reference-session-collector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org",
|
|
6
|
-
"tag": "
|
|
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
|
|
3
|
-
* sessions 汇总常驻 Map
|
|
4
|
-
*
|
|
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
|
|
17
|
-
* v4
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
db.
|
|
159
|
-
}
|
|
160
|
-
|
|
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(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
229
|
-
|
|
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) {
|