@stardustlc/dsh-dream 0.4.1 → 0.5.1

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.
@@ -0,0 +1,179 @@
1
+ /** 经验类别:偏好 / 流程 / 坑 / 事实。 */
2
+ export type LessonKind = 'preference' | 'procedure' | 'pitfall' | 'fact';
3
+ /** 经验状态。candidate=候选;usable=可用;disputed=有冲突;stale=待复核;rejected=已拒绝。 */
4
+ export type LessonState = 'candidate' | 'usable' | 'disputed' | 'stale' | 'rejected';
5
+ /** 审阅结论。 */
6
+ export type ReviewDecision = 'unreviewed' | 'accepted' | 'rejected';
7
+ /** 一条可复用经验。 */
8
+ export interface Lesson {
9
+ schemaVersion: 1;
10
+ id: string;
11
+ revision: number;
12
+ kind: LessonKind;
13
+ /** 一句话结论(不含条件时不得单独使用)。 */
14
+ title: string;
15
+ /** 可执行动作。 */
16
+ action: string;
17
+ /** 适用条件(何时该用)。 */
18
+ when: string;
19
+ /** 例外,检索时不得被截断。 */
20
+ exceptions: string[];
21
+ scope: {
22
+ projectId?: string;
23
+ workspaceRoot?: string;
24
+ global: boolean;
25
+ };
26
+ applicability: {
27
+ package?: string;
28
+ versions?: string;
29
+ platform?: string;
30
+ }[];
31
+ state: LessonState;
32
+ evidenceIds: string[];
33
+ /** 独立证据数(同证据复述不增加)。 */
34
+ independentSupportCount: number;
35
+ review: {
36
+ decision: ReviewDecision;
37
+ actor?: string;
38
+ at?: string;
39
+ };
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ lastValidatedAt?: string;
43
+ reviewAfter?: string;
44
+ supersedes?: string[];
45
+ conflictIds: string[];
46
+ }
47
+ /** 一条证据:只存脱敏摘要 + 定位信息。 */
48
+ export interface Evidence {
49
+ schemaVersion: 1;
50
+ id: string;
51
+ kind: 'session' | 'user-correction' | 'local-artifact';
52
+ sessionId?: string;
53
+ recordSeq?: number | string;
54
+ projectId?: string;
55
+ observedAt: string;
56
+ /** 已脱敏的一句话。 */
57
+ summary: string;
58
+ /** read=插件读到原记录;claimed=仅模型声称。 */
59
+ verification: 'read' | 'claimed';
60
+ sourceHash?: string;
61
+ }
62
+ /** dream_learn / 迁移的输入。 */
63
+ export interface LessonInput {
64
+ kind: LessonKind;
65
+ title: string;
66
+ action: string;
67
+ when: string;
68
+ exceptions?: string[];
69
+ projectId?: string;
70
+ workspaceRoot?: string;
71
+ global?: boolean;
72
+ applicability?: Lesson['applicability'];
73
+ evidence: EvidenceInput[];
74
+ conflictIds?: string[];
75
+ }
76
+ /** 证据输入:id / schemaVersion / observedAt 由存储层补齐。 */
77
+ export interface EvidenceInput extends Omit<Evidence, 'schemaVersion' | 'id' | 'observedAt'> {
78
+ observedAt?: string;
79
+ }
80
+ /** 知识事件(events.jsonl 的每一行)。 */
81
+ export type KnowledgeEvent = {
82
+ schemaVersion: 1;
83
+ id: string;
84
+ at: string;
85
+ kind: 'lesson.create';
86
+ lessonId: string;
87
+ idempotencyKey: string;
88
+ requestHash?: string;
89
+ payload: Lesson;
90
+ } | {
91
+ schemaVersion: 1;
92
+ id: string;
93
+ at: string;
94
+ kind: 'lesson.update';
95
+ lessonId: string;
96
+ revision: number;
97
+ idempotencyKey: string;
98
+ requestHash?: string;
99
+ payload: Partial<Lesson>;
100
+ } | {
101
+ schemaVersion: 1;
102
+ id: string;
103
+ at: string;
104
+ kind: 'lesson.review';
105
+ lessonId: string;
106
+ revision: number;
107
+ idempotencyKey: string;
108
+ requestHash?: string;
109
+ payload: {
110
+ decision: ReviewDecision;
111
+ actor?: string;
112
+ state: LessonState;
113
+ };
114
+ } | {
115
+ schemaVersion: 1;
116
+ id: string;
117
+ at: string;
118
+ kind: 'evidence.add';
119
+ idempotencyKey: string;
120
+ requestHash?: string;
121
+ payload: Evidence;
122
+ };
123
+ /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
124
+ export declare class KnowledgeError extends Error {
125
+ code: 'invalid' | 'revision' | 'duplicate' | 'io';
126
+ /** 可选结构化上下文(脱敏后给工具/面板用;字段不承诺长期稳定)。 */
127
+ details?: Record<string, unknown>;
128
+ constructor(code: 'invalid' | 'revision' | 'duplicate' | 'io', message: string, details?: Record<string, unknown>);
129
+ }
130
+ /** 当前内存模型 / 写盘的 lesson schemaVersion。 */
131
+ export declare const LESSON_SCHEMA_VERSION = 1;
132
+ /** 迁移函数:把 from 版本的原始记录纯函数地转换成 to 版本的原始记录;必须幂等、不得写盘。 */
133
+ export type LessonSchemaMigration = (from: number, raw: unknown, to: number) => unknown;
134
+ /** 已知迁移链占位:v2 落地时在此登记;存储层默认只识别不自动迁移。 */
135
+ export declare const LESSON_MIGRATIONS: Record<number, LessonSchemaMigration>;
136
+ /** validateLesson 的可选前向兼容参数;旧调用签名(单参)保持不变。 */
137
+ export interface ValidateLessonOptions {
138
+ /** 允许直接按当前形状读取的 schemaVersion 列表;默认 [1]。 */
139
+ accept?: number[];
140
+ /** 显式迁移函数:对非当前版本优先生效,返回值必须能通过 v1 校验。 */
141
+ migrate?: LessonSchemaMigration;
142
+ }
143
+ /**
144
+ * 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。
145
+ * 前向兼容:schemaVersion 非当前版本时,先尝试 options.migrate / LESSON_MIGRATIONS;
146
+ * 否则仅当版本在 options.accept 内才按当前形状读取,返回内存模型 v1。
147
+ */
148
+ export declare function validateLesson(value: unknown, options?: ValidateLessonOptions): Lesson;
149
+ /** 校验并归一化一条证据;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
150
+ export declare function validateEvidence(value: unknown): Evidence;
151
+ /**
152
+ * 规范化用于比较的文本:Unicode NFC + 折叠连续空白 + trim。
153
+ * 展示仍用原样;指纹比较时再统一 lowercase。
154
+ */
155
+ export declare function normalizeLessonText(text: string): string;
156
+ /** lessonFingerprint 的最小输入;title 可选,但建议传入,让“应开启/不应开启”这类结论差异参与指纹。 */
157
+ export type LessonFingerprintInput = Pick<Lesson, 'kind' | 'action' | 'when' | 'scope'> & {
158
+ title?: string;
159
+ };
160
+ /**
161
+ * 经验指纹:kind + title + action + when + scope 的规范化小写序列表,
162
+ * sha256 取前 16 位十六进制。否定词、数值、路径、版本条件原样参与。
163
+ */
164
+ export declare function lessonFingerprint(input: LessonFingerprintInput): string;
165
+ /** 状态机:from → to 是否允许。同状态与未知状态一律不允许。 */
166
+ export declare function canTransition(from: LessonState, to: LessonState): boolean;
167
+ /** sessionId + recordSeq 组成的会话定位键;两者缺一不可。 */
168
+ export declare function evidenceSessionKey(evidence: Pick<Evidence, 'sessionId' | 'recordSeq'>): string | undefined;
169
+ /** sourceHash 键;空串视为缺失。 */
170
+ export declare function evidenceHashKey(evidence: Pick<Evidence, 'sourceHash'>): string | undefined;
171
+ /** 证据的天然身份键:同 sessionId + recordSeq 优先,其次同 sourceHash。 */
172
+ export declare function evidenceNaturalKey(evidence: Pick<Evidence, 'sessionId' | 'recordSeq' | 'sourceHash'>): string | undefined;
173
+ /** 去重键:优先天然身份键,否则退回证据 id。 */
174
+ export declare function evidenceDedupKey(evidence: Pick<Evidence, 'id' | 'sessionId' | 'recordSeq' | 'sourceHash'>): string;
175
+ /**
176
+ * 去重后的独立证据数:只看 lesson.evidenceIds 引用到的证据,
177
+ * 同 sessionId+recordSeq 或同 sourceHash 只算一次;verification=claimed 不计入。
178
+ */
179
+ export declare function mergeEvidenceSupport(lesson: Pick<Lesson, 'evidenceIds'>, evidence: Evidence[]): number;
@@ -0,0 +1,378 @@
1
+ /**
2
+ * dsh-dream 知识层:经验(Lesson)、证据(Evidence)与知识事件的类型定义、
3
+ * schema 校验与确定性纯函数。
4
+ *
5
+ * 本模块只做纯计算,不落盘;落盘见 knowledge-store.ts。
6
+ *
7
+ * @module dsh-dream/knowledge
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
11
+ export class KnowledgeError extends Error {
12
+ code;
13
+ /** 可选结构化上下文(脱敏后给工具/面板用;字段不承诺长期稳定)。 */
14
+ details;
15
+ constructor(code, message, details) {
16
+ super(message);
17
+ this.name = 'KnowledgeError';
18
+ this.code = code;
19
+ if (details !== undefined)
20
+ this.details = details;
21
+ }
22
+ }
23
+ const LESSON_KINDS = ['preference', 'procedure', 'pitfall', 'fact'];
24
+ const LESSON_STATES = ['candidate', 'usable', 'disputed', 'stale', 'rejected'];
25
+ const REVIEW_DECISIONS = ['unreviewed', 'accepted', 'rejected'];
26
+ const EVIDENCE_KINDS = ['session', 'user-correction', 'local-artifact'];
27
+ const EVIDENCE_VERIFICATIONS = ['read', 'claimed'];
28
+ /** 允许的状态迁移。rejected 只能显式回到 candidate 重新审阅,不能直接变 usable。 */
29
+ const TRANSITIONS = {
30
+ candidate: ['usable', 'rejected', 'disputed', 'stale'],
31
+ usable: ['disputed', 'stale', 'rejected'],
32
+ disputed: ['usable', 'rejected', 'stale'],
33
+ stale: ['usable', 'rejected', 'disputed'],
34
+ rejected: ['candidate'],
35
+ };
36
+ function fail(field, detail) {
37
+ throw new KnowledgeError('invalid', '知识数据无效(' + field + '):' + detail);
38
+ }
39
+ function isRecord(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ }
42
+ function isEnum(value, allowed) {
43
+ return typeof value === 'string' && allowed.includes(value);
44
+ }
45
+ function readString(value, field, opts = {}) {
46
+ if (value === undefined || value === null) {
47
+ if (opts.required === true)
48
+ fail(field, '缺失');
49
+ return undefined;
50
+ }
51
+ if (typeof value !== 'string')
52
+ fail(field, '必须是字符串');
53
+ if (opts.nonEmpty === true && value.trim() === '')
54
+ fail(field, '不能为空');
55
+ return value;
56
+ }
57
+ function readStringArray(value, field) {
58
+ if (value === undefined || value === null)
59
+ return undefined;
60
+ if (!Array.isArray(value))
61
+ fail(field, '必须是字符串数组');
62
+ const out = [];
63
+ for (let i = 0; i < value.length; i++) {
64
+ const item = value[i];
65
+ if (typeof item !== 'string')
66
+ fail(field + '[' + i + ']', '必须是字符串');
67
+ if (item.trim() === '')
68
+ fail(field + '[' + i + ']', '不能为空');
69
+ if (!out.includes(item))
70
+ out.push(item);
71
+ }
72
+ return out;
73
+ }
74
+ function readIsoDate(value, field, required = false) {
75
+ const text = readString(value, field, { required, nonEmpty: true });
76
+ if (text === undefined)
77
+ return undefined;
78
+ if (Number.isNaN(Date.parse(text)))
79
+ fail(field, '必须是可解析的 ISO 时间');
80
+ return text;
81
+ }
82
+ function readScope(value) {
83
+ if (!isRecord(value))
84
+ fail('scope', '必须是对象');
85
+ const global = value.global;
86
+ if (typeof global !== 'boolean')
87
+ fail('scope.global', '必须是布尔值');
88
+ const projectId = readString(value.projectId, 'scope.projectId', { nonEmpty: true });
89
+ const workspaceRoot = readString(value.workspaceRoot, 'scope.workspaceRoot', { nonEmpty: true });
90
+ const scope = { global };
91
+ if (projectId !== undefined)
92
+ scope.projectId = projectId;
93
+ if (workspaceRoot !== undefined)
94
+ scope.workspaceRoot = workspaceRoot;
95
+ return scope;
96
+ }
97
+ function readApplicability(value) {
98
+ if (value === undefined || value === null)
99
+ return [];
100
+ if (!Array.isArray(value))
101
+ fail('applicability', '必须是对象数组');
102
+ const out = [];
103
+ for (let i = 0; i < value.length; i++) {
104
+ const entry = value[i];
105
+ if (!isRecord(entry))
106
+ fail('applicability[' + i + ']', '必须是对象');
107
+ const item = {};
108
+ const pkg = readString(entry.package, 'applicability[' + i + '].package', { nonEmpty: true });
109
+ const versions = readString(entry.versions, 'applicability[' + i + '].versions', { nonEmpty: true });
110
+ const platform = readString(entry.platform, 'applicability[' + i + '].platform', { nonEmpty: true });
111
+ if (pkg !== undefined)
112
+ item.package = pkg;
113
+ if (versions !== undefined)
114
+ item.versions = versions;
115
+ if (platform !== undefined)
116
+ item.platform = platform;
117
+ out.push(item);
118
+ }
119
+ return out;
120
+ }
121
+ function readReview(value) {
122
+ if (value === undefined || value === null)
123
+ return { decision: 'unreviewed' };
124
+ if (!isRecord(value))
125
+ fail('review', '必须是对象');
126
+ const decision = value.decision;
127
+ if (!isEnum(decision, REVIEW_DECISIONS))
128
+ fail('review.decision', '取值不合法');
129
+ const review = { decision };
130
+ const actor = readString(value.actor, 'review.actor', { nonEmpty: true });
131
+ const at = readIsoDate(value.at, 'review.at');
132
+ if (actor !== undefined)
133
+ review.actor = actor;
134
+ if (at !== undefined)
135
+ review.at = at;
136
+ return review;
137
+ }
138
+ /** 当前内存模型 / 写盘的 lesson schemaVersion。 */
139
+ export const LESSON_SCHEMA_VERSION = 1;
140
+ /** 已知迁移链占位:v2 落地时在此登记;存储层默认只识别不自动迁移。 */
141
+ export const LESSON_MIGRATIONS = {};
142
+ /**
143
+ * 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。
144
+ * 前向兼容:schemaVersion 非当前版本时,先尝试 options.migrate / LESSON_MIGRATIONS;
145
+ * 否则仅当版本在 options.accept 内才按当前形状读取,返回内存模型 v1。
146
+ */
147
+ export function validateLesson(value, options = {}) {
148
+ if (!isRecord(value))
149
+ fail('lesson', '必须是对象');
150
+ const version = value.schemaVersion;
151
+ if (version !== LESSON_SCHEMA_VERSION) {
152
+ const accept = options.accept === undefined ? [LESSON_SCHEMA_VERSION] : options.accept;
153
+ const migration = options.migrate !== undefined
154
+ ? options.migrate
155
+ : (typeof version === 'number' ? LESSON_MIGRATIONS[version] : undefined);
156
+ if (migration !== undefined) {
157
+ let migrated;
158
+ try {
159
+ migrated = migration(version, value, LESSON_SCHEMA_VERSION);
160
+ }
161
+ catch (error) {
162
+ fail('schemaVersion', '迁移失败:' + (error instanceof Error ? error.message : String(error)));
163
+ }
164
+ return validateLesson(migrated, { accept: [LESSON_SCHEMA_VERSION] });
165
+ }
166
+ if (typeof version !== 'number' || !accept.includes(version)) {
167
+ fail('schemaVersion', '不支持 ' + String(version) + '(只识别 ' + accept.join(', ') + ')');
168
+ }
169
+ return validateLesson({ ...value, schemaVersion: LESSON_SCHEMA_VERSION }, { accept: [LESSON_SCHEMA_VERSION] });
170
+ }
171
+ const id = readString(value.id, 'id', { required: true, nonEmpty: true });
172
+ const revision = value.revision;
173
+ if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1)
174
+ fail('revision', '必须是 >= 1 的整数');
175
+ const kind = value.kind;
176
+ if (!isEnum(kind, LESSON_KINDS))
177
+ fail('kind', '取值不合法');
178
+ const title = readString(value.title, 'title', { required: true, nonEmpty: true });
179
+ const action = readString(value.action, 'action', { required: true, nonEmpty: true });
180
+ const when = readString(value.when, 'when', { required: true, nonEmpty: true });
181
+ const exceptions = readStringArray(value.exceptions, 'exceptions') ?? [];
182
+ const scope = readScope(value.scope);
183
+ const applicability = readApplicability(value.applicability);
184
+ const state = value.state;
185
+ if (!isEnum(state, LESSON_STATES))
186
+ fail('state', '取值不合法');
187
+ const evidenceIds = readStringArray(value.evidenceIds, 'evidenceIds') ?? [];
188
+ const independentSupportCount = value.independentSupportCount;
189
+ if (typeof independentSupportCount !== 'number' || !Number.isInteger(independentSupportCount) || independentSupportCount < 0) {
190
+ fail('independentSupportCount', '必须是 >= 0 的整数');
191
+ }
192
+ const review = readReview(value.review);
193
+ const createdAt = readIsoDate(value.createdAt, 'createdAt', true);
194
+ const updatedAt = readIsoDate(value.updatedAt, 'updatedAt', true);
195
+ const lastValidatedAt = readIsoDate(value.lastValidatedAt, 'lastValidatedAt');
196
+ const reviewAfter = readIsoDate(value.reviewAfter, 'reviewAfter');
197
+ const supersedes = readStringArray(value.supersedes, 'supersedes');
198
+ const conflictIds = readStringArray(value.conflictIds, 'conflictIds') ?? [];
199
+ const lesson = {
200
+ schemaVersion: 1,
201
+ id,
202
+ revision,
203
+ kind,
204
+ title,
205
+ action,
206
+ when,
207
+ exceptions,
208
+ scope,
209
+ applicability,
210
+ state,
211
+ evidenceIds,
212
+ independentSupportCount,
213
+ review,
214
+ createdAt,
215
+ updatedAt,
216
+ conflictIds,
217
+ };
218
+ if (lastValidatedAt !== undefined)
219
+ lesson.lastValidatedAt = lastValidatedAt;
220
+ if (reviewAfter !== undefined)
221
+ lesson.reviewAfter = reviewAfter;
222
+ if (supersedes !== undefined)
223
+ lesson.supersedes = supersedes;
224
+ return lesson;
225
+ }
226
+ /** 校验并归一化一条证据;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
227
+ export function validateEvidence(value) {
228
+ if (!isRecord(value))
229
+ fail('evidence', '必须是对象');
230
+ if (value.schemaVersion !== 1)
231
+ fail('evidence.schemaVersion', '只支持 1');
232
+ const id = readString(value.id, 'evidence.id', { required: true, nonEmpty: true });
233
+ const kind = value.kind;
234
+ if (!isEnum(kind, EVIDENCE_KINDS))
235
+ fail('evidence.kind', '取值不合法');
236
+ const verification = value.verification;
237
+ if (!isEnum(verification, EVIDENCE_VERIFICATIONS))
238
+ fail('evidence.verification', '取值不合法');
239
+ const observedAt = readIsoDate(value.observedAt, 'evidence.observedAt', true);
240
+ const summary = readString(value.summary, 'evidence.summary', { required: true, nonEmpty: true });
241
+ const sessionId = readString(value.sessionId, 'evidence.sessionId', { nonEmpty: true });
242
+ const projectId = readString(value.projectId, 'evidence.projectId', { nonEmpty: true });
243
+ const sourceHash = readString(value.sourceHash, 'evidence.sourceHash', { nonEmpty: true });
244
+ const recordSeqRaw = value.recordSeq;
245
+ let recordSeq;
246
+ if (recordSeqRaw !== undefined && recordSeqRaw !== null) {
247
+ if (typeof recordSeqRaw === 'number') {
248
+ if (!Number.isInteger(recordSeqRaw) || recordSeqRaw < 0)
249
+ fail('evidence.recordSeq', '数字必须是 >= 0 的整数');
250
+ recordSeq = recordSeqRaw;
251
+ }
252
+ else if (typeof recordSeqRaw === 'string') {
253
+ if (recordSeqRaw.trim() === '')
254
+ fail('evidence.recordSeq', '字符串不能为空');
255
+ recordSeq = recordSeqRaw;
256
+ }
257
+ else {
258
+ fail('evidence.recordSeq', '必须是数字或字符串');
259
+ }
260
+ }
261
+ const evidence = { schemaVersion: 1, id, kind, observedAt, summary, verification };
262
+ if (sessionId !== undefined)
263
+ evidence.sessionId = sessionId;
264
+ if (recordSeq !== undefined)
265
+ evidence.recordSeq = recordSeq;
266
+ if (projectId !== undefined)
267
+ evidence.projectId = projectId;
268
+ if (sourceHash !== undefined)
269
+ evidence.sourceHash = sourceHash;
270
+ return evidence;
271
+ }
272
+ /**
273
+ * 规范化用于比较的文本:Unicode NFC + 折叠连续空白 + trim。
274
+ * 展示仍用原样;指纹比较时再统一 lowercase。
275
+ */
276
+ export function normalizeLessonText(text) {
277
+ if (typeof text !== 'string')
278
+ throw new KnowledgeError('invalid', 'normalizeLessonText 需要字符串');
279
+ return text.normalize('NFC').replace(/\s+/g, ' ').trim();
280
+ }
281
+ /**
282
+ * 经验指纹:kind + title + action + when + scope 的规范化小写序列表,
283
+ * sha256 取前 16 位十六进制。否定词、数值、路径、版本条件原样参与。
284
+ */
285
+ export function lessonFingerprint(input) {
286
+ const part = (text) => normalizeLessonText(text === undefined ? '' : text).toLowerCase();
287
+ const scope = input.scope;
288
+ const canonical = JSON.stringify([
289
+ input.kind,
290
+ part(input.title),
291
+ part(input.action),
292
+ part(input.when),
293
+ {
294
+ global: scope !== undefined && scope.global === true,
295
+ projectId: part(scope === undefined ? undefined : scope.projectId) || null,
296
+ workspaceRoot: part(scope === undefined ? undefined : scope.workspaceRoot) || null,
297
+ },
298
+ ]);
299
+ return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16);
300
+ }
301
+ /** 状态机:from → to 是否允许。同状态与未知状态一律不允许。 */
302
+ export function canTransition(from, to) {
303
+ if (!isEnum(from, LESSON_STATES) || !isEnum(to, LESSON_STATES))
304
+ return false;
305
+ return TRANSITIONS[from].includes(to);
306
+ }
307
+ /** sessionId + recordSeq 组成的会话定位键;两者缺一不可。 */
308
+ export function evidenceSessionKey(evidence) {
309
+ if (typeof evidence.sessionId === 'string' &&
310
+ evidence.sessionId !== '' &&
311
+ evidence.recordSeq !== undefined &&
312
+ evidence.recordSeq !== null) {
313
+ return 'session:' + evidence.sessionId + '#' + String(evidence.recordSeq);
314
+ }
315
+ return undefined;
316
+ }
317
+ /** sourceHash 键;空串视为缺失。 */
318
+ export function evidenceHashKey(evidence) {
319
+ if (typeof evidence.sourceHash === 'string' && evidence.sourceHash !== '') {
320
+ return 'hash:' + evidence.sourceHash;
321
+ }
322
+ return undefined;
323
+ }
324
+ /** 证据的天然身份键:同 sessionId + recordSeq 优先,其次同 sourceHash。 */
325
+ export function evidenceNaturalKey(evidence) {
326
+ return evidenceSessionKey(evidence) ?? evidenceHashKey(evidence);
327
+ }
328
+ /** 去重键:优先天然身份键,否则退回证据 id。 */
329
+ export function evidenceDedupKey(evidence) {
330
+ return evidenceNaturalKey(evidence) ?? 'id:' + String(evidence.id);
331
+ }
332
+ /**
333
+ * 去重后的独立证据数:只看 lesson.evidenceIds 引用到的证据,
334
+ * 同 sessionId+recordSeq 或同 sourceHash 只算一次;verification=claimed 不计入。
335
+ */
336
+ export function mergeEvidenceSupport(lesson, evidence) {
337
+ const wanted = new Set(lesson.evidenceIds);
338
+ const items = evidence.filter((item) => wanted.has(item.id) && item.verification === 'read');
339
+ const parent = items.map((_, index) => index);
340
+ const find = (index) => {
341
+ let current = index;
342
+ while (parent[current] !== current) {
343
+ parent[current] = parent[parent[current]];
344
+ current = parent[current];
345
+ }
346
+ return current;
347
+ };
348
+ const union = (a, b) => {
349
+ const rootA = find(a);
350
+ const rootB = find(b);
351
+ if (rootA !== rootB)
352
+ parent[rootB] = rootA;
353
+ };
354
+ const bySession = new Map();
355
+ const byHash = new Map();
356
+ for (let i = 0; i < items.length; i++) {
357
+ const sessionKey = evidenceSessionKey(items[i]);
358
+ if (sessionKey !== undefined) {
359
+ const seen = bySession.get(sessionKey);
360
+ if (seen === undefined)
361
+ bySession.set(sessionKey, i);
362
+ else
363
+ union(i, seen);
364
+ }
365
+ const hashKey = evidenceHashKey(items[i]);
366
+ if (hashKey !== undefined) {
367
+ const seen = byHash.get(hashKey);
368
+ if (seen === undefined)
369
+ byHash.set(hashKey, i);
370
+ else
371
+ union(i, seen);
372
+ }
373
+ }
374
+ const roots = new Set();
375
+ for (let i = 0; i < items.length; i++)
376
+ roots.add(find(i));
377
+ return roots.size;
378
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 任务相关经验检索(FREEZE v1 + v1.1 增补 R1/R3/R4)。
3
+ *
4
+ * 顺序:范围过滤 → 排除 rejected/stale/disputed → 候选/无证据策略过滤 → 版本包名上下文 →
5
+ * 确定性排序 → 按排名整条装入的字符预算(遇第一条放不下即停,rankInversionCount===0)。
6
+ * essential(title/when/action/exceptions,含 scopeLabel)永不截断;metadata(whyRelevant/
7
+ * evidenceSummary)放不下时可截断并置 truncated:true。
8
+ *
9
+ * @module dsh-dream/retrieval
10
+ */
11
+ import type { Evidence, Lesson, LessonState } from './knowledge.js';
12
+ /** 检索入参;limit/maxChars 在内部按硬上限裁剪。 */
13
+ export interface RetrievalQuery {
14
+ query: string;
15
+ projectId?: string;
16
+ workspaceRoot?: string;
17
+ /** 默认 5,硬上限 20。 */
18
+ limit?: number;
19
+ /** 默认 3000,硬上限 20000。 */
20
+ maxChars?: number;
21
+ /** 当前任务相关依赖的版本,用于匹配 applicability.versions(R3:必须先识别包名)。 */
22
+ packageVersion?: string;
23
+ /** 显式包名上下文;与 query 文本一起用于识别 applicability[].package(R3)。 */
24
+ packageName?: string;
25
+ /** 是否返回 candidate;默认 true(面板/审计需要)。dream_context 默认传 false(R1)。 */
26
+ includeCandidates?: boolean;
27
+ /** 是否保留 independentSupportCount===0 且非 usable 的条目;默认 true。dream_context 传 false(R1)。 */
28
+ includeNoEvidence?: boolean;
29
+ }
30
+ /** 检索结果中的单条经验(when/exceptions 永不截断)。 */
31
+ export interface RetrievedLesson {
32
+ lessonId: string;
33
+ revision: number;
34
+ title: string;
35
+ when: string;
36
+ action: string;
37
+ exceptions: string[];
38
+ state: LessonState;
39
+ scopeLabel: string;
40
+ whyRelevant: string;
41
+ evidenceSummary: string;
42
+ lastValidatedAt?: string;
43
+ truncated: boolean;
44
+ }
45
+ /** 默认条数。 */
46
+ export declare const DEFAULT_RETRIEVAL_LIMIT = 5;
47
+ /** 条数硬上限。 */
48
+ export declare const MAX_RETRIEVAL_LIMIT = 20;
49
+ /** 默认字符预算。 */
50
+ export declare const DEFAULT_RETRIEVAL_MAX_CHARS = 3000;
51
+ /** 字符预算硬上限。 */
52
+ export declare const MAX_RETRIEVAL_MAX_CHARS = 20000;
53
+ /** 查询侧范围文案(面板与 dream_context 共用)。 */
54
+ export declare function queryScopeLabel(q: Pick<RetrievalQuery, 'projectId' | 'workspaceRoot'>): string;
55
+ /** 经验范围文案(面板与 dream_context 共用)。 */
56
+ export declare function lessonScopeLabel(scope: Lesson['scope']): string;
57
+ /** 证据摘要(面板与 dream_context 共用):独立支持数 + 已读/仅声明 + 脱敏摘要片段。 */
58
+ export declare function buildEvidenceSummary(lesson: Lesson, evidence: Evidence[]): string;
59
+ /** essential 成本:title/when/action/exceptions/scopeLabel,永不截断(R4)。 */
60
+ export declare function essentialLessonChars(item: Pick<RetrievedLesson, 'title' | 'when' | 'action' | 'exceptions' | 'scopeLabel'>): number;
61
+ /** metadata 成本:whyRelevant/evidenceSummary,预算紧时可截断(R4)。 */
62
+ export declare function metadataLessonChars(item: Pick<RetrievedLesson, 'whyRelevant' | 'evidenceSummary'>): number;
63
+ /** 单条结果的完整字符成本(与 dream_context.budget.usedChars 同口径)。 */
64
+ export declare function retrievedLessonChars(item: RetrievedLesson): number;
65
+ /**
66
+ * rank inversion 指标(R4):被 budget-stop 跳过、且排在某个已返回条目之前的条目数。
67
+ * 按排名整条装入的预算策略必须让它恒为 0。
68
+ */
69
+ export declare function rankInversionCount(rankedLessonIds: string[], result: {
70
+ items: Array<Pick<RetrievedLesson, 'lessonId'>>;
71
+ skipped: Array<{
72
+ lessonId: string;
73
+ reason: string;
74
+ }>;
75
+ }): number;
76
+ /** 解析 limit/maxChars,套用默认值与硬上限。 */
77
+ export declare function resolveRetrievalBudget(q: Pick<RetrievalQuery, 'limit' | 'maxChars'>): {
78
+ limit: number;
79
+ maxChars: number;
80
+ };
81
+ /** 简单 semver 条件匹配:支持 = / > / >= / < / <= / ^ / ~、逗号或空格 AND、|| OR、x/* 通配。 */
82
+ export declare function versionSatisfies(versionText: string, rangeText: string): boolean;
83
+ /**
84
+ * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
85
+ * 未知项目(未给 projectId/workspaceRoot)时只返回 global,绝不跨项目扫描。
86
+ *
87
+ * R1:includeCandidates=false 时 candidate 记 skipped:candidate-hold;includeNoEvidence=false 时
88
+ * independentSupportCount===0 且非 usable 记 skipped:no-evidence(优先于 candidate-hold)。
89
+ * R3:applicability.versions 仅在包名被识别时参与;包名识别但版本不满足 → skipped:version-mismatch。
90
+ * R4:预算按排名整条装入,第一条 essential 放不下即停(budget-stop);metadata 可截断。
91
+ */
92
+ export declare function retrieveLessons(lessons: Lesson[], evidence: Evidence[], q: RetrievalQuery): {
93
+ items: RetrievedLesson[];
94
+ skipped: {
95
+ lessonId: string;
96
+ reason: string;
97
+ }[];
98
+ };