@stardustlc/dsh-dream 0.5.0 → 0.6.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.
@@ -4,6 +4,15 @@ export type LessonKind = 'preference' | 'procedure' | 'pitfall' | 'fact';
4
4
  export type LessonState = 'candidate' | 'usable' | 'disputed' | 'stale' | 'rejected';
5
5
  /** 审阅结论。 */
6
6
  export type ReviewDecision = 'unreviewed' | 'accepted' | 'rejected';
7
+ /** 解析 / 冲突处理的可选痕迹(事件与经验都带得下;旧数据没有也合法)。 */
8
+ export interface LessonReviewResolution {
9
+ /** prefer | drop | merge 等;不强制枚举,由 tools 层约定。 */
10
+ kind: string;
11
+ targetId?: string;
12
+ affectedIds?: string[];
13
+ }
14
+ /** 审阅备注最大长度;超出截断而不是报错。 */
15
+ export declare const REVIEW_NOTE_MAX_LENGTH = 500;
7
16
  /** 一条可复用经验。 */
8
17
  export interface Lesson {
9
18
  schemaVersion: 1;
@@ -36,6 +45,8 @@ export interface Lesson {
36
45
  decision: ReviewDecision;
37
46
  actor?: string;
38
47
  at?: string;
48
+ note?: string;
49
+ resolution?: LessonReviewResolution;
39
50
  };
40
51
  createdAt: string;
41
52
  updatedAt: string;
@@ -58,6 +69,8 @@ export interface Evidence {
58
69
  /** read=插件读到原记录;claimed=仅模型声称。 */
59
70
  verification: 'read' | 'claimed';
60
71
  sourceHash?: string;
72
+ /** 插件生成的核验结果;不是模型给出的保证。 */
73
+ verificationReason?: string;
61
74
  }
62
75
  /** dream_learn / 迁移的输入。 */
63
76
  export interface LessonInput {
@@ -85,6 +98,7 @@ export type KnowledgeEvent = {
85
98
  kind: 'lesson.create';
86
99
  lessonId: string;
87
100
  idempotencyKey: string;
101
+ requestHash?: string;
88
102
  payload: Lesson;
89
103
  } | {
90
104
  schemaVersion: 1;
@@ -94,6 +108,7 @@ export type KnowledgeEvent = {
94
108
  lessonId: string;
95
109
  revision: number;
96
110
  idempotencyKey: string;
111
+ requestHash?: string;
97
112
  payload: Partial<Lesson>;
98
113
  } | {
99
114
  schemaVersion: 1;
@@ -103,10 +118,13 @@ export type KnowledgeEvent = {
103
118
  lessonId: string;
104
119
  revision: number;
105
120
  idempotencyKey: string;
121
+ requestHash?: string;
106
122
  payload: {
107
123
  decision: ReviewDecision;
108
124
  actor?: string;
109
125
  state: LessonState;
126
+ note?: string;
127
+ resolution?: LessonReviewResolution;
110
128
  };
111
129
  } | {
112
130
  schemaVersion: 1;
@@ -114,15 +132,49 @@ export type KnowledgeEvent = {
114
132
  at: string;
115
133
  kind: 'evidence.add';
116
134
  idempotencyKey: string;
135
+ requestHash?: string;
117
136
  payload: Evidence;
137
+ } | {
138
+ schemaVersion: 1;
139
+ id: string;
140
+ at: string;
141
+ kind: 'lesson.batch';
142
+ idempotencyKey: string;
143
+ requestHash?: string;
144
+ payload: {
145
+ updates: Array<{
146
+ lessonId: string;
147
+ revision: number;
148
+ patch: Partial<Lesson>;
149
+ }>;
150
+ };
118
151
  };
119
152
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
120
153
  export declare class KnowledgeError extends Error {
121
154
  code: 'invalid' | 'revision' | 'duplicate' | 'io';
122
- constructor(code: 'invalid' | 'revision' | 'duplicate' | 'io', message: string);
155
+ /** 可选结构化上下文(脱敏后给工具/面板用;字段不承诺长期稳定)。 */
156
+ details?: Record<string, unknown>;
157
+ constructor(code: 'invalid' | 'revision' | 'duplicate' | 'io', message: string, details?: Record<string, unknown>);
158
+ }
159
+ /** 当前内存模型 / 写盘的 lesson schemaVersion。 */
160
+ export declare const LESSON_SCHEMA_VERSION = 1;
161
+ /** 迁移函数:把 from 版本的原始记录纯函数地转换成 to 版本的原始记录;必须幂等、不得写盘。 */
162
+ export type LessonSchemaMigration = (from: number, raw: unknown, to: number) => unknown;
163
+ /** 已知迁移链占位:v2 落地时在此登记;存储层默认只识别不自动迁移。 */
164
+ export declare const LESSON_MIGRATIONS: Record<number, LessonSchemaMigration>;
165
+ /** validateLesson 的可选前向兼容参数;旧调用签名(单参)保持不变。 */
166
+ export interface ValidateLessonOptions {
167
+ /** 允许直接按当前形状读取的 schemaVersion 列表;默认 [1]。 */
168
+ accept?: number[];
169
+ /** 显式迁移函数:对非当前版本优先生效,返回值必须能通过 v1 校验。 */
170
+ migrate?: LessonSchemaMigration;
123
171
  }
124
- /** 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
125
- export declare function validateLesson(value: unknown): Lesson;
172
+ /**
173
+ * 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。
174
+ * 前向兼容:schemaVersion 非当前版本时,先尝试 options.migrate / LESSON_MIGRATIONS;
175
+ * 否则仅当版本在 options.accept 内才按当前形状读取,返回内存模型 v1。
176
+ */
177
+ export declare function validateLesson(value: unknown, options?: ValidateLessonOptions): Lesson;
126
178
  /** 校验并归一化一条证据;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
127
179
  export declare function validateEvidence(value: unknown): Evidence;
128
180
  /**
package/lib/knowledge.js CHANGED
@@ -7,13 +7,19 @@
7
7
  * @module dsh-dream/knowledge
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
+ /** 审阅备注最大长度;超出截断而不是报错。 */
11
+ export const REVIEW_NOTE_MAX_LENGTH = 500;
10
12
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
11
13
  export class KnowledgeError extends Error {
12
14
  code;
13
- constructor(code, message) {
15
+ /** 可选结构化上下文(脱敏后给工具/面板用;字段不承诺长期稳定)。 */
16
+ details;
17
+ constructor(code, message, details) {
14
18
  super(message);
15
19
  this.name = 'KnowledgeError';
16
20
  this.code = code;
21
+ if (details !== undefined)
22
+ this.details = details;
17
23
  }
18
24
  }
19
25
  const LESSON_KINDS = ['preference', 'procedure', 'pitfall', 'fact'];
@@ -114,6 +120,21 @@ function readApplicability(value) {
114
120
  }
115
121
  return out;
116
122
  }
123
+ function readResolution(value) {
124
+ if (value === undefined || value === null)
125
+ return undefined;
126
+ if (!isRecord(value))
127
+ fail('review.resolution', '必须是对象');
128
+ const kind = readString(value.kind, 'review.resolution.kind', { required: true, nonEmpty: true });
129
+ const targetId = readString(value.targetId, 'review.resolution.targetId', { nonEmpty: true });
130
+ const affectedIds = readStringArray(value.affectedIds, 'review.resolution.affectedIds');
131
+ const resolution = { kind };
132
+ if (targetId !== undefined)
133
+ resolution.targetId = targetId;
134
+ if (affectedIds !== undefined)
135
+ resolution.affectedIds = affectedIds;
136
+ return resolution;
137
+ }
117
138
  function readReview(value) {
118
139
  if (value === undefined || value === null)
119
140
  return { decision: 'unreviewed' };
@@ -129,14 +150,50 @@ function readReview(value) {
129
150
  review.actor = actor;
130
151
  if (at !== undefined)
131
152
  review.at = at;
153
+ const noteRaw = readString(value.note, 'review.note');
154
+ if (noteRaw !== undefined) {
155
+ const trimmed = noteRaw.trim();
156
+ if (trimmed !== '')
157
+ review.note = trimmed.length > REVIEW_NOTE_MAX_LENGTH ? trimmed.slice(0, REVIEW_NOTE_MAX_LENGTH) : trimmed;
158
+ }
159
+ const resolution = readResolution(value.resolution);
160
+ if (resolution !== undefined)
161
+ review.resolution = resolution;
132
162
  return review;
133
163
  }
134
- /** 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
135
- export function validateLesson(value) {
164
+ /** 当前内存模型 / 写盘的 lesson schemaVersion。 */
165
+ export const LESSON_SCHEMA_VERSION = 1;
166
+ /** 已知迁移链占位:v2 落地时在此登记;存储层默认只识别不自动迁移。 */
167
+ export const LESSON_MIGRATIONS = {};
168
+ /**
169
+ * 校验并归一化一条经验;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。
170
+ * 前向兼容:schemaVersion 非当前版本时,先尝试 options.migrate / LESSON_MIGRATIONS;
171
+ * 否则仅当版本在 options.accept 内才按当前形状读取,返回内存模型 v1。
172
+ */
173
+ export function validateLesson(value, options = {}) {
136
174
  if (!isRecord(value))
137
175
  fail('lesson', '必须是对象');
138
- if (value.schemaVersion !== 1)
139
- fail('schemaVersion', '只支持 1');
176
+ const version = value.schemaVersion;
177
+ if (version !== LESSON_SCHEMA_VERSION) {
178
+ const accept = options.accept === undefined ? [LESSON_SCHEMA_VERSION] : options.accept;
179
+ const migration = options.migrate !== undefined
180
+ ? options.migrate
181
+ : (typeof version === 'number' ? LESSON_MIGRATIONS[version] : undefined);
182
+ if (migration !== undefined) {
183
+ let migrated;
184
+ try {
185
+ migrated = migration(version, value, LESSON_SCHEMA_VERSION);
186
+ }
187
+ catch (error) {
188
+ fail('schemaVersion', '迁移失败:' + (error instanceof Error ? error.message : String(error)));
189
+ }
190
+ return validateLesson(migrated, { accept: [LESSON_SCHEMA_VERSION] });
191
+ }
192
+ if (typeof version !== 'number' || !accept.includes(version)) {
193
+ fail('schemaVersion', '不支持 ' + String(version) + '(只识别 ' + accept.join(', ') + ')');
194
+ }
195
+ return validateLesson({ ...value, schemaVersion: LESSON_SCHEMA_VERSION }, { accept: [LESSON_SCHEMA_VERSION] });
196
+ }
140
197
  const id = readString(value.id, 'id', { required: true, nonEmpty: true });
141
198
  const revision = value.revision;
142
199
  if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1)
@@ -210,6 +267,7 @@ export function validateEvidence(value) {
210
267
  const sessionId = readString(value.sessionId, 'evidence.sessionId', { nonEmpty: true });
211
268
  const projectId = readString(value.projectId, 'evidence.projectId', { nonEmpty: true });
212
269
  const sourceHash = readString(value.sourceHash, 'evidence.sourceHash', { nonEmpty: true });
270
+ const verificationReason = readString(value.verificationReason, 'evidence.verificationReason', { nonEmpty: true });
213
271
  const recordSeqRaw = value.recordSeq;
214
272
  let recordSeq;
215
273
  if (recordSeqRaw !== undefined && recordSeqRaw !== null) {
@@ -236,6 +294,8 @@ export function validateEvidence(value) {
236
294
  evidence.projectId = projectId;
237
295
  if (sourceHash !== undefined)
238
296
  evidence.sourceHash = sourceHash;
297
+ if (verificationReason !== undefined)
298
+ evidence.verificationReason = verificationReason;
239
299
  return evidence;
240
300
  }
241
301
  /**
package/lib/mask.d.ts CHANGED
@@ -10,4 +10,6 @@ export declare const SECRET_PATTERNS: Array<{
10
10
  label: string;
11
11
  }>;
12
12
  /** 对文本做脱敏;无命中时原样返回。 */
13
- export declare function maskSecrets(text: string): string;
13
+ export declare function maskSecrets(text: string, publicReferences?: readonly string[]): string;
14
+ /** 只认可插件生成的引用形状;不得把任意 *Id / *Hash 字段当作脱敏豁免。 */
15
+ export declare function isDreamReference(value: string): boolean;
package/lib/mask.js CHANGED
@@ -22,12 +22,18 @@ function looksHighEntropy(text) {
22
22
  return /[a-z]/.test(text) && (/[A-Z]/.test(text) || /[0-9]/.test(text));
23
23
  }
24
24
  /** 对文本做脱敏;无命中时原样返回。 */
25
- export function maskSecrets(text) {
25
+ export function maskSecrets(text, publicReferences = []) {
26
26
  let out = text;
27
27
  for (const { re, label } of SECRET_PATTERNS) {
28
28
  out = out.replace(re, '[已脱敏·' + label + ']');
29
29
  }
30
30
  out = out.replace(CRED_ASSIGN, (_m, key, sep) => key + sep + '[已脱敏·凭据]');
31
- out = out.replace(LONG_TOKEN, (m) => (looksHighEntropy(m) ? '[已脱敏·长令牌]' : m));
31
+ const references = new Set(publicReferences.filter(isDreamReference));
32
+ out = out.replace(LONG_TOKEN, (m) => (references.has(m) || !looksHighEntropy(m) ? m : '[已脱敏·长令牌]'));
32
33
  return out;
33
34
  }
35
+ /** 只认可插件生成的引用形状;不得把任意 *Id / *Hash 字段当作脱敏豁免。 */
36
+ export function isDreamReference(value) {
37
+ return /^(?:lsn|evd|evt|brg)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
38
+ || /^pvw_[0-9a-f]{24}$/i.test(value);
39
+ }
@@ -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,21 @@ 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
+ /** 任务目标平台;宿主工具缺省使用当前系统,跨平台任务可显式指定。 */
26
+ platform?: string;
27
+ /** 是否返回 candidate;默认 true(面板/审计需要)。dream_context 默认传 false(R1)。 */
28
+ includeCandidates?: boolean;
29
+ /** 是否保留 independentSupportCount===0 且非 usable 的条目;默认 true。dream_context 传 false(R1)。 */
30
+ includeNoEvidence?: boolean;
22
31
  }
23
- /** 检索结果中的单条经验(已完整保留 when/exceptions)。 */
32
+ /** 检索结果中的单条经验(when/exceptions 永不截断)。 */
24
33
  export interface RetrievedLesson {
25
34
  lessonId: string;
35
+ revision: number;
26
36
  title: string;
27
37
  when: string;
28
38
  action: string;
@@ -48,8 +58,30 @@ export declare function queryScopeLabel(q: Pick<RetrievalQuery, 'projectId' | 'w
48
58
  export declare function lessonScopeLabel(scope: Lesson['scope']): string;
49
59
  /** 证据摘要(面板与 dream_context 共用):独立支持数 + 已读/仅声明 + 脱敏摘要片段。 */
50
60
  export declare function buildEvidenceSummary(lesson: Lesson, evidence: Evidence[]): string;
51
- /** 单条结果的字符成本(与 dream_context.budget.usedChars 同口径)。 */
61
+ /** essential 成本:title/when/action/exceptions/scopeLabel,永不截断(R4)。 */
62
+ export declare function essentialLessonChars(item: Pick<RetrievedLesson, 'title' | 'when' | 'action' | 'exceptions' | 'scopeLabel'>): number;
63
+ /** metadata 成本:whyRelevant/evidenceSummary,预算紧时可截断(R4)。 */
64
+ export declare function metadataLessonChars(item: Pick<RetrievedLesson, 'whyRelevant' | 'evidenceSummary'>): number;
65
+ /** 单条结果的完整字符成本(与 dream_context.budget.usedChars 同口径)。 */
52
66
  export declare function retrievedLessonChars(item: RetrievedLesson): number;
67
+ /**
68
+ * rank inversion 指标(R4):被 budget-stop 跳过、且排在某个已返回条目之前的条目数。
69
+ * 按排名整条装入的预算策略必须让它恒为 0。
70
+ */
71
+ export declare function rankInversionCount(rankedLessonIds: string[], result: {
72
+ items: Array<Pick<RetrievedLesson, 'lessonId'>>;
73
+ skipped: Array<{
74
+ lessonId: string;
75
+ reason: string;
76
+ }>;
77
+ }): number;
78
+ /** skipped.reason 的中文标签(面板与工具共用;未知 reason 原样返回)。 */
79
+ export declare function skippedReasonLabel(reason: string): string;
80
+ /**
81
+ * 浏览态(无 query 上下文)下一条经验的确定性扣留原因;usable 返回 undefined。
82
+ * 与 retrieveLessons 的优先级一致:no-evidence 先于 candidate-hold;version-mismatch 需 query/包名,不适用。
83
+ */
84
+ export declare function deterministicHoldReason(lesson: Pick<Lesson, 'state' | 'independentSupportCount'>): string | undefined;
53
85
  /** 解析 limit/maxChars,套用默认值与硬上限。 */
54
86
  export declare function resolveRetrievalBudget(q: Pick<RetrievalQuery, 'limit' | 'maxChars'>): {
55
87
  limit: number;
@@ -57,9 +89,17 @@ export declare function resolveRetrievalBudget(q: Pick<RetrievalQuery, 'limit' |
57
89
  };
58
90
  /** 简单 semver 条件匹配:支持 = / > / >= / < / <= / ^ / ~、逗号或空格 AND、|| OR、x/* 通配。 */
59
91
  export declare function versionSatisfies(versionText: string, rangeText: string): boolean;
92
+ export declare function normalizePlatform(value: string): string;
93
+ /** 显式任务平台优先,其次无歧义的任务文本,最后宿主平台。 */
94
+ export declare function taskPlatform(query: string, explicit?: string, fallback?: string): string | undefined;
60
95
  /**
61
96
  * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
62
97
  * 未知项目(未给 projectId/workspaceRoot)时只返回 global,绝不跨项目扫描。
98
+ *
99
+ * R1:includeCandidates=false 时 candidate 记 skipped:candidate-hold;includeNoEvidence=false 时
100
+ * independentSupportCount===0 且非 usable 记 skipped:no-evidence(优先于 candidate-hold)。
101
+ * R3:applicability.versions 仅在包名被识别时参与;包名识别但版本不满足 → skipped:version-mismatch。
102
+ * R4:预算按排名整条装入,第一条 essential 放不下即停(budget-stop);metadata 可截断。
63
103
  */
64
104
  export declare function retrieveLessons(lessons: Lesson[], evidence: Evidence[], q: RetrievalQuery): {
65
105
  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,79 @@ 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
+ }
145
+ const SKIPPED_REASON_LABELS = {
146
+ 'state:rejected': '已驳回',
147
+ 'state:stale': '已过期(待复核)',
148
+ 'state:disputed': '冲突未解决',
149
+ 'candidate-hold': '候选未审阅(默认不注入)',
150
+ 'no-evidence': '无独立证据',
151
+ 'version-mismatch': '版本条件不匹配',
152
+ 'platform-mismatch': '平台条件不匹配',
153
+ limit: '超出条数上限',
154
+ 'budget-stop': '预算放不下',
155
+ budget: '预算放不下(旧值)',
156
+ };
157
+ /** skipped.reason 的中文标签(面板与工具共用;未知 reason 原样返回)。 */
158
+ export function skippedReasonLabel(reason) {
159
+ return SKIPPED_REASON_LABELS[reason] ?? reason;
160
+ }
161
+ /**
162
+ * 浏览态(无 query 上下文)下一条经验的确定性扣留原因;usable 返回 undefined。
163
+ * 与 retrieveLessons 的优先级一致:no-evidence 先于 candidate-hold;version-mismatch 需 query/包名,不适用。
164
+ */
165
+ export function deterministicHoldReason(lesson) {
166
+ if (lesson.state === 'rejected')
167
+ return 'state:rejected';
168
+ if (lesson.state === 'stale')
169
+ return 'state:stale';
170
+ if (lesson.state === 'disputed')
171
+ return 'state:disputed';
172
+ if (lesson.state !== 'usable')
173
+ return lesson.independentSupportCount === 0 ? 'no-evidence' : 'candidate-hold';
174
+ return undefined;
175
+ }
101
176
  /** 解析 limit/maxChars,套用默认值与硬上限。 */
102
177
  export function resolveRetrievalBudget(q) {
103
178
  const rawLimit = q.limit;
@@ -203,21 +278,68 @@ export function versionSatisfies(versionText, rangeText) {
203
278
  return clauses.every((clause) => satisfiesClause(version, clause));
204
279
  });
205
280
  }
281
+ /** R3:只有显式 packageName 一致,或 query 文本出现该包名,才认为包名被识别。 */
282
+ function packageIdentified(pkg, q, queryText) {
283
+ const normalizedPkg = normalizeText(pkg);
284
+ if (normalizedPkg === '')
285
+ return false;
286
+ const explicit = trimmed(q.packageName);
287
+ if (explicit !== undefined && normalizeText(explicit) === normalizedPkg)
288
+ return true;
289
+ return queryText.includes(normalizedPkg);
290
+ }
291
+ export function normalizePlatform(value) {
292
+ const key = value.trim().toLowerCase();
293
+ if (['win32', 'win64', 'windows', 'win', '微软windows'].includes(key))
294
+ return 'windows';
295
+ if (['darwin', 'macos', 'mac', 'osx', 'os x'].includes(key))
296
+ return 'macos';
297
+ if (['linux', 'gnu/linux'].includes(key))
298
+ return 'linux';
299
+ return key;
300
+ }
301
+ /** 显式任务平台优先,其次无歧义的任务文本,最后宿主平台。 */
302
+ export function taskPlatform(query, explicit, fallback) {
303
+ if (trimmed(explicit))
304
+ return normalizePlatform(explicit);
305
+ const platforms = [];
306
+ if (/\b(?:windows|win32|win64)\b/i.test(query))
307
+ platforms.push('windows');
308
+ if (/\b(?:linux|ubuntu|debian|fedora)\b/i.test(query))
309
+ platforms.push('linux');
310
+ if (/\b(?:macos|darwin|osx)\b/i.test(query))
311
+ platforms.push('macos');
312
+ return platforms.length === 1 ? platforms[0] : fallback ? normalizePlatform(fallback) : undefined;
313
+ }
206
314
  function matchApplicability(lesson, q, queryText) {
207
- const out = { packages: [], versions: [], platforms: [] };
315
+ const out = { packages: [], versions: [], platforms: [], versionMismatch: false, platformMismatch: false };
208
316
  const packageVersion = trimmed(q.packageVersion);
317
+ const currentPlatform = taskPlatform(q.query, q.platform);
318
+ const platformChecks = [];
319
+ const identifiedVersionChecks = [];
209
320
  for (const entry of lesson.applicability) {
210
321
  const pkg = trimmed(entry.package);
211
- if (pkg !== undefined && queryText.includes(normalizeText(pkg)) && !out.packages.includes(pkg))
322
+ const identified = pkg !== undefined && packageIdentified(pkg, q, queryText);
323
+ if (identified && pkg !== undefined && !out.packages.includes(pkg))
212
324
  out.packages.push(pkg);
213
325
  const versions = trimmed(entry.versions);
214
- if (packageVersion !== undefined && versions !== undefined && versionSatisfies(packageVersion, versions) && !out.versions.includes(versions)) {
215
- out.versions.push(versions);
326
+ if (identified && versions !== undefined && packageVersion !== undefined) {
327
+ const ok = versionSatisfies(packageVersion, versions);
328
+ identifiedVersionChecks.push(ok);
329
+ if (ok && !out.versions.includes(versions))
330
+ out.versions.push(versions);
216
331
  }
217
332
  const platform = trimmed(entry.platform);
218
- if (platform !== undefined && queryText.includes(normalizeText(platform)) && !out.platforms.includes(platform))
219
- out.platforms.push(platform);
333
+ if (currentPlatform !== undefined) {
334
+ const ok = platform === undefined || ['*', 'any', 'all'].includes(normalizePlatform(platform))
335
+ || platform.split(/[,|/]/).some(item => normalizePlatform(item) === currentPlatform);
336
+ platformChecks.push(ok);
337
+ if (ok && platform !== undefined && !out.platforms.includes(platform))
338
+ out.platforms.push(platform);
339
+ }
220
340
  }
341
+ out.versionMismatch = identifiedVersionChecks.length > 0 && identifiedVersionChecks.every((ok) => !ok);
342
+ out.platformMismatch = platformChecks.length > 0 && platformChecks.every((ok) => !ok);
221
343
  return out;
222
344
  }
223
345
  function queryTerms(query) {
@@ -308,6 +430,7 @@ function toRetrievedLesson(entry, evidence, q) {
308
430
  const lesson = entry.lesson;
309
431
  const item = {
310
432
  lessonId: lesson.id,
433
+ revision: lesson.revision,
311
434
  title: lesson.title,
312
435
  when: lesson.when,
313
436
  action: lesson.action,
@@ -338,11 +461,18 @@ function stateRank(state) {
338
461
  /**
339
462
  * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
340
463
  * 未知项目(未给 projectId/workspaceRoot)时只返回 global,绝不跨项目扫描。
464
+ *
465
+ * R1:includeCandidates=false 时 candidate 记 skipped:candidate-hold;includeNoEvidence=false 时
466
+ * independentSupportCount===0 且非 usable 记 skipped:no-evidence(优先于 candidate-hold)。
467
+ * R3:applicability.versions 仅在包名被识别时参与;包名识别但版本不满足 → skipped:version-mismatch。
468
+ * R4:预算按排名整条装入,第一条 essential 放不下即停(budget-stop);metadata 可截断。
341
469
  */
342
470
  export function retrieveLessons(lessons, evidence, q) {
343
471
  const { limit, maxChars } = resolveRetrievalBudget(q);
344
472
  const terms = queryTerms(q.query ?? '');
345
473
  const queryText = normalizeText(q.query ?? '');
474
+ const includeCandidates = q.includeCandidates !== false;
475
+ const includeNoEvidence = q.includeNoEvidence !== false;
346
476
  const skipped = [];
347
477
  const candidates = [];
348
478
  for (const lesson of lessons) {
@@ -353,8 +483,25 @@ export function retrieveLessons(lessons, evidence, q) {
353
483
  skipped.push({ lessonId: lesson.id, reason: excludedReason });
354
484
  continue;
355
485
  }
356
- const hits = keywordHits(lesson, terms);
486
+ // R3 优先于 R1 的候选扣留:包名已识别但版本不满足时,reason 必须是 version-mismatch。
357
487
  const applicability = matchApplicability(lesson, q, queryText);
488
+ if (applicability.platformMismatch) {
489
+ skipped.push({ lessonId: lesson.id, reason: 'platform-mismatch' });
490
+ continue;
491
+ }
492
+ if (applicability.versionMismatch) {
493
+ skipped.push({ lessonId: lesson.id, reason: 'version-mismatch' });
494
+ continue;
495
+ }
496
+ if (!includeNoEvidence && lesson.state !== 'usable' && lesson.independentSupportCount === 0) {
497
+ skipped.push({ lessonId: lesson.id, reason: 'no-evidence' });
498
+ continue;
499
+ }
500
+ if (!includeCandidates && lesson.state === 'candidate') {
501
+ skipped.push({ lessonId: lesson.id, reason: 'candidate-hold' });
502
+ continue;
503
+ }
504
+ const hits = keywordHits(lesson, terms);
358
505
  const applicabilityCount = applicability.packages.length + applicability.versions.length + applicability.platforms.length;
359
506
  if (hits.length === 0 && applicabilityCount === 0)
360
507
  continue;
@@ -385,19 +532,49 @@ export function retrieveLessons(lessons, evidence, q) {
385
532
  });
386
533
  const items = [];
387
534
  let usedChars = 0;
388
- for (const candidate of candidates) {
535
+ for (let index = 0; index < candidates.length; index += 1) {
389
536
  if (items.length >= limit) {
390
- skipped.push({ lessonId: candidate.lesson.id, reason: 'limit' });
391
- continue;
537
+ for (let rest = index; rest < candidates.length; rest += 1) {
538
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'limit' });
539
+ }
540
+ break;
392
541
  }
542
+ const candidate = candidates[index];
393
543
  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' });
544
+ const remaining = maxChars - usedChars;
545
+ const essential = essentialLessonChars(item);
546
+ if (essential > remaining) {
547
+ for (let rest = index; rest < candidates.length; rest += 1) {
548
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'budget-stop' });
549
+ }
550
+ break;
551
+ }
552
+ const fullCost = retrievedLessonChars(item);
553
+ if (fullCost <= remaining) {
554
+ items.push(item);
555
+ usedChars += fullCost;
397
556
  continue;
398
557
  }
399
- items.push(item);
400
- usedChars += cost;
558
+ const trimmed = truncateMetadata(item, remaining - essential);
559
+ const trimmedCost = retrievedLessonChars(trimmed);
560
+ if (trimmedCost > remaining) {
561
+ for (let rest = index; rest < candidates.length; rest += 1) {
562
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'budget-stop' });
563
+ }
564
+ break;
565
+ }
566
+ items.push(trimmed);
567
+ usedChars += trimmedCost;
401
568
  }
402
569
  return { items, skipped };
403
570
  }
571
+ /** metadata(whyRelevant/evidenceSummary)按预算截断,essential 保持不变。 */
572
+ function truncateMetadata(item, metadataBudget) {
573
+ const budget = Math.max(0, metadataBudget);
574
+ const whyBudget = Math.min(item.whyRelevant.length, Math.ceil(budget / 2));
575
+ const whyRelevant = clipTo(item.whyRelevant, whyBudget);
576
+ const evidenceSummary = clipTo(item.evidenceSummary, Math.max(0, budget - whyRelevant.length));
577
+ if (whyRelevant === item.whyRelevant && evidenceSummary === item.evidenceSummary)
578
+ return item;
579
+ return { ...item, whyRelevant, evidenceSummary, truncated: true };
580
+ }