@stardustlc/dsh-dream 0.4.0 → 0.5.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.
@@ -0,0 +1,403 @@
1
+ import { normalizeLessonText } from './knowledge.js';
2
+ /** 默认条数。 */
3
+ export const DEFAULT_RETRIEVAL_LIMIT = 5;
4
+ /** 条数硬上限。 */
5
+ export const MAX_RETRIEVAL_LIMIT = 20;
6
+ /** 默认字符预算。 */
7
+ export const DEFAULT_RETRIEVAL_MAX_CHARS = 3000;
8
+ /** 字符预算硬上限。 */
9
+ export const MAX_RETRIEVAL_MAX_CHARS = 20000;
10
+ const EXCLUDED_STATE_REASONS = {
11
+ rejected: 'state:rejected',
12
+ stale: 'state:stale',
13
+ disputed: 'state:disputed',
14
+ };
15
+ function trimmed(value) {
16
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
17
+ }
18
+ function normalizeText(value) {
19
+ return normalizeLessonText(value).toLowerCase();
20
+ }
21
+ function oneLine(value) {
22
+ return value.replace(/\s+/g, ' ').trim();
23
+ }
24
+ function clip(value, max) {
25
+ return value.length > max ? value.slice(0, max) + '…' : value;
26
+ }
27
+ function pathKey(value) {
28
+ return value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
29
+ }
30
+ function samePath(a, b) {
31
+ const ka = pathKey(a);
32
+ const kb = pathKey(b);
33
+ if (ka === '' || kb === '')
34
+ return false;
35
+ const windowsLike = /^[a-zA-Z]:/.test(ka) || /^[a-zA-Z]:/.test(kb) || a.includes('\\') || b.includes('\\');
36
+ return windowsLike ? ka.toLowerCase() === kb.toLowerCase() : ka === kb;
37
+ }
38
+ /** 查询侧范围文案(面板与 dream_context 共用)。 */
39
+ export function queryScopeLabel(q) {
40
+ const project = trimmed(q.projectId);
41
+ const workspace = trimmed(q.workspaceRoot);
42
+ if (project !== undefined && workspace !== undefined)
43
+ return '项目 ' + project + '(工作区 ' + workspace + ')';
44
+ if (project !== undefined)
45
+ return '项目 ' + project;
46
+ if (workspace !== undefined)
47
+ return '工作区 ' + workspace;
48
+ return '全局';
49
+ }
50
+ /** 经验范围文案(面板与 dream_context 共用)。 */
51
+ export function lessonScopeLabel(scope) {
52
+ const project = trimmed(scope.projectId);
53
+ const workspace = trimmed(scope.workspaceRoot);
54
+ if (project !== undefined && workspace !== undefined)
55
+ return '项目 ' + project + '(工作区 ' + workspace + ')';
56
+ if (project !== undefined)
57
+ return '项目 ' + project;
58
+ if (workspace !== undefined)
59
+ return '工作区 ' + workspace;
60
+ if (scope.global === true)
61
+ return '全局';
62
+ return '未限定范围';
63
+ }
64
+ /** 证据摘要(面板与 dream_context 共用):独立支持数 + 已读/仅声明 + 脱敏摘要片段。 */
65
+ export function buildEvidenceSummary(lesson, evidence) {
66
+ const byId = new Map();
67
+ for (const item of evidence) {
68
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string')
69
+ byId.set(item.id, item);
70
+ }
71
+ const linked = [];
72
+ for (const id of lesson.evidenceIds) {
73
+ const found = byId.get(id);
74
+ if (found !== undefined)
75
+ linked.push(found);
76
+ }
77
+ if (linked.length === 0) {
78
+ if (lesson.evidenceIds.length > 0)
79
+ return '证据暂不可核对(' + lesson.evidenceIds.length + ' 条引用缺失)';
80
+ return '无证据(仅候选,不可作为已证实结论)';
81
+ }
82
+ const read = linked.filter((e) => e.verification === 'read').length;
83
+ const claimed = linked.filter((e) => e.verification === 'claimed').length;
84
+ const head = '独立证据 ' + lesson.independentSupportCount + ' 条(已读 ' + read + ' / 仅声明 ' + claimed + ')';
85
+ const snippets = linked
86
+ .slice(0, 2)
87
+ .map((e) => clip(oneLine(String(e.summary ?? '')), 80))
88
+ .filter((s) => s !== '');
89
+ if (snippets.length === 0)
90
+ return head;
91
+ return head + ':' + snippets.join(';') + (linked.length > 2 ? ' 等' : '');
92
+ }
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;
97
+ for (const exception of item.exceptions)
98
+ total += exception.length;
99
+ return total;
100
+ }
101
+ /** 解析 limit/maxChars,套用默认值与硬上限。 */
102
+ export function resolveRetrievalBudget(q) {
103
+ const rawLimit = q.limit;
104
+ const rawMax = q.maxChars;
105
+ const limit = typeof rawLimit === 'number' && Number.isInteger(rawLimit) && rawLimit > 0
106
+ ? Math.min(MAX_RETRIEVAL_LIMIT, rawLimit)
107
+ : DEFAULT_RETRIEVAL_LIMIT;
108
+ const maxChars = typeof rawMax === 'number' && Number.isInteger(rawMax) && rawMax > 0
109
+ ? Math.min(MAX_RETRIEVAL_MAX_CHARS, rawMax)
110
+ : DEFAULT_RETRIEVAL_MAX_CHARS;
111
+ return { limit, maxChars };
112
+ }
113
+ function parseVersion(text) {
114
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(text.trim());
115
+ if (match === null)
116
+ return null;
117
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
118
+ }
119
+ function compareVersion(a, b) {
120
+ for (let i = 0; i < 3; i += 1) {
121
+ if (a[i] !== b[i])
122
+ return a[i] < b[i] ? -1 : 1;
123
+ }
124
+ return 0;
125
+ }
126
+ function parseStarBase(text) {
127
+ const match = /^v?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(text.trim());
128
+ if (match === null)
129
+ return null;
130
+ const parts = [match[1], match[2], match[3]];
131
+ const numbers = [];
132
+ let wildcard = 3;
133
+ for (let i = 0; i < 3; i += 1) {
134
+ const part = parts[i];
135
+ if (part === undefined || part === 'x' || part === 'X' || part === '*') {
136
+ if (wildcard === 3)
137
+ wildcard = i === 0 ? 1 : i;
138
+ numbers.push(0);
139
+ continue;
140
+ }
141
+ if (wildcard !== 3)
142
+ return null;
143
+ numbers.push(Number(part));
144
+ }
145
+ return { base: [numbers[0], numbers[1], numbers[2]], wildcard };
146
+ }
147
+ function satisfiesClause(version, clauseText) {
148
+ const clause = clauseText.trim();
149
+ if (clause === '')
150
+ return false;
151
+ const lowered = clause.toLowerCase();
152
+ if (lowered === '*' || lowered === 'x' || lowered === 'any' || lowered === 'latest')
153
+ return true;
154
+ const operatorMatch = /^(>=|<=|>|<|=|\^|~)/.exec(clause);
155
+ const operator = operatorMatch === null ? '=' : operatorMatch[1];
156
+ const rest = operatorMatch === null ? clause : clause.slice(operatorMatch[1].length).trim();
157
+ if (rest === '')
158
+ return false;
159
+ const star = parseStarBase(rest);
160
+ const base = star === null ? parseVersion(rest) : star.base;
161
+ if (base === null)
162
+ return false;
163
+ const wildcard = star === null ? 3 : star.wildcard;
164
+ const cmp = compareVersion(version, base);
165
+ switch (operator) {
166
+ case '>=': return cmp >= 0;
167
+ case '<=': return cmp <= 0;
168
+ case '>': return cmp > 0;
169
+ case '<': return cmp < 0;
170
+ case '^': {
171
+ if (cmp < 0)
172
+ return false;
173
+ const upper = base[0] > 0
174
+ ? [base[0] + 1, 0, 0]
175
+ : base[1] > 0 ? [0, base[1] + 1, 0] : [0, 0, base[2] + 1];
176
+ return compareVersion(version, upper) < 0;
177
+ }
178
+ case '~': {
179
+ if (cmp < 0)
180
+ return false;
181
+ return compareVersion(version, [base[0], base[1] + 1, 0]) < 0;
182
+ }
183
+ default: {
184
+ if (wildcard >= 3)
185
+ return cmp === 0;
186
+ if (wildcard === 2)
187
+ return version[0] === base[0] && version[1] === base[1];
188
+ return version[0] === base[0];
189
+ }
190
+ }
191
+ }
192
+ /** 简单 semver 条件匹配:支持 = / > / >= / < / <= / ^ / ~、逗号或空格 AND、|| OR、x/* 通配。 */
193
+ export function versionSatisfies(versionText, rangeText) {
194
+ const version = parseVersion(versionText);
195
+ if (version === null)
196
+ return false;
197
+ if (rangeText.trim() === '')
198
+ return false;
199
+ return rangeText.split('||').some((part) => {
200
+ const clauses = part.split(/[\s,]+/).filter((c) => c !== '');
201
+ if (clauses.length === 0)
202
+ return false;
203
+ return clauses.every((clause) => satisfiesClause(version, clause));
204
+ });
205
+ }
206
+ function matchApplicability(lesson, q, queryText) {
207
+ const out = { packages: [], versions: [], platforms: [] };
208
+ const packageVersion = trimmed(q.packageVersion);
209
+ for (const entry of lesson.applicability) {
210
+ const pkg = trimmed(entry.package);
211
+ if (pkg !== undefined && queryText.includes(normalizeText(pkg)) && !out.packages.includes(pkg))
212
+ out.packages.push(pkg);
213
+ const versions = trimmed(entry.versions);
214
+ if (packageVersion !== undefined && versions !== undefined && versionSatisfies(packageVersion, versions) && !out.versions.includes(versions)) {
215
+ out.versions.push(versions);
216
+ }
217
+ const platform = trimmed(entry.platform);
218
+ if (platform !== undefined && queryText.includes(normalizeText(platform)) && !out.platforms.includes(platform))
219
+ out.platforms.push(platform);
220
+ }
221
+ return out;
222
+ }
223
+ function queryTerms(query) {
224
+ const normalized = normalizeText(query);
225
+ const terms = new Set();
226
+ const rawTokens = normalized.split(/[^\p{L}\p{N}+#._-]+/u);
227
+ for (const token of rawTokens) {
228
+ if (token === '')
229
+ continue;
230
+ if (token.length >= 2)
231
+ terms.add(token);
232
+ const cjkRuns = token.match(/\p{Script=Han}+/gu);
233
+ if (cjkRuns === null)
234
+ continue;
235
+ for (const run of cjkRuns) {
236
+ if (run.length === 1)
237
+ terms.add(run);
238
+ for (let i = 0; i + 1 < run.length; i += 1)
239
+ terms.add(run.slice(i, i + 2));
240
+ for (let i = 0; i + 2 < run.length; i += 1)
241
+ terms.add(run.slice(i, i + 3));
242
+ }
243
+ }
244
+ return [...terms];
245
+ }
246
+ function keywordHits(lesson, terms) {
247
+ if (terms.length === 0)
248
+ return [];
249
+ const fields = [normalizeText(lesson.title), normalizeText(lesson.action), normalizeText(lesson.when)];
250
+ const hits = [];
251
+ for (const term of terms) {
252
+ if (fields.some((field) => field.includes(term)))
253
+ hits.push(term);
254
+ }
255
+ return hits;
256
+ }
257
+ function scopeMatches(lesson, q) {
258
+ if (lesson.scope.global === true)
259
+ return true;
260
+ const queryProject = trimmed(q.projectId);
261
+ const queryWorkspace = trimmed(q.workspaceRoot);
262
+ const lessonProject = trimmed(lesson.scope.projectId);
263
+ const lessonWorkspace = trimmed(lesson.scope.workspaceRoot);
264
+ if (queryProject !== undefined && lessonProject === queryProject)
265
+ return true;
266
+ if (queryWorkspace !== undefined && lessonWorkspace !== undefined && samePath(lessonWorkspace, queryWorkspace))
267
+ return true;
268
+ return false;
269
+ }
270
+ function buildWhyRelevant(entry, q) {
271
+ const lesson = entry.lesson;
272
+ const parts = [];
273
+ if (entry.keywordHits.length > 0) {
274
+ const shown = entry.keywordHits.slice(0, 3).map((term) => '「' + term + '」').join('、');
275
+ parts.push('命中关键词' + shown + (entry.keywordHits.length > 3 ? ' 等' : ''));
276
+ }
277
+ if (entry.applicability.packages.length > 0) {
278
+ parts.push('适用包「' + entry.applicability.packages.slice(0, 2).join('、') + '」');
279
+ }
280
+ if (entry.applicability.versions.length > 0) {
281
+ const current = trimmed(q.packageVersion);
282
+ parts.push('适用版本「' + entry.applicability.versions.slice(0, 2).join('、') + '」' + (current !== undefined ? '(当前 ' + current + ')' : ''));
283
+ }
284
+ if (entry.applicability.platforms.length > 0) {
285
+ parts.push('适用平台「' + entry.applicability.platforms.slice(0, 2).join('、') + '」');
286
+ }
287
+ const queryProject = trimmed(q.projectId);
288
+ const queryWorkspace = trimmed(q.workspaceRoot);
289
+ const lessonProject = trimmed(lesson.scope.projectId);
290
+ const lessonWorkspace = trimmed(lesson.scope.workspaceRoot);
291
+ if (lesson.scope.global === true && lessonProject === undefined && lessonWorkspace === undefined) {
292
+ parts.push('全局经验');
293
+ }
294
+ else if (queryProject !== undefined && lessonProject === queryProject) {
295
+ parts.push('项目匹配(' + lessonProject + ')');
296
+ }
297
+ else if (queryWorkspace !== undefined && lessonWorkspace !== undefined && samePath(lessonWorkspace, queryWorkspace)) {
298
+ parts.push('工作区匹配(' + lessonWorkspace + ')');
299
+ }
300
+ else if (lesson.scope.global === true) {
301
+ parts.push('全局经验');
302
+ }
303
+ if (parts.length === 0)
304
+ parts.push('范围匹配(' + lessonScopeLabel(lesson.scope) + ')');
305
+ return parts.join(';');
306
+ }
307
+ function toRetrievedLesson(entry, evidence, q) {
308
+ const lesson = entry.lesson;
309
+ const item = {
310
+ lessonId: lesson.id,
311
+ title: lesson.title,
312
+ when: lesson.when,
313
+ action: lesson.action,
314
+ exceptions: [...lesson.exceptions],
315
+ state: lesson.state,
316
+ scopeLabel: lessonScopeLabel(lesson.scope),
317
+ whyRelevant: buildWhyRelevant(entry, q),
318
+ evidenceSummary: buildEvidenceSummary(lesson, evidence),
319
+ truncated: false,
320
+ };
321
+ if (lesson.lastValidatedAt !== undefined)
322
+ item.lastValidatedAt = lesson.lastValidatedAt;
323
+ return item;
324
+ }
325
+ function validatedAtValue(lesson) {
326
+ if (lesson.lastValidatedAt === undefined)
327
+ return 0;
328
+ const parsed = Date.parse(lesson.lastValidatedAt);
329
+ return Number.isFinite(parsed) ? parsed : 0;
330
+ }
331
+ function stateRank(state) {
332
+ if (state === 'usable')
333
+ return 2;
334
+ if (state === 'candidate')
335
+ return 1;
336
+ return 0;
337
+ }
338
+ /**
339
+ * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
340
+ * 未知项目(未给 projectId/workspaceRoot)时只返回 global,绝不跨项目扫描。
341
+ */
342
+ export function retrieveLessons(lessons, evidence, q) {
343
+ const { limit, maxChars } = resolveRetrievalBudget(q);
344
+ const terms = queryTerms(q.query ?? '');
345
+ const queryText = normalizeText(q.query ?? '');
346
+ const skipped = [];
347
+ const candidates = [];
348
+ for (const lesson of lessons) {
349
+ if (!scopeMatches(lesson, q))
350
+ continue;
351
+ const excludedReason = EXCLUDED_STATE_REASONS[lesson.state];
352
+ if (excludedReason !== undefined) {
353
+ skipped.push({ lessonId: lesson.id, reason: excludedReason });
354
+ continue;
355
+ }
356
+ const hits = keywordHits(lesson, terms);
357
+ const applicability = matchApplicability(lesson, q, queryText);
358
+ const applicabilityCount = applicability.packages.length + applicability.versions.length + applicability.platforms.length;
359
+ if (hits.length === 0 && applicabilityCount === 0)
360
+ continue;
361
+ candidates.push({ lesson, keywordHits: hits, applicability });
362
+ }
363
+ candidates.sort((a, b) => {
364
+ const aKeyword = a.keywordHits.length > 0 ? 1 : 0;
365
+ const bKeyword = b.keywordHits.length > 0 ? 1 : 0;
366
+ if (aKeyword !== bKeyword)
367
+ return bKeyword - aKeyword;
368
+ if (a.keywordHits.length !== b.keywordHits.length)
369
+ return b.keywordHits.length - a.keywordHits.length;
370
+ const aApp = a.applicability.packages.length + a.applicability.versions.length + a.applicability.platforms.length;
371
+ const bApp = b.applicability.packages.length + b.applicability.versions.length + b.applicability.platforms.length;
372
+ if (aApp !== bApp)
373
+ return bApp - aApp;
374
+ const aRank = stateRank(a.lesson.state);
375
+ const bRank = stateRank(b.lesson.state);
376
+ if (aRank !== bRank)
377
+ return bRank - aRank;
378
+ const aTime = validatedAtValue(a.lesson);
379
+ const bTime = validatedAtValue(b.lesson);
380
+ if (aTime !== bTime)
381
+ return bTime - aTime;
382
+ if (a.lesson.id === b.lesson.id)
383
+ return 0;
384
+ return a.lesson.id < b.lesson.id ? -1 : 1;
385
+ });
386
+ const items = [];
387
+ let usedChars = 0;
388
+ for (const candidate of candidates) {
389
+ if (items.length >= limit) {
390
+ skipped.push({ lessonId: candidate.lesson.id, reason: 'limit' });
391
+ continue;
392
+ }
393
+ 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' });
397
+ continue;
398
+ }
399
+ items.push(item);
400
+ usedChars += cost;
401
+ }
402
+ return { items, skipped };
403
+ }