@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,515 @@
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
+ /** 在预算内截断(含省略号占用);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
+ }
37
+ function pathKey(value) {
38
+ return value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
39
+ }
40
+ function samePath(a, b) {
41
+ const ka = pathKey(a);
42
+ const kb = pathKey(b);
43
+ if (ka === '' || kb === '')
44
+ return false;
45
+ const windowsLike = /^[a-zA-Z]:/.test(ka) || /^[a-zA-Z]:/.test(kb) || a.includes('\\') || b.includes('\\');
46
+ return windowsLike ? ka.toLowerCase() === kb.toLowerCase() : ka === kb;
47
+ }
48
+ /** 查询侧范围文案(面板与 dream_context 共用)。 */
49
+ export function queryScopeLabel(q) {
50
+ const project = trimmed(q.projectId);
51
+ const workspace = trimmed(q.workspaceRoot);
52
+ if (project !== undefined && workspace !== undefined)
53
+ return '项目 ' + project + '(工作区 ' + workspace + ')';
54
+ if (project !== undefined)
55
+ return '项目 ' + project;
56
+ if (workspace !== undefined)
57
+ return '工作区 ' + workspace;
58
+ return '全局';
59
+ }
60
+ /** 经验范围文案(面板与 dream_context 共用)。 */
61
+ export function lessonScopeLabel(scope) {
62
+ const project = trimmed(scope.projectId);
63
+ const workspace = trimmed(scope.workspaceRoot);
64
+ if (project !== undefined && workspace !== undefined)
65
+ return '项目 ' + project + '(工作区 ' + workspace + ')';
66
+ if (project !== undefined)
67
+ return '项目 ' + project;
68
+ if (workspace !== undefined)
69
+ return '工作区 ' + workspace;
70
+ if (scope.global === true)
71
+ return '全局';
72
+ return '未限定范围';
73
+ }
74
+ /** 证据摘要(面板与 dream_context 共用):独立支持数 + 已读/仅声明 + 脱敏摘要片段。 */
75
+ export function buildEvidenceSummary(lesson, evidence) {
76
+ const byId = new Map();
77
+ for (const item of evidence) {
78
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string')
79
+ byId.set(item.id, item);
80
+ }
81
+ const linked = [];
82
+ for (const id of lesson.evidenceIds) {
83
+ const found = byId.get(id);
84
+ if (found !== undefined)
85
+ linked.push(found);
86
+ }
87
+ if (linked.length === 0) {
88
+ if (lesson.evidenceIds.length > 0)
89
+ return '证据暂不可核对(' + lesson.evidenceIds.length + ' 条引用缺失)';
90
+ return '无证据(仅候选,不可作为已证实结论)';
91
+ }
92
+ const read = linked.filter((e) => e.verification === 'read').length;
93
+ const claimed = linked.filter((e) => e.verification === 'claimed').length;
94
+ const head = '独立证据 ' + lesson.independentSupportCount + ' 条(已读 ' + read + ' / 仅声明 ' + claimed + ')';
95
+ const snippets = linked
96
+ .slice(0, 2)
97
+ .map((e) => clip(oneLine(String(e.summary ?? '')), 80))
98
+ .filter((s) => s !== '');
99
+ if (snippets.length === 0)
100
+ return head;
101
+ return head + ':' + snippets.join(';') + (linked.length > 2 ? ' 等' : '');
102
+ }
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;
106
+ for (const exception of item.exceptions)
107
+ total += exception.length;
108
+ return total;
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
+ /** 解析 limit/maxChars,套用默认值与硬上限。 */
146
+ export function resolveRetrievalBudget(q) {
147
+ const rawLimit = q.limit;
148
+ const rawMax = q.maxChars;
149
+ const limit = typeof rawLimit === 'number' && Number.isInteger(rawLimit) && rawLimit > 0
150
+ ? Math.min(MAX_RETRIEVAL_LIMIT, rawLimit)
151
+ : DEFAULT_RETRIEVAL_LIMIT;
152
+ const maxChars = typeof rawMax === 'number' && Number.isInteger(rawMax) && rawMax > 0
153
+ ? Math.min(MAX_RETRIEVAL_MAX_CHARS, rawMax)
154
+ : DEFAULT_RETRIEVAL_MAX_CHARS;
155
+ return { limit, maxChars };
156
+ }
157
+ function parseVersion(text) {
158
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(text.trim());
159
+ if (match === null)
160
+ return null;
161
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
162
+ }
163
+ function compareVersion(a, b) {
164
+ for (let i = 0; i < 3; i += 1) {
165
+ if (a[i] !== b[i])
166
+ return a[i] < b[i] ? -1 : 1;
167
+ }
168
+ return 0;
169
+ }
170
+ function parseStarBase(text) {
171
+ const match = /^v?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(text.trim());
172
+ if (match === null)
173
+ return null;
174
+ const parts = [match[1], match[2], match[3]];
175
+ const numbers = [];
176
+ let wildcard = 3;
177
+ for (let i = 0; i < 3; i += 1) {
178
+ const part = parts[i];
179
+ if (part === undefined || part === 'x' || part === 'X' || part === '*') {
180
+ if (wildcard === 3)
181
+ wildcard = i === 0 ? 1 : i;
182
+ numbers.push(0);
183
+ continue;
184
+ }
185
+ if (wildcard !== 3)
186
+ return null;
187
+ numbers.push(Number(part));
188
+ }
189
+ return { base: [numbers[0], numbers[1], numbers[2]], wildcard };
190
+ }
191
+ function satisfiesClause(version, clauseText) {
192
+ const clause = clauseText.trim();
193
+ if (clause === '')
194
+ return false;
195
+ const lowered = clause.toLowerCase();
196
+ if (lowered === '*' || lowered === 'x' || lowered === 'any' || lowered === 'latest')
197
+ return true;
198
+ const operatorMatch = /^(>=|<=|>|<|=|\^|~)/.exec(clause);
199
+ const operator = operatorMatch === null ? '=' : operatorMatch[1];
200
+ const rest = operatorMatch === null ? clause : clause.slice(operatorMatch[1].length).trim();
201
+ if (rest === '')
202
+ return false;
203
+ const star = parseStarBase(rest);
204
+ const base = star === null ? parseVersion(rest) : star.base;
205
+ if (base === null)
206
+ return false;
207
+ const wildcard = star === null ? 3 : star.wildcard;
208
+ const cmp = compareVersion(version, base);
209
+ switch (operator) {
210
+ case '>=': return cmp >= 0;
211
+ case '<=': return cmp <= 0;
212
+ case '>': return cmp > 0;
213
+ case '<': return cmp < 0;
214
+ case '^': {
215
+ if (cmp < 0)
216
+ return false;
217
+ const upper = base[0] > 0
218
+ ? [base[0] + 1, 0, 0]
219
+ : base[1] > 0 ? [0, base[1] + 1, 0] : [0, 0, base[2] + 1];
220
+ return compareVersion(version, upper) < 0;
221
+ }
222
+ case '~': {
223
+ if (cmp < 0)
224
+ return false;
225
+ return compareVersion(version, [base[0], base[1] + 1, 0]) < 0;
226
+ }
227
+ default: {
228
+ if (wildcard >= 3)
229
+ return cmp === 0;
230
+ if (wildcard === 2)
231
+ return version[0] === base[0] && version[1] === base[1];
232
+ return version[0] === base[0];
233
+ }
234
+ }
235
+ }
236
+ /** 简单 semver 条件匹配:支持 = / > / >= / < / <= / ^ / ~、逗号或空格 AND、|| OR、x/* 通配。 */
237
+ export function versionSatisfies(versionText, rangeText) {
238
+ const version = parseVersion(versionText);
239
+ if (version === null)
240
+ return false;
241
+ if (rangeText.trim() === '')
242
+ return false;
243
+ return rangeText.split('||').some((part) => {
244
+ const clauses = part.split(/[\s,]+/).filter((c) => c !== '');
245
+ if (clauses.length === 0)
246
+ return false;
247
+ return clauses.every((clause) => satisfiesClause(version, clause));
248
+ });
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
+ }
260
+ function matchApplicability(lesson, q, queryText) {
261
+ const out = { packages: [], versions: [], platforms: [], versionMismatch: false };
262
+ const packageVersion = trimmed(q.packageVersion);
263
+ const identifiedVersionChecks = [];
264
+ for (const entry of lesson.applicability) {
265
+ const pkg = trimmed(entry.package);
266
+ const identified = pkg !== undefined && packageIdentified(pkg, q, queryText);
267
+ if (identified && pkg !== undefined && !out.packages.includes(pkg))
268
+ out.packages.push(pkg);
269
+ const versions = trimmed(entry.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);
275
+ }
276
+ const platform = trimmed(entry.platform);
277
+ if (platform !== undefined && queryText.includes(normalizeText(platform)) && !out.platforms.includes(platform)) {
278
+ out.platforms.push(platform);
279
+ }
280
+ }
281
+ out.versionMismatch = identifiedVersionChecks.length > 0 && identifiedVersionChecks.every((ok) => !ok);
282
+ return out;
283
+ }
284
+ function queryTerms(query) {
285
+ const normalized = normalizeText(query);
286
+ const terms = new Set();
287
+ const rawTokens = normalized.split(/[^\p{L}\p{N}+#._-]+/u);
288
+ for (const token of rawTokens) {
289
+ if (token === '')
290
+ continue;
291
+ if (token.length >= 2)
292
+ terms.add(token);
293
+ const cjkRuns = token.match(/\p{Script=Han}+/gu);
294
+ if (cjkRuns === null)
295
+ continue;
296
+ for (const run of cjkRuns) {
297
+ if (run.length === 1)
298
+ terms.add(run);
299
+ for (let i = 0; i + 1 < run.length; i += 1)
300
+ terms.add(run.slice(i, i + 2));
301
+ for (let i = 0; i + 2 < run.length; i += 1)
302
+ terms.add(run.slice(i, i + 3));
303
+ }
304
+ }
305
+ return [...terms];
306
+ }
307
+ function keywordHits(lesson, terms) {
308
+ if (terms.length === 0)
309
+ return [];
310
+ const fields = [normalizeText(lesson.title), normalizeText(lesson.action), normalizeText(lesson.when)];
311
+ const hits = [];
312
+ for (const term of terms) {
313
+ if (fields.some((field) => field.includes(term)))
314
+ hits.push(term);
315
+ }
316
+ return hits;
317
+ }
318
+ function scopeMatches(lesson, q) {
319
+ if (lesson.scope.global === true)
320
+ return true;
321
+ const queryProject = trimmed(q.projectId);
322
+ const queryWorkspace = trimmed(q.workspaceRoot);
323
+ const lessonProject = trimmed(lesson.scope.projectId);
324
+ const lessonWorkspace = trimmed(lesson.scope.workspaceRoot);
325
+ if (queryProject !== undefined && lessonProject === queryProject)
326
+ return true;
327
+ if (queryWorkspace !== undefined && lessonWorkspace !== undefined && samePath(lessonWorkspace, queryWorkspace))
328
+ return true;
329
+ return false;
330
+ }
331
+ function buildWhyRelevant(entry, q) {
332
+ const lesson = entry.lesson;
333
+ const parts = [];
334
+ if (entry.keywordHits.length > 0) {
335
+ const shown = entry.keywordHits.slice(0, 3).map((term) => '「' + term + '」').join('、');
336
+ parts.push('命中关键词' + shown + (entry.keywordHits.length > 3 ? ' 等' : ''));
337
+ }
338
+ if (entry.applicability.packages.length > 0) {
339
+ parts.push('适用包「' + entry.applicability.packages.slice(0, 2).join('、') + '」');
340
+ }
341
+ if (entry.applicability.versions.length > 0) {
342
+ const current = trimmed(q.packageVersion);
343
+ parts.push('适用版本「' + entry.applicability.versions.slice(0, 2).join('、') + '」' + (current !== undefined ? '(当前 ' + current + ')' : ''));
344
+ }
345
+ if (entry.applicability.platforms.length > 0) {
346
+ parts.push('适用平台「' + entry.applicability.platforms.slice(0, 2).join('、') + '」');
347
+ }
348
+ const queryProject = trimmed(q.projectId);
349
+ const queryWorkspace = trimmed(q.workspaceRoot);
350
+ const lessonProject = trimmed(lesson.scope.projectId);
351
+ const lessonWorkspace = trimmed(lesson.scope.workspaceRoot);
352
+ if (lesson.scope.global === true && lessonProject === undefined && lessonWorkspace === undefined) {
353
+ parts.push('全局经验');
354
+ }
355
+ else if (queryProject !== undefined && lessonProject === queryProject) {
356
+ parts.push('项目匹配(' + lessonProject + ')');
357
+ }
358
+ else if (queryWorkspace !== undefined && lessonWorkspace !== undefined && samePath(lessonWorkspace, queryWorkspace)) {
359
+ parts.push('工作区匹配(' + lessonWorkspace + ')');
360
+ }
361
+ else if (lesson.scope.global === true) {
362
+ parts.push('全局经验');
363
+ }
364
+ if (parts.length === 0)
365
+ parts.push('范围匹配(' + lessonScopeLabel(lesson.scope) + ')');
366
+ return parts.join(';');
367
+ }
368
+ function toRetrievedLesson(entry, evidence, q) {
369
+ const lesson = entry.lesson;
370
+ const item = {
371
+ lessonId: lesson.id,
372
+ revision: lesson.revision,
373
+ title: lesson.title,
374
+ when: lesson.when,
375
+ action: lesson.action,
376
+ exceptions: [...lesson.exceptions],
377
+ state: lesson.state,
378
+ scopeLabel: lessonScopeLabel(lesson.scope),
379
+ whyRelevant: buildWhyRelevant(entry, q),
380
+ evidenceSummary: buildEvidenceSummary(lesson, evidence),
381
+ truncated: false,
382
+ };
383
+ if (lesson.lastValidatedAt !== undefined)
384
+ item.lastValidatedAt = lesson.lastValidatedAt;
385
+ return item;
386
+ }
387
+ function validatedAtValue(lesson) {
388
+ if (lesson.lastValidatedAt === undefined)
389
+ return 0;
390
+ const parsed = Date.parse(lesson.lastValidatedAt);
391
+ return Number.isFinite(parsed) ? parsed : 0;
392
+ }
393
+ function stateRank(state) {
394
+ if (state === 'usable')
395
+ return 2;
396
+ if (state === 'candidate')
397
+ return 1;
398
+ return 0;
399
+ }
400
+ /**
401
+ * 任务相关经验检索。只返回 global 或与查询项目/工作区精确匹配的记录;
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 可截断。
408
+ */
409
+ export function retrieveLessons(lessons, evidence, q) {
410
+ const { limit, maxChars } = resolveRetrievalBudget(q);
411
+ const terms = queryTerms(q.query ?? '');
412
+ const queryText = normalizeText(q.query ?? '');
413
+ const includeCandidates = q.includeCandidates !== false;
414
+ const includeNoEvidence = q.includeNoEvidence !== false;
415
+ const skipped = [];
416
+ const candidates = [];
417
+ for (const lesson of lessons) {
418
+ if (!scopeMatches(lesson, q))
419
+ continue;
420
+ const excludedReason = EXCLUDED_STATE_REASONS[lesson.state];
421
+ if (excludedReason !== undefined) {
422
+ skipped.push({ lessonId: lesson.id, reason: excludedReason });
423
+ continue;
424
+ }
425
+ // R3 优先于 R1 的候选扣留:包名已识别但版本不满足时,reason 必须是 version-mismatch。
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);
440
+ const applicabilityCount = applicability.packages.length + applicability.versions.length + applicability.platforms.length;
441
+ if (hits.length === 0 && applicabilityCount === 0)
442
+ continue;
443
+ candidates.push({ lesson, keywordHits: hits, applicability });
444
+ }
445
+ candidates.sort((a, b) => {
446
+ const aKeyword = a.keywordHits.length > 0 ? 1 : 0;
447
+ const bKeyword = b.keywordHits.length > 0 ? 1 : 0;
448
+ if (aKeyword !== bKeyword)
449
+ return bKeyword - aKeyword;
450
+ if (a.keywordHits.length !== b.keywordHits.length)
451
+ return b.keywordHits.length - a.keywordHits.length;
452
+ const aApp = a.applicability.packages.length + a.applicability.versions.length + a.applicability.platforms.length;
453
+ const bApp = b.applicability.packages.length + b.applicability.versions.length + b.applicability.platforms.length;
454
+ if (aApp !== bApp)
455
+ return bApp - aApp;
456
+ const aRank = stateRank(a.lesson.state);
457
+ const bRank = stateRank(b.lesson.state);
458
+ if (aRank !== bRank)
459
+ return bRank - aRank;
460
+ const aTime = validatedAtValue(a.lesson);
461
+ const bTime = validatedAtValue(b.lesson);
462
+ if (aTime !== bTime)
463
+ return bTime - aTime;
464
+ if (a.lesson.id === b.lesson.id)
465
+ return 0;
466
+ return a.lesson.id < b.lesson.id ? -1 : 1;
467
+ });
468
+ const items = [];
469
+ let usedChars = 0;
470
+ for (let index = 0; index < candidates.length; index += 1) {
471
+ if (items.length >= limit) {
472
+ for (let rest = index; rest < candidates.length; rest += 1) {
473
+ skipped.push({ lessonId: candidates[rest].lesson.id, reason: 'limit' });
474
+ }
475
+ break;
476
+ }
477
+ const candidate = candidates[index];
478
+ const item = toRetrievedLesson(candidate, evidence, q);
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;
491
+ continue;
492
+ }
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;
503
+ }
504
+ return { items, skipped };
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
+ }