@stardustlc/dsh-dream 0.5.0 → 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.
@@ -85,6 +85,7 @@ export type KnowledgeEvent = {
85
85
  kind: 'lesson.create';
86
86
  lessonId: string;
87
87
  idempotencyKey: string;
88
+ requestHash?: string;
88
89
  payload: Lesson;
89
90
  } | {
90
91
  schemaVersion: 1;
@@ -94,6 +95,7 @@ export type KnowledgeEvent = {
94
95
  lessonId: string;
95
96
  revision: number;
96
97
  idempotencyKey: string;
98
+ requestHash?: string;
97
99
  payload: Partial<Lesson>;
98
100
  } | {
99
101
  schemaVersion: 1;
@@ -103,6 +105,7 @@ export type KnowledgeEvent = {
103
105
  lessonId: string;
104
106
  revision: number;
105
107
  idempotencyKey: string;
108
+ requestHash?: string;
106
109
  payload: {
107
110
  decision: ReviewDecision;
108
111
  actor?: string;
@@ -114,15 +117,35 @@ export type KnowledgeEvent = {
114
117
  at: string;
115
118
  kind: 'evidence.add';
116
119
  idempotencyKey: string;
120
+ requestHash?: string;
117
121
  payload: Evidence;
118
122
  };
119
123
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
120
124
  export declare class KnowledgeError extends Error {
121
125
  code: 'invalid' | 'revision' | 'duplicate' | 'io';
122
- constructor(code: 'invalid' | 'revision' | 'duplicate' | 'io', message: string);
126
+ /** 可选结构化上下文(脱敏后给工具/面板用;字段不承诺长期稳定)。 */
127
+ details?: Record<string, unknown>;
128
+ constructor(code: 'invalid' | 'revision' | 'duplicate' | 'io', message: string, details?: Record<string, unknown>);
123
129
  }
124
- /** 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
125
- export declare function validateLesson(value: unknown): Lesson;
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;
126
149
  /** 校验并归一化一条证据;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
127
150
  export declare function validateEvidence(value: unknown): Evidence;
128
151
  /**
package/lib/knowledge.js CHANGED
@@ -10,10 +10,14 @@ import { createHash } from 'node:crypto';
10
10
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
11
11
  export class KnowledgeError extends Error {
12
12
  code;
13
- constructor(code, message) {
13
+ /** 可选结构化上下文(脱敏后给工具/面板用;字段不承诺长期稳定)。 */
14
+ details;
15
+ constructor(code, message, details) {
14
16
  super(message);
15
17
  this.name = 'KnowledgeError';
16
18
  this.code = code;
19
+ if (details !== undefined)
20
+ this.details = details;
17
21
  }
18
22
  }
19
23
  const LESSON_KINDS = ['preference', 'procedure', 'pitfall', 'fact'];
@@ -131,12 +135,39 @@ function readReview(value) {
131
135
  review.at = at;
132
136
  return review;
133
137
  }
134
- /** 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
135
- export function validateLesson(value) {
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 = {}) {
136
148
  if (!isRecord(value))
137
149
  fail('lesson', '必须是对象');
138
- if (value.schemaVersion !== 1)
139
- fail('schemaVersion', '只支持 1');
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
+ }
140
171
  const id = readString(value.id, 'id', { required: true, nonEmpty: true });
141
172
  const revision = value.revision;
142
173
  if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1)
@@ -1,9 +1,10 @@
1
1
  /**
2
- * 任务相关经验检索(FREEZE §3)。
2
+ * 任务相关经验检索(FREEZE v1 + v1.1 增补 R1/R3/R4)。
3
3
  *
4
- * 顺序:范围过滤 → 排除 rejected/stale/disputed → 确定性排序 → 字符预算。
5
- * 条件(when)与例外(exceptions)不可截断:整条放不下时跳过并记 skipped.budget,
6
- * 绝不返回半条经验。
4
+ * 顺序:范围过滤 → 排除 rejected/stale/disputed → 候选/无证据策略过滤 → 版本包名上下文 →
5
+ * 确定性排序 → 按排名整条装入的字符预算(遇第一条放不下即停,rankInversionCount===0)。
6
+ * essential(title/when/action/exceptions,含 scopeLabel)永不截断;metadata(whyRelevant/
7
+ * evidenceSummary)放不下时可截断并置 truncated:true。
7
8
  *
8
9
  * @module dsh-dream/retrieval
9
10
  */
@@ -17,12 +18,19 @@ export interface RetrievalQuery {
17
18
  limit?: number;
18
19
  /** 默认 3000,硬上限 20000。 */
19
20
  maxChars?: number;
20
- /** 当前任务相关依赖的版本,用于匹配 applicability.versions。 */
21
+ /** 当前任务相关依赖的版本,用于匹配 applicability.versions(R3:必须先识别包名)。 */
21
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;
22
29
  }
23
- /** 检索结果中的单条经验(已完整保留 when/exceptions)。 */
30
+ /** 检索结果中的单条经验(when/exceptions 永不截断)。 */
24
31
  export interface RetrievedLesson {
25
32
  lessonId: string;
33
+ revision: number;
26
34
  title: string;
27
35
  when: string;
28
36
  action: string;
@@ -48,8 +56,23 @@ export declare function queryScopeLabel(q: Pick<RetrievalQuery, 'projectId' | 'w
48
56
  export declare function lessonScopeLabel(scope: Lesson['scope']): string;
49
57
  /** 证据摘要(面板与 dream_context 共用):独立支持数 + 已读/仅声明 + 脱敏摘要片段。 */
50
58
  export declare function buildEvidenceSummary(lesson: Lesson, evidence: Evidence[]): string;
51
- /** 单条结果的字符成本(与 dream_context.budget.usedChars 同口径)。 */
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 同口径)。 */
52
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;
53
76
  /** 解析 limit/maxChars,套用默认值与硬上限。 */
54
77
  export declare function resolveRetrievalBudget(q: Pick<RetrievalQuery, 'limit' | 'maxChars'>): {
55
78
  limit: number;
@@ -60,6 +83,11 @@ export declare function versionSatisfies(versionText: string, rangeText: string)
60
83
  /**
61
84
  * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
62
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 可截断。
63
91
  */
64
92
  export declare function retrieveLessons(lessons: Lesson[], evidence: Evidence[], q: RetrievalQuery): {
65
93
  items: RetrievedLesson[];
package/lib/retrieval.js CHANGED
@@ -24,6 +24,16 @@ function oneLine(value) {
24
24
  function clip(value, max) {
25
25
  return value.length > max ? value.slice(0, max) + '…' : value;
26
26
  }
27
+ /** 在预算内截断(含省略号占用);conditions/exceptions 不走这里。 */
28
+ function clipTo(text, max) {
29
+ if (max <= 0)
30
+ return '';
31
+ if (text.length <= max)
32
+ return text;
33
+ if (max === 1)
34
+ return '…';
35
+ return text.slice(0, max - 1) + '…';
36
+ }
27
37
  function pathKey(value) {
28
38
  return value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
29
39
  }
@@ -90,14 +100,48 @@ export function buildEvidenceSummary(lesson, evidence) {
90
100
  return head;
91
101
  return head + ':' + snippets.join(';') + (linked.length > 2 ? ' 等' : '');
92
102
  }
93
- /** 单条结果的字符成本(与 dream_context.budget.usedChars 同口径)。 */
94
- export function retrievedLessonChars(item) {
95
- let total = item.title.length + item.when.length + item.action.length
96
- + item.scopeLabel.length + item.whyRelevant.length + item.evidenceSummary.length;
103
+ /** essential 成本:title/when/action/exceptions/scopeLabel,永不截断(R4)。 */
104
+ export function essentialLessonChars(item) {
105
+ let total = item.title.length + item.when.length + item.action.length + item.scopeLabel.length;
97
106
  for (const exception of item.exceptions)
98
107
  total += exception.length;
99
108
  return total;
100
109
  }
110
+ /** metadata 成本:whyRelevant/evidenceSummary,预算紧时可截断(R4)。 */
111
+ export function metadataLessonChars(item) {
112
+ return item.whyRelevant.length + item.evidenceSummary.length;
113
+ }
114
+ /** 单条结果的完整字符成本(与 dream_context.budget.usedChars 同口径)。 */
115
+ export function retrievedLessonChars(item) {
116
+ return essentialLessonChars(item) + metadataLessonChars(item);
117
+ }
118
+ /**
119
+ * rank inversion 指标(R4):被 budget-stop 跳过、且排在某个已返回条目之前的条目数。
120
+ * 按排名整条装入的预算策略必须让它恒为 0。
121
+ */
122
+ export function rankInversionCount(rankedLessonIds, result) {
123
+ const rank = new Map();
124
+ rankedLessonIds.forEach((id, index) => { if (!rank.has(id))
125
+ rank.set(id, index); });
126
+ const returnedRanks = [];
127
+ for (const item of result.items) {
128
+ const index = rank.get(item.lessonId);
129
+ if (index !== undefined)
130
+ returnedRanks.push(index);
131
+ }
132
+ if (returnedRanks.length === 0)
133
+ return 0;
134
+ const lastReturned = Math.max(...returnedRanks);
135
+ let inversions = 0;
136
+ for (const entry of result.skipped) {
137
+ if (entry.reason !== 'budget-stop' && entry.reason !== 'budget')
138
+ continue;
139
+ const index = rank.get(entry.lessonId);
140
+ if (index !== undefined && index < lastReturned)
141
+ inversions += 1;
142
+ }
143
+ return inversions;
144
+ }
101
145
  /** 解析 limit/maxChars,套用默认值与硬上限。 */
102
146
  export function resolveRetrievalBudget(q) {
103
147
  const rawLimit = q.limit;
@@ -203,21 +247,38 @@ export function versionSatisfies(versionText, rangeText) {
203
247
  return clauses.every((clause) => satisfiesClause(version, clause));
204
248
  });
205
249
  }
250
+ /** R3:只有显式 packageName 一致,或 query 文本出现该包名,才认为包名被识别。 */
251
+ function packageIdentified(pkg, q, queryText) {
252
+ const normalizedPkg = normalizeText(pkg);
253
+ if (normalizedPkg === '')
254
+ return false;
255
+ const explicit = trimmed(q.packageName);
256
+ if (explicit !== undefined && normalizeText(explicit) === normalizedPkg)
257
+ return true;
258
+ return queryText.includes(normalizedPkg);
259
+ }
206
260
  function matchApplicability(lesson, q, queryText) {
207
- const out = { packages: [], versions: [], platforms: [] };
261
+ const out = { packages: [], versions: [], platforms: [], versionMismatch: false };
208
262
  const packageVersion = trimmed(q.packageVersion);
263
+ const identifiedVersionChecks = [];
209
264
  for (const entry of lesson.applicability) {
210
265
  const pkg = trimmed(entry.package);
211
- if (pkg !== undefined && queryText.includes(normalizeText(pkg)) && !out.packages.includes(pkg))
266
+ const identified = pkg !== undefined && packageIdentified(pkg, q, queryText);
267
+ if (identified && pkg !== undefined && !out.packages.includes(pkg))
212
268
  out.packages.push(pkg);
213
269
  const versions = trimmed(entry.versions);
214
- if (packageVersion !== undefined && versions !== undefined && versionSatisfies(packageVersion, versions) && !out.versions.includes(versions)) {
215
- out.versions.push(versions);
270
+ if (identified && versions !== undefined && packageVersion !== undefined) {
271
+ const ok = versionSatisfies(packageVersion, versions);
272
+ identifiedVersionChecks.push(ok);
273
+ if (ok && !out.versions.includes(versions))
274
+ out.versions.push(versions);
216
275
  }
217
276
  const platform = trimmed(entry.platform);
218
- if (platform !== undefined && queryText.includes(normalizeText(platform)) && !out.platforms.includes(platform))
277
+ if (platform !== undefined && queryText.includes(normalizeText(platform)) && !out.platforms.includes(platform)) {
219
278
  out.platforms.push(platform);
279
+ }
220
280
  }
281
+ out.versionMismatch = identifiedVersionChecks.length > 0 && identifiedVersionChecks.every((ok) => !ok);
221
282
  return out;
222
283
  }
223
284
  function queryTerms(query) {
@@ -308,6 +369,7 @@ function toRetrievedLesson(entry, evidence, q) {
308
369
  const lesson = entry.lesson;
309
370
  const item = {
310
371
  lessonId: lesson.id,
372
+ revision: lesson.revision,
311
373
  title: lesson.title,
312
374
  when: lesson.when,
313
375
  action: lesson.action,
@@ -338,11 +400,18 @@ function stateRank(state) {
338
400
  /**
339
401
  * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
340
402
  * 未知项目(未给 projectId/workspaceRoot)时只返回 global,绝不跨项目扫描。
403
+ *
404
+ * R1:includeCandidates=false 时 candidate 记 skipped:candidate-hold;includeNoEvidence=false 时
405
+ * independentSupportCount===0 且非 usable 记 skipped:no-evidence(优先于 candidate-hold)。
406
+ * R3:applicability.versions 仅在包名被识别时参与;包名识别但版本不满足 → skipped:version-mismatch。
407
+ * R4:预算按排名整条装入,第一条 essential 放不下即停(budget-stop);metadata 可截断。
341
408
  */
342
409
  export function retrieveLessons(lessons, evidence, q) {
343
410
  const { limit, maxChars } = resolveRetrievalBudget(q);
344
411
  const terms = queryTerms(q.query ?? '');
345
412
  const queryText = normalizeText(q.query ?? '');
413
+ const includeCandidates = q.includeCandidates !== false;
414
+ const includeNoEvidence = q.includeNoEvidence !== false;
346
415
  const skipped = [];
347
416
  const candidates = [];
348
417
  for (const lesson of lessons) {
@@ -353,8 +422,21 @@ export function retrieveLessons(lessons, evidence, q) {
353
422
  skipped.push({ lessonId: lesson.id, reason: excludedReason });
354
423
  continue;
355
424
  }
356
- const hits = keywordHits(lesson, terms);
425
+ // R3 优先于 R1 的候选扣留:包名已识别但版本不满足时,reason 必须是 version-mismatch。
357
426
  const applicability = matchApplicability(lesson, q, queryText);
427
+ if (applicability.versionMismatch) {
428
+ skipped.push({ lessonId: lesson.id, reason: 'version-mismatch' });
429
+ continue;
430
+ }
431
+ if (!includeNoEvidence && lesson.state !== 'usable' && lesson.independentSupportCount === 0) {
432
+ skipped.push({ lessonId: lesson.id, reason: 'no-evidence' });
433
+ continue;
434
+ }
435
+ if (!includeCandidates && lesson.state === 'candidate') {
436
+ skipped.push({ lessonId: lesson.id, reason: 'candidate-hold' });
437
+ continue;
438
+ }
439
+ const hits = keywordHits(lesson, terms);
358
440
  const applicabilityCount = applicability.packages.length + applicability.versions.length + applicability.platforms.length;
359
441
  if (hits.length === 0 && applicabilityCount === 0)
360
442
  continue;
@@ -385,19 +467,49 @@ export function retrieveLessons(lessons, evidence, q) {
385
467
  });
386
468
  const items = [];
387
469
  let usedChars = 0;
388
- for (const candidate of candidates) {
470
+ for (let index = 0; index < candidates.length; index += 1) {
389
471
  if (items.length >= limit) {
390
- skipped.push({ lessonId: candidate.lesson.id, reason: 'limit' });
391
- continue;
472
+ for (let rest = index; rest < candidates.length; rest += 1) {
473
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'limit' });
474
+ }
475
+ break;
392
476
  }
477
+ const candidate = candidates[index];
393
478
  const item = toRetrievedLesson(candidate, evidence, q);
394
- const cost = retrievedLessonChars(item);
395
- if (usedChars + cost > maxChars) {
396
- skipped.push({ lessonId: candidate.lesson.id, reason: 'budget' });
479
+ const remaining = maxChars - usedChars;
480
+ const essential = essentialLessonChars(item);
481
+ if (essential > remaining) {
482
+ for (let rest = index; rest < candidates.length; rest += 1) {
483
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'budget-stop' });
484
+ }
485
+ break;
486
+ }
487
+ const fullCost = retrievedLessonChars(item);
488
+ if (fullCost <= remaining) {
489
+ items.push(item);
490
+ usedChars += fullCost;
397
491
  continue;
398
492
  }
399
- items.push(item);
400
- usedChars += cost;
493
+ const trimmed = truncateMetadata(item, remaining - essential);
494
+ const trimmedCost = retrievedLessonChars(trimmed);
495
+ if (trimmedCost > remaining) {
496
+ for (let rest = index; rest < candidates.length; rest += 1) {
497
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'budget-stop' });
498
+ }
499
+ break;
500
+ }
501
+ items.push(trimmed);
502
+ usedChars += trimmedCost;
401
503
  }
402
504
  return { items, skipped };
403
505
  }
506
+ /** metadata(whyRelevant/evidenceSummary)按预算截断,essential 保持不变。 */
507
+ function truncateMetadata(item, metadataBudget) {
508
+ const budget = Math.max(0, metadataBudget);
509
+ const whyBudget = Math.min(item.whyRelevant.length, Math.ceil(budget / 2));
510
+ const whyRelevant = clipTo(item.whyRelevant, whyBudget);
511
+ const evidenceSummary = clipTo(item.evidenceSummary, Math.max(0, budget - whyRelevant.length));
512
+ if (whyRelevant === item.whyRelevant && evidenceSummary === item.evidenceSummary)
513
+ return item;
514
+ return { ...item, whyRelevant, evidenceSummary, truncated: true };
515
+ }
package/lib/tools.js CHANGED
@@ -456,7 +456,7 @@ export function buildDreamTools(config) {
456
456
  },
457
457
  description: '适用范围(可选)。',
458
458
  },
459
- evidence: { type: 'array', required: true, items: evidenceItemsSpec(), description: '证据数组,必填;可为空数组(此时只能保存为 candidate)。' },
459
+ evidence: { type: 'array', items: evidenceItemsSpec(), description: '证据数组(可选,缺省为空数组;无证据时只能保存为 candidate)。' },
460
460
  }),
461
461
  output: {
462
462
  schema: baseSchema,
@@ -521,7 +521,17 @@ export function buildDreamTools(config) {
521
521
  })),
522
522
  }));
523
523
  const store = knowledgeStoreOf(cfg);
524
- const { lesson, created } = store.createLesson(lessonInput, idempotencyKey);
524
+ let { lesson, created } = store.createLesson(lessonInput, idempotencyKey);
525
+ // R2:user-correction + read 证据视为用户已采纳;初始 candidate 经验直接进入 usable,
526
+ // 并在 review 记 actor=user。若存储层未来也实现 R2,这里会因 state 已 usable 而跳过(兼容)。
527
+ const hasUserCorrectionRead = evidenceInputs.some((entry) => entry.kind === 'user-correction' && entry.verification === 'read');
528
+ const reviewIsUserAccepted = lesson.review.decision === 'accepted' && lesson.review.actor === 'user';
529
+ const userReviewAllowed = lesson.state === 'candidate' || lesson.state === 'usable';
530
+ // 兼容两种时序:R2′ 未落盘时 createLesson 返回 candidate;已落盘时返回 usable(review 未必带 actor)。
531
+ // user-correction + read 一律记为用户已采纳;rejected/disputed/stale 不覆盖既有审阅结论。
532
+ if (hasUserCorrectionRead && !reviewIsUserAccepted && userReviewAllowed) {
533
+ lesson = store.reviewLesson(lesson.id, 'accepted', lesson.revision, 'dream_learn:user-correction:' + lesson.id + ':' + lesson.revision, 'user');
534
+ }
525
535
  const byId = new Map(store.listEvidence().map((item) => [item.id, item]));
526
536
  const evidence = lesson.evidenceIds
527
537
  .map((id) => byId.get(id))
@@ -536,6 +546,14 @@ export function buildDreamTools(config) {
536
546
  const claimedCount = evidence.filter((item) => item.verification === 'claimed').length;
537
547
  notes.push('证据:' + readCount + ' 条已读(计入独立支持),' + claimedCount + ' 条仅声明(不计入独立支持)。');
538
548
  }
549
+ if (hasUserCorrectionRead) {
550
+ if (lesson.state === 'usable' && lesson.review.decision === 'accepted' && lesson.review.actor === 'user') {
551
+ notes.push('检测到 user-correction + read 证据:按 R2 视为用户已采纳(state=usable,review.actor=user)。');
552
+ }
553
+ else {
554
+ notes.push('检测到 user-correction + read 证据,但经验当前状态为 ' + lesson.state + ':不覆盖既有审阅结论。');
555
+ }
556
+ }
539
557
  if (!created)
540
558
  notes.push('已存在同指纹经验(' + lesson.id + '):复用既有记录并合并新证据,未新增经验。');
541
559
  if (!isGlobal && projectId === undefined && workspaceRoot === undefined) {
@@ -559,14 +577,16 @@ export function buildDreamTools(config) {
559
577
  };
560
578
  const dreamContext = {
561
579
  name: 'dream_context',
562
- description: '取梦:按当前任务返回少量真正适用的经验(只读,默认最多 5 条 / 3000 字符)。只返回 global 或与 projectId/workspaceRoot 精确匹配的经验;未知项目只返回 global,绝不跨项目扫描。rejected/stale/disputed 默认排除并在 skipped 说明原因;条件与例外完整返回,放不下整条跳过;没有相关经验时返回空数组。',
580
+ description: '取梦:按当前任务返回少量真正适用的经验(只读,默认最多 5 条 / 3000 字符)。只返回 global 或与 projectId/workspaceRoot 精确匹配的经验;未知项目只返回 global,绝不跨项目扫描。rejected/stale/disputed 默认排除;候选默认不注入(candidate-hold),无独立证据且非 usable 不注入(no-evidence),版本条件与包名不符不注入(version-mismatch),skipped 均给原因。未审阅候选不得当作已证实结论;只有明确审阅候选时才传 includeCandidates=true。when/exceptions 完整保留,元数据可按预算截断,预算放不下即停。',
563
581
  parameters: compileParameters({
564
582
  query: { type: 'string', required: true, description: '当前任务描述或检索关键词。' },
565
583
  projectId: { type: 'string', description: '当前项目 ID(可选;未知时只返回 global)。' },
566
584
  workspaceRoot: { type: 'string', description: '当前工作区根目录(可选)。' },
567
585
  limit: { type: 'integer', description: '条数上限 1-20(默认 5)。' },
568
586
  maxChars: { type: 'integer', description: '字符预算 1-20000(默认 3000)。' },
569
- packageVersion: { type: 'string', description: '当前相关依赖版本(可选)。' },
587
+ packageVersion: { type: 'string', description: '当前相关依赖版本(可选;需配合包名被识别才参与版本判定)。' },
588
+ packageName: { type: 'string', description: '当前相关依赖包名(可选;与 query 文本一起识别 applicability.package)。' },
589
+ includeCandidates: { type: 'boolean', description: '是否把候选经验纳入任务建议(默认 false;仅明确审阅候选时传 true,无证据候选仍不注入)。' },
570
590
  }),
571
591
  output: {
572
592
  schema: baseSchema,
@@ -575,12 +595,26 @@ export function buildDreamTools(config) {
575
595
  const items = Array.isArray(rec.items) ? rec.items : [];
576
596
  const budget = asRecord(rec.budget);
577
597
  if (items.length === 0) {
578
- return [{ type: 'text', text: '没有与当前任务相关的经验(范围:' + rec.scopeLabel + ')。' }];
598
+ const skipped = Array.isArray(rec.skipped) ? rec.skipped : [];
599
+ const countReason = (reason) => skipped.filter((entry) => asRecord(entry).reason === reason).length;
600
+ const hints = [];
601
+ const held = countReason('candidate-hold');
602
+ const noEvidence = countReason('no-evidence');
603
+ const mismatched = countReason('version-mismatch');
604
+ if (held > 0)
605
+ hints.push(held + ' 条候选待审阅(要审阅候选时显式传 includeCandidates=true)');
606
+ if (noEvidence > 0)
607
+ hints.push(noEvidence + ' 条无独立证据不注入');
608
+ if (mismatched > 0)
609
+ hints.push(mismatched + ' 条版本条件不匹配不注入');
610
+ const suffix = hints.length > 0 ? ';' + hints.join(';') : '';
611
+ return [{ type: 'text', text: '没有可注入的适用经验(范围:' + rec.scopeLabel + suffix + ')。' }];
579
612
  }
580
613
  const lines = ['适用经验 ' + items.length + ' 条(范围:' + rec.scopeLabel + ',字符 ' + budget.usedChars + '/' + budget.maxChars + '):'];
581
614
  for (const item of items) {
582
615
  const lesson = asRecord(item);
583
- lines.push('- [' + lesson.state + '|' + lesson.scopeLabel + '] ' + lesson.title);
616
+ const truncation = lesson.truncated === true ? '|元数据截断' : '';
617
+ lines.push('- [' + lesson.state + '|' + lesson.scopeLabel + '|rev ' + lesson.revision + truncation + '] ' + lesson.title);
584
618
  lines.push(' 何时:' + lesson.when);
585
619
  lines.push(' 行动:' + lesson.action);
586
620
  const exceptions = Array.isArray(lesson.exceptions) ? lesson.exceptions : [];
@@ -598,6 +632,7 @@ export function buildDreamTools(config) {
598
632
  const projectId = optionalString(args, 'projectId');
599
633
  const workspaceRoot = optionalString(args, 'workspaceRoot');
600
634
  const packageVersion = optionalString(args, 'packageVersion');
635
+ const packageName = optionalString(args, 'packageName');
601
636
  const { limit, maxChars } = resolveRetrievalBudget({
602
637
  limit: typeof args.limit === 'number' ? args.limit : undefined,
603
638
  maxChars: typeof args.maxChars === 'number' ? args.maxChars : undefined,
@@ -606,9 +641,13 @@ export function buildDreamTools(config) {
606
641
  query,
607
642
  limit,
608
643
  maxChars,
644
+ // R1:默认不注入候选;claimed-only / 无证据且非 usable 的条目也不注入(R1 附带规则)。
645
+ includeCandidates: args.includeCandidates === true,
646
+ includeNoEvidence: false,
609
647
  ...(projectId !== undefined ? { projectId } : {}),
610
648
  ...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
611
649
  ...(packageVersion !== undefined ? { packageVersion } : {}),
650
+ ...(packageName !== undefined ? { packageName } : {}),
612
651
  };
613
652
  const scopeLabel = queryScopeLabel(retrievalQuery);
614
653
  const dir = knowledgeDirOf(cfg);
@@ -661,7 +700,10 @@ export function buildDreamTools(config) {
661
700
  if (before === undefined)
662
701
  throw new KnowledgeError('invalid', '经验不存在:' + lessonId);
663
702
  if (before.revision !== expectedRevision) {
664
- throw new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
703
+ const error = new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
704
+ error.currentRevision = before.revision;
705
+ error.details = { currentRevision: before.revision };
706
+ throw error;
665
707
  }
666
708
  const conflictWith = stringArray(args, 'conflictWith');
667
709
  const note = optionalString(args, 'note');
package/lib/web.d.ts CHANGED
@@ -24,13 +24,16 @@ export interface DreamWebPayload {
24
24
  /** 本次生效的条数上限。 */
25
25
  limit: number;
26
26
  }
27
- /** 知识路由的统计形状(与 FREEZE §2 的 stats() 对齐)。 */
27
+ /** 知识路由的统计形状(与 FREEZE §2 的 stats() 对齐;v1.1 R8 透传 badLines)。 */
28
28
  export interface KnowledgeWebStats {
29
29
  lessons: number;
30
30
  evidence: number;
31
31
  events: number;
32
32
  byState: Record<string, number>;
33
+ /** 有界回放被截断时为 true(面板据此显示提示行)。 */
33
34
  truncated?: boolean;
35
+ /** 事件 / 证据流中被跳过的坏行数(面板据此显示提示行)。 */
36
+ badLines: number;
34
37
  }
35
38
  /** 知识路由的 JSON 形状:脱敏后的经验 / 证据 / 统计。 */
36
39
  export interface KnowledgeWebPayload {
package/lib/web.js CHANGED
@@ -139,7 +139,7 @@ function maskDeep(value) {
139
139
  }
140
140
  return value;
141
141
  }
142
- /** 统计归一化:数字字段兜底为 0,byState 五个键齐全,truncated 只在为 true 时保留。 */
142
+ /** 统计归一化:数字字段兜底为 0,byState 五个键齐全,truncated 只在为 true 时保留,badLines 始终透传。 */
143
143
  function normalizeKnowledgeStats(value) {
144
144
  const source = value !== null && typeof value === 'object' ? value : {};
145
145
  const rawByState = source.byState !== null && typeof source.byState === 'object'
@@ -159,6 +159,7 @@ function normalizeKnowledgeStats(value) {
159
159
  evidence: numberAt('evidence'),
160
160
  events: numberAt('events'),
161
161
  byState,
162
+ badLines: Math.floor(numberAt('badLines')),
162
163
  };
163
164
  if (source.truncated === true)
164
165
  stats.truncated = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardustlc/dsh-dream",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "DSH 做梦插件:会话间隙回放近期对话、反思写入梦境日记,并把高频教训幂等桥接进 AGENTS.md;默认隐私脱敏,零运行时依赖。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -20,10 +20,10 @@ description: 做梦协议:任务开始取回适用经验,长任务收尾沉
20
20
 
21
21
  ## 一、任务前:取回上下文(dream_context)
22
22
 
23
- - 任务涉及过往代码、用户偏好或踩过的坑时,先调 `dream_context`:给 `query`,能给就给 `projectId` / `workspaceRoot` / `packageVersion`。
23
+ - 任务涉及过往代码、用户偏好或踩过的坑时,先调 `dream_context`:给 `query`,能给就给 `projectId` / `workspaceRoot`,以及 `packageName` + `packageVersion`(`applicability.versions` 只在该包名被识别时参与判定)。审阅候选时显式传 `includeCandidates: true`。
24
24
  - **预算:默认 5 条 / 3000 字符**(工具硬上限 20 条 / 20000 字符)。取回少量真正适用的即可;没有相关经验就接受空结果,不要为了「显得有记忆」硬塞。
25
25
  - `dream_context` 是只读的:不写盘,只返回标题、适用条件、行动建议、证据摘要与「为什么与当前任务相关」。
26
- - 未知当前项目时只会返回全局经验;不会跨项目扫描。候选(candidate)默认不作为任务建议注入。
26
+ - 未知当前项目时只会返回全局经验;不会跨项目扫描。候选(candidate)**可被检索到**(显式 `includeCandidates: true`,用于审阅候选),但**默认不注入任务建议**(`includeCandidates: false` 时记 `skipped: candidate-hold`;`independentSupportCount === 0` 且非 `usable` 的记 `no-evidence`),且排在可用经验之后——未经审阅不得当作已证实结论。
27
27
 
28
28
  ## 二、任务中:先把真实任务做完
29
29
 
@@ -38,7 +38,9 @@ description: 做梦协议:任务开始取回适用经验,长任务收尾沉
38
38
  - 能定位就填 `sessionId` / `recordSeq`;
39
39
  - `summary` 只写脱敏后的一句话,不搬原文;
40
40
  - `verification`:`read` = 你或插件读到了原记录;`claimed` = 仅模型声称。**claimed 只计入证据列表,不计入独立支持数。**
41
+ - **只有真的读到来源(会话记录 / 本地验证产物 / 用户明确纠正)才能标 read,自我判断一律 claimed**("我记得""应该是"都算 claimed);标错 read 等于自我提权,禁止。
41
42
  - 没有证据的观察只能是 candidate,不能写成确定事实,也不能说成「已验证」。
43
+ - 例外:证据为 `kind: "user-correction"` 且 `verification: "read"`(读到用户纠正原文)时,该经验初始状态直接是 `usable`,`review` 记 `actor: "user"`、`decision: "accepted"`——用户决定优先于模型推测;其余证据一律从 `candidate` 起,claimed 不计独立支持、也不能覆盖用户纠正。
42
44
  - 用户明确采纳/驳回、发现经验互相冲突、依赖升级后旧经验需要复核 → 用 `dream_review`(必须带 `expectedRevision`)。
43
45
  - 用户采纳必须来自真实用户指令;模型自己不能冒充 `actor: user`。
44
46
  - 同一证据重复提交是幂等的,不会增加独立支持数。**禁止靠反复调用 `dream_save` 或 `dream_learn` 刷次数**——次数不等于正确,也不等于重要。
@@ -81,10 +83,10 @@ Dreams and **lessons** are two separate pipelines: a **first-person reflection**
81
83
 
82
84
  ## 1. Before the task: retrieve context (dream_context)
83
85
 
84
- - When a task touches past code, user preferences or known pitfalls, call `dream_context` first with `query`, plus `projectId` / `workspaceRoot` / `packageVersion` when available.
86
+ - When a task touches past code, user preferences or known pitfalls, call `dream_context` first with `query`, plus `projectId` / `workspaceRoot` and `packageName` + `packageVersion` when available (`applicability.versions` only counts when that package is identified). Pass `includeCandidates: true` explicitly when reviewing candidates.
85
87
  - **Budget: 5 items / 3000 characters by default** (hard caps 20 items / 20000 characters). Take only what is relevant; an empty result is acceptable — never stuff memories in to look useful.
86
88
  - `dream_context` is read-only: it writes nothing and returns title, applicability, action, evidence summary and why it is relevant.
87
- - With an unknown project it returns global lessons only; it never scans across projects. Candidates are not injected as task advice by default.
89
+ - With an unknown project it returns global lessons only and never scans across projects. Candidates **can be retrieved** (`includeCandidates: true`, for candidate review) but are **held out of default task advice** (`includeCandidates: false` reports `skipped: candidate-hold`; evidence-free non-usable items report `no-evidence`), and they rank after usable lessons — never treat one as a verified conclusion before review.
88
90
 
89
91
  ## 2. During the task: do the real work first
90
92
 
@@ -99,7 +101,9 @@ Dreams and **lessons** are two separate pipelines: a **first-person reflection**
99
101
  - fill `sessionId` / `recordSeq` when the source can be located;
100
102
  - keep `summary` to one masked sentence — never copy the raw record;
101
103
  - `verification`: `read` = the record was actually read; `claimed` = the model merely asserts it. **Claimed evidence does not count toward independent support.**
104
+ - **Only mark read when the source was actually read (session record / local artifact / explicit user correction); anything self-asserted is claimed.** Marking read without a source is self-promotion and is forbidden.
102
105
  - An observation without evidence stays a candidate: do not present it as a fact, and never call it "verified".
106
+ - Exception: when the evidence is `kind: "user-correction"` with `verification: "read"` (the user's correction was actually read), the lesson starts as `usable` and `review` records `actor: "user"` / `decision: "accepted"` — a user decision outranks model inference. All other evidence starts as `candidate`; claimed evidence does not count toward independent support and cannot override a user correction.
103
107
  - For explicit acceptance/rejection, conflicts between lessons, or re-validation after dependency upgrades → call `dream_review` with an `expectedRevision`.
104
108
  - User acceptance must come from a real user instruction; the model must not impersonate `actor: user`.
105
109
  - Submitting the same evidence twice is idempotent and does not increase independent support. **Never grind counts by calling `dream_save` or `dream_learn` repeatedly** — frequency is neither correctness nor importance.