dsh-lite-memory 0.3.0 → 0.15.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.
Files changed (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
package/view.js ADDED
@@ -0,0 +1,616 @@
1
+ /**
2
+ * 模型能看到什么、自动召回拿不拿得到:都在这一处定。
3
+ * 投影只挑当前字段:历史版本、来源、回执、候选和维护计数永不进入模型视图。
4
+ * 适用范围只有三档:普遍适用 / 带可核验条件 / 未解决;未解决不自动召回。
5
+ */
6
+ import { LIMITS } from './state.js';
7
+
8
+ export const APPLICABILITY = ['all_in_scope', 'conditional', 'unresolved'];
9
+ export const SELECTION_MODES = ['complete', 'examples'];
10
+ export const LINK_TYPES = ['related'];
11
+ export const CONDITION_KEYS = ['targetOs', 'shell', 'runtime', 'machine', 'projectRoot', 'pathPrefix', 'artifactKey', 'taskKind', 'seriesId', 'phase'];
12
+
13
+ /** 卡片与单次请求的硬上限:整张卡要么进来,要么省略,不截半句前提。 */
14
+ export const CARD = { max: 3, bytes: 1200 };
15
+ export const TOOL_BYTES = 6144;
16
+ export const REQUEST_BYTES = 8192;
17
+ export const REQUEST_LIMITS = { bodies: 3, reads: 8 };
18
+
19
+ const MAX_CONDITIONS = 8;
20
+ const MAX_TRIGGERS = 4;
21
+ const MAX_LINKS = 3;
22
+ const MAX_SELECTION_ITEMS = 20;
23
+ const CONDITION_CHARS = 60;
24
+ const PATH_CHARS = 200;
25
+ const STOPWORDS = new Set([
26
+ '这个', '那个', '一下', '什么', '怎么', '可以', '需要', '现在', '然后', '还是', '就是',
27
+ '我们', '你们', '他们', '自己', '时候', '东西', '事情', '问题', '地方', '已经', '一个',
28
+ 'the', 'and', 'for', 'with', 'that', 'this', 'you', 'are', 'was', 'have', 'not', 'but',
29
+ ]);
30
+
31
+ function plain(value, max) {
32
+ if (typeof value !== 'string') return '';
33
+ return value.normalize('NFKC').replace(/\s+/g, ' ').trim().slice(0, max);
34
+ }
35
+
36
+ function fail(message) {
37
+ throw new Error(message);
38
+ }
39
+
40
+ function listOf(value) {
41
+ return Array.isArray(value) ? value : null;
42
+ }
43
+
44
+ /** 条件只认固定键:等值比较,路径只认前缀。认识的键少,但每个都能核验。 */
45
+ export function normalizeConditions(input) {
46
+ if (input === undefined) return undefined;
47
+ if (input === null) return [];
48
+ const list = listOf(input);
49
+ if (!list) fail('条件要写成 [{key, value}] 的数组');
50
+ if (list.length > MAX_CONDITIONS) fail(`条件最多 ${MAX_CONDITIONS} 条`);
51
+ const seen = new Set();
52
+ const out = [];
53
+ for (const item of list) {
54
+ if (!item || typeof item !== 'object') fail('条件要写成 [{key, value}]');
55
+ const key = plain(item.key, CONDITION_CHARS);
56
+ if (!CONDITION_KEYS.includes(key)) fail(`不认这个条件键:${key || '空'}`);
57
+ if (seen.has(key)) fail(`条件键重复:${key}`);
58
+ seen.add(key);
59
+ const max = key === 'pathPrefix' ? PATH_CHARS : CONDITION_CHARS;
60
+ const value = plain(item.value, max);
61
+ if (!value) fail(`条件 ${key} 缺少值`);
62
+ out.push({ key, value });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** 排除清单只对 complete 有意义:artifactKey 指向具体对象,清单是闭集。 */
68
+ export function normalizeSelection(input) {
69
+ if (input === undefined) return undefined;
70
+ if (input === null) return null;
71
+ if (typeof input !== 'object' || Array.isArray(input)) fail('selection 要写成 {mode, artifactKey?, items?}');
72
+ const mode = plain(input.mode, 20);
73
+ if (!SELECTION_MODES.includes(mode)) fail(`selection.mode 只能是 ${SELECTION_MODES.join(' / ')}`);
74
+ const artifactKey = plain(input.artifactKey, CONDITION_CHARS);
75
+ const items = listOf(input.items) || [];
76
+ if (items.length > MAX_SELECTION_ITEMS) fail(`清单最多 ${MAX_SELECTION_ITEMS} 条`);
77
+ const clean = [];
78
+ for (const item of items) {
79
+ const text = plain(typeof item === 'string' ? item : item && item.value, CONDITION_CHARS);
80
+ if (!text) fail('清单里有空项');
81
+ if (!clean.includes(text)) clean.push(text);
82
+ }
83
+ if (mode === 'complete' && !artifactKey) fail('complete 要给 artifactKey,否则不知道排除的是哪个对象');
84
+ return { mode, artifactKey, items: clean };
85
+ }
86
+
87
+ export function normalizeApplicability(value) {
88
+ if (value === undefined) return undefined;
89
+ const text = plain(value, 20);
90
+ if (!APPLICABILITY.includes(text)) fail(`适用范围只能是 ${APPLICABILITY.join(' / ')}`);
91
+ return text;
92
+ }
93
+
94
+ export function normalizeTriggers(input) {
95
+ if (input === undefined) return undefined;
96
+ if (input === null) return [];
97
+ const list = listOf(input);
98
+ if (!list) fail('触发词要写成字符串数组');
99
+ if (list.length > MAX_TRIGGERS) fail(`触发词最多 ${MAX_TRIGGERS} 个`);
100
+ const out = [];
101
+ for (const item of list) {
102
+ const text = plain(item, CONDITION_CHARS);
103
+ if (!text) fail('触发词里有空项');
104
+ if (!out.includes(text)) out.push(text);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** related 只连当前库里真实存在的条目;自指、重复、第 4 条都拒绝,不自动挤掉旧的。 */
110
+ export function normalizeLinks(input, selfId = '') {
111
+ if (input === undefined) return undefined;
112
+ if (input === null) return [];
113
+ const list = listOf(input);
114
+ if (!list) fail('关联要写成 [{type, id}] 的数组');
115
+ if (list.length > MAX_LINKS) fail(`关联最多 ${MAX_LINKS} 条`);
116
+ const out = [];
117
+ for (const item of list) {
118
+ if (!item || typeof item !== 'object') fail('关联要写成 {type:"related", id}');
119
+ const type = plain(item.type, 20) || 'related';
120
+ if (!LINK_TYPES.includes(type)) fail(`关联类型只能是 ${LINK_TYPES.join(' / ')}`);
121
+ const id = plain(item.id, 80);
122
+ if (!/^mem_[a-z0-9]{6,}$/.test(id)) fail(`关联目标 id 不合法:${id || '空'}`);
123
+ if (selfId && id === selfId) fail('不能关联自己');
124
+ if (out.some((link) => link.id === id)) fail(`关联重复:${id}`);
125
+ out.push({ type, id });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * 没给适用范围时按字段推断:有结构化条件才是 conditional;只有一句 when 的旧写法
132
+ * 归 unresolved,宁可等用户确认,也不拿自然语言当条件。
133
+ */
134
+ export function decideApplicability({ applicability, conditions, when } = {}) {
135
+ if (applicability) return applicability;
136
+ if (Array.isArray(conditions) && conditions.length > 0) return 'conditional';
137
+ if (plain(when, LIMITS.whenChars)) return 'unresolved';
138
+ return 'all_in_scope';
139
+ }
140
+
141
+ /** 三档之外再查一致性和来源:写进去之前就把自相矛盾的组合拒掉。 */
142
+ export function validateApplicability(applicability, conditions, selection, when) {
143
+ const mode = decideApplicability({ applicability, conditions, when });
144
+ const list = Array.isArray(conditions) ? conditions : [];
145
+ if (mode === 'all_in_scope' && list.length > 0) fail('普遍适用不能带条件;要带条件就把适用范围写成 conditional');
146
+ if (mode === 'conditional' && list.length === 0) fail('条件适用至少要给一个条件');
147
+ if (selection && selection.mode === 'complete' && !selection.artifactKey) fail('complete 要给 artifactKey');
148
+ return mode;
149
+ }
150
+
151
+ /** 已核验的上下文只来自任务配置,不从模型措辞里猜。 */
152
+ export function trustedContext(scope, options = {}) {
153
+ const cwd = typeof options.cwd === 'string' && options.cwd ? options.cwd : (scope && scope.cwd) || '';
154
+ return {
155
+ targetOs: options.targetOs,
156
+ shell: options.shell,
157
+ runtime: options.runtime,
158
+ machine: options.machine,
159
+ projectRoot: options.projectRoot || cwd,
160
+ pathPrefix: options.pathPrefix === undefined ? cwd : options.pathPrefix,
161
+ artifactKey: options.artifactKey,
162
+ /** 任务键只接受已确认的项目默认或本轮请求绑定;自由措辞不能直接填进来。 */
163
+ ...(options.taskKind ? { taskKind: String(options.taskKind) } : {}),
164
+ ...(options.seriesId ? { seriesId: String(options.seriesId) } : {}),
165
+ ...(options.phase ? { phase: String(options.phase) } : {}),
166
+ };
167
+ }
168
+
169
+ function sameText(left, right) {
170
+ return plain(left, PATH_CHARS).toLowerCase() === plain(right, PATH_CHARS).toLowerCase();
171
+ }
172
+
173
+ function compareOne(condition, context) {
174
+ const value = context[condition.key];
175
+ if (condition.key === 'pathPrefix') {
176
+ if (!value) return 'unknown';
177
+ const prefix = plain(condition.value, PATH_CHARS).replace(/\/+$/, '');
178
+ const target = plain(value, PATH_CHARS);
179
+ return target === prefix || target.startsWith(`${prefix}/`) ? 'match' : 'mismatch';
180
+ }
181
+ if (value === undefined || value === null || value === '') return 'unknown';
182
+ return sameText(condition.value, value) ? 'match' : 'mismatch';
183
+ }
184
+
185
+ /**
186
+ * 条件与 selection 一起裁决:任何一条 mismatch 就不适用;缺上下文算 unknown,
187
+ * 不把「不知道」当「不存在」,也不把「未解决」当「普遍适用」。
188
+ */
189
+ export function evaluate(record, context) {
190
+ const applicability = record.applicability || 'all_in_scope';
191
+ if (applicability === 'unresolved') return { state: 'unknown', missing: [], failed: [], reason: 'unresolved' };
192
+ const missing = [];
193
+ const failed = [];
194
+ for (const condition of record.conditions || []) {
195
+ const result = compareOne(condition, context || {});
196
+ if (result === 'unknown') missing.push(condition.key);
197
+ if (result === 'mismatch') failed.push(condition.key);
198
+ }
199
+ const selection = record.selection;
200
+ if (selection && selection.mode === 'complete') {
201
+ const artifactKey = (context || {}).artifactKey;
202
+ if (!artifactKey) missing.push('artifactKey');
203
+ else if (!sameText(selection.artifactKey, artifactKey)) failed.push('artifactKey');
204
+ }
205
+ if (failed.length) return { state: 'mismatch', missing, failed, reason: 'condition_mismatch' };
206
+ if (missing.length) return { state: 'unknown', missing, failed, reason: 'needs_context' };
207
+ return { state: 'match', missing, failed, reason: '' };
208
+ }
209
+
210
+ /** 自动召回只认 match;普通工具读可以带着 unknown 状态看正文,但不会当成已确认适用。 */
211
+ export function statusOf(record, context) {
212
+ if (!record || record.status === 'archived') return 'gone';
213
+ const result = evaluate(record, context);
214
+ if (result.state === 'match') return 'ok';
215
+ if (result.state === 'unknown') return 'needs_context';
216
+ return 'not_applicable';
217
+ }
218
+
219
+ /** 旧库迁过来的条目没复核过,先让人看见再决定,不自动注入。 */
220
+ export function canAutoRecall(record, context) {
221
+ if (!record || record.reviewState === 'legacy_unreviewed') return false;
222
+ return statusOf(record, context) === 'ok';
223
+ }
224
+
225
+ /**
226
+ * 学习规则是否还在有效期内:paused/pending 和被时间淘汰的 trial 都不能当依据。
227
+ * 只对实验来源生效;普通记忆不看这一层。
228
+ */
229
+ export function experimentalUsable(record, now = Date.now()) {
230
+ if (!record) return false;
231
+ const learning = record.learning && typeof record.learning === 'object' ? record.learning : null;
232
+ if (!learning && record.origin !== 'project_evolution') return true;
233
+ if (!learning) return false;
234
+ if (learning.state === 'active') return true;
235
+ if (learning.state !== 'trial') return false;
236
+ const until = Date.parse(String(learning.trialUntil || ''));
237
+ return !Number.isFinite(until) || until > Number(now);
238
+ }
239
+
240
+ function gramsOf(text) {
241
+ const plainText = plain(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
242
+ const set = new Set();
243
+ if (!plainText) return set;
244
+ if (plainText.length === 1) {
245
+ set.add(plainText);
246
+ return set;
247
+ }
248
+ for (let i = 0; i + 1 < plainText.length; i += 1) set.add(plainText.slice(i, i + 2));
249
+ return set;
250
+ }
251
+
252
+ function wordsOf(text) {
253
+ const plainText = plain(text, 400).toLowerCase();
254
+ const out = [];
255
+ try {
256
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
257
+ for (const part of segmenter.segment(plainText)) {
258
+ const word = part.segment.trim();
259
+ if (!word || !/[\p{L}\p{N}]/u.test(word)) continue;
260
+ if (STOPWORDS.has(word)) continue;
261
+ out.push(word);
262
+ }
263
+ } catch {
264
+ for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) if (word && !STOPWORDS.has(word)) out.push(word);
265
+ }
266
+ return out.slice(0, 24);
267
+ }
268
+
269
+ const WEIGHT = { title: 3, keywords: 4, summary: 1.5, when: 1 };
270
+
271
+ /**
272
+ * 轻量匹配:标题 / 旧标题 / 关键词 / 摘要各算各的分,先过阈值再排序。
273
+ * 排序固定用 id 兜底,同一批数据每次顺序一致。
274
+ */
275
+ export function matchScore(record, query) {
276
+ const raw = plain(query, 400);
277
+ if (!raw) return { score: 0, hints: [] };
278
+ if (new RegExp(`\\b${String(record.id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(raw)) {
279
+ return { score: 1000, hints: ['id'] };
280
+ }
281
+ const grams = gramsOf(raw);
282
+ const words = wordsOf(raw);
283
+ const hints = [];
284
+ let score = 0;
285
+ const hit = (text, weight) => {
286
+ if (!text) return 0;
287
+ const pool = gramsOf(text);
288
+ let shared = 0;
289
+ for (const gram of grams) if (pool.has(gram)) shared += 1;
290
+ if (shared === 0) return 0;
291
+ return (shared / Math.max(1, Math.min(grams.size, 12))) * weight;
292
+ };
293
+ const titleScore = Math.max(hit(record.title, WEIGHT.title), hit((record.aliases || []).join(' '), 2));
294
+ if (titleScore > 0) {
295
+ score += titleScore;
296
+ hints.push('title');
297
+ }
298
+ /** 标签和触发词都是关键词:触发词在查询里出现,就说明这条规则该被想起。 */
299
+ const keywordTexts = [...(record.tags || []), ...(record.triggers || [])];
300
+ const keywordScore = Math.max(0, ...keywordTexts.map((text) => hit(text, WEIGHT.keywords)));
301
+ if (keywordScore > 0) {
302
+ score += keywordScore;
303
+ hints.push('keyword');
304
+ }
305
+ const loweredRaw = raw.toLowerCase();
306
+ const triggerHit = (record.triggers || []).some((trigger) => {
307
+ const text = plain(trigger, 400).toLowerCase();
308
+ return Boolean(text) && loweredRaw.includes(text);
309
+ });
310
+ if (triggerHit) {
311
+ score += WEIGHT.keywords;
312
+ hints.push('trigger');
313
+ }
314
+ for (const word of words) {
315
+ const plainWord = word.toLowerCase();
316
+ const inTitle = plain(record.title, 400).toLowerCase().includes(plainWord)
317
+ || (record.aliases || []).some((name) => plain(name, 400).toLowerCase().includes(plainWord));
318
+ const inKeywords = keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord));
319
+ if (inTitle || inKeywords) {
320
+ score += inKeywords ? WEIGHT.keywords : WEIGHT.title;
321
+ hints.push(inKeywords ? 'keyword' : 'title');
322
+ }
323
+ }
324
+ const summaryScore = hit(record.summary, WEIGHT.summary);
325
+ if (summaryScore > 0) {
326
+ score += summaryScore;
327
+ hints.push('summary');
328
+ }
329
+ const whenScore = hit(record.when, WEIGHT.when);
330
+ if (whenScore > 0) score += whenScore;
331
+ return { score, hints };
332
+ }
333
+
334
+ export const MATCH_FLOOR = 2.4;
335
+
336
+ /**
337
+ * 按需召回排序:只有真命中才进候选,不凑数;同分按最近使用、再按 id。
338
+ * requireApplicable 打开时,条件未知或未解决的条目直接不参与自动召回。
339
+ */
340
+ export function rank(records, query, options = {}) {
341
+ const limit = Number.isInteger(options.limit) ? options.limit : 4;
342
+ const context = options.context || {};
343
+ const rows = [];
344
+ for (const record of records || []) {
345
+ if (!record || record.status === 'archived') continue;
346
+ if (options.requireApplicable && record.reviewState === 'legacy_unreviewed') continue;
347
+ const status = options.requireApplicable ? statusOf(record, context) : 'ok';
348
+ if (options.requireApplicable && status !== 'ok') continue;
349
+ const matched = matchScore(record, query);
350
+ if (matched.score < MATCH_FLOOR) continue;
351
+ rows.push({ record, score: matched.score, hints: matched.hints, status });
352
+ }
353
+ rows.sort((a, b) => b.score - a.score
354
+ || String(b.record.lastUsed || '').localeCompare(String(a.record.lastUsed || ''))
355
+ || String(a.record.id).localeCompare(String(b.record.id)));
356
+ return rows.slice(0, limit);
357
+ }
358
+
359
+ /** 卡片:只放够判断要不要读的那几行,必要前提完整在场,不截断。 */
360
+ export function cardOf(record, scope) {
361
+ const conditions = (record.conditions || []).map((condition) => `${condition.key}=${condition.value}`);
362
+ const card = {
363
+ id: record.id,
364
+ scope: scope === 'global' ? 'global' : 'project',
365
+ revision: record.revision || 1,
366
+ title: record.title || '',
367
+ summary: record.summary || '',
368
+ };
369
+ if (conditions.length) card.conditions = conditions;
370
+ if (record.selection && record.selection.mode === 'complete') card.selection = record.selection.artifactKey;
371
+ const related = linksOf(record).length;
372
+ if (related > 0) card.related = related;
373
+ return card;
374
+ }
375
+
376
+ export function linksOf(record) {
377
+ return Array.isArray(record && record.links) ? record.links : [];
378
+ }
379
+
380
+ export function bytesOf(value) {
381
+ return Buffer.byteLength(JSON.stringify(value));
382
+ }
383
+
384
+ /** 整卡装预算:单卡超预算就省略,装不下就停,不为了凑满 3 张截短任何一张。 */
385
+ export function selectCards(records, options = {}) {
386
+ const max = Number.isInteger(options.max) ? options.max : CARD.max;
387
+ const bytes = Number.isInteger(options.bytes) ? options.bytes : CARD.bytes;
388
+ const scopeOf = typeof options.scopeOf === 'function' ? options.scopeOf : () => '';
389
+ const cards = [];
390
+ const omitted = [];
391
+ let used = 0;
392
+ for (const record of records) {
393
+ if (cards.length >= max) {
394
+ omitted.push(record.id);
395
+ continue;
396
+ }
397
+ const card = cardOf(record, scopeOf(record));
398
+ const size = bytesOf(card);
399
+ if (used + size > bytes) {
400
+ omitted.push(record.id);
401
+ continue;
402
+ }
403
+ used += size;
404
+ cards.push(card);
405
+ }
406
+ return { cards, omitted, bytes: used };
407
+ }
408
+
409
+ function pick(record, keys) {
410
+ const out = {};
411
+ for (const key of keys) if (record[key] !== undefined && record[key] !== null && record[key] !== '') out[key] = record[key];
412
+ return out;
413
+ }
414
+
415
+ /**
416
+ * 模型投影:按用途挑当前字段,维护区一律不出现。
417
+ * save / read / search / suggest / forget / links 各自一份白名单,新增字段必须显式加。
418
+ */
419
+ export function toModelView(record, purpose, extra = {}) {
420
+ if (!record) return null;
421
+ const scope = extra.scope || record.scope || (record.storeScope === 'global' ? 'global' : 'project');
422
+ if (purpose === 'search') {
423
+ const view = pick(record, ['id', 'title', 'summary', 'when', 'revision']);
424
+ view.scope = scope;
425
+ if (record.origin === 'project_evolution') view.origin = 'project_evolution';
426
+ if (extra.status && extra.status !== 'ok') view.state = extra.status;
427
+ return view;
428
+ }
429
+ if (purpose === 'read') {
430
+ const view = pick(record, ['id', 'title', 'summary', 'when', 'kind', 'revision', 'body']);
431
+ view.scope = scope;
432
+ if (record.origin === 'project_evolution') view.origin = 'project_evolution';
433
+ if (Array.isArray(record.conditions) && record.conditions.length) view.conditions = record.conditions;
434
+ if (record.selection && record.selection.mode === 'complete') view.selection = record.selection.artifactKey;
435
+ const related = linksOf(record).length;
436
+ if (related > 0) view.related = related;
437
+ if (extra.status && extra.status !== 'ok') {
438
+ view.state = extra.status;
439
+ if (extra.missing && extra.missing.length) view.missing = extra.missing;
440
+ }
441
+ return view;
442
+ }
443
+ if (purpose === 'save') {
444
+ const view = pick(record, ['id', 'title', 'summary', 'when', 'revision', 'topicKey']);
445
+ view.scope = scope;
446
+ if (record.applicability && record.applicability !== 'all_in_scope') view.applicability = record.applicability;
447
+ return view;
448
+ }
449
+ if (purpose === 'suggest') {
450
+ return pick(record, ['id', 'title', 'summary', 'targetId', 'targetTitle', 'expiresAt']);
451
+ }
452
+ if (purpose === 'forget') {
453
+ const view = pick(record, ['id', 'title']);
454
+ view.scope = scope;
455
+ return view;
456
+ }
457
+ if (purpose === 'links') {
458
+ const view = pick(record, ['id', 'title', 'revision']);
459
+ view.scope = scope;
460
+ view.links = (extra.links || []).map((link) => {
461
+ const row = pick(link, ['id', 'title', 'revision']);
462
+ row.status = link.status || 'ok';
463
+ return row;
464
+ });
465
+ return view;
466
+ }
467
+ if (purpose === 'hint') {
468
+ const card = cardOf(record, scope);
469
+ return card;
470
+ }
471
+ return null;
472
+ }
473
+
474
+ /** 错误出口也只带状态与一句原因:不泄露路径、维护字段或正文。 */
475
+ export function errorView(code, message) {
476
+ return {
477
+ error: {
478
+ code: plain(code, 40) || 'failed',
479
+ message: plain(message, 120) || '操作没有完成',
480
+ },
481
+ };
482
+ }
483
+
484
+ /** 宿主合成的「用户消息」不是用户说的话:提示查询只认 source.kind === 'user'。 */
485
+ const SYNTHETIC_PREFIXES = [
486
+ 'Current runtime context',
487
+ 'This is an automatically generated checkpoint',
488
+ 'Fast mode reminder',
489
+ '<system-reminder>',
490
+ '<goal_round>',
491
+ 'Auto schedule:',
492
+ 'Time sampled while preparing',
493
+ 'background job ',
494
+ ];
495
+
496
+ export function textOfMessage(message) {
497
+ const content = message?.content;
498
+ if (typeof content === 'string') return content.trim();
499
+ if (!Array.isArray(content)) return '';
500
+ let text = '';
501
+ for (const part of content) {
502
+ if (!part || typeof part !== 'object') continue;
503
+ if (part.type === 'tool-result' || part.type === 'tool-call') return '';
504
+ if (part.type !== 'text' || typeof part.text !== 'string') continue;
505
+ text += `${part.text}\n`;
506
+ }
507
+ return text.trim();
508
+ }
509
+
510
+ /** 最后一句真人说的话;goal 轮次、压缩摘要、时间快照、后台任务回执都不算。 */
511
+ export function newestUserQuery(messages) {
512
+ if (!Array.isArray(messages)) return '';
513
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
514
+ const message = messages[i];
515
+ if (!message || message.role !== 'user') continue;
516
+ const kind = message?.source?.kind;
517
+ if (kind && kind !== 'user') continue;
518
+ const text = textOfMessage(message);
519
+ if (!text) continue;
520
+ if (SYNTHETIC_PREFIXES.some((prefix) => text.startsWith(prefix))) continue;
521
+ return text;
522
+ }
523
+ return '';
524
+ }
525
+
526
+ export function projectionText(view) {
527
+ if (!view) return '';
528
+ const rows = [];
529
+ rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}`);
530
+ if (view.conditions && view.conditions.length) rows.push(`条件:${view.conditions.map((item) => `${item.key}=${item.value}`).join(',')}`);
531
+ if (view.selection) rows.push(`仅对 ${view.selection} 生效`);
532
+ if (view.state === 'needs_context') rows.push('适用范围待确认');
533
+ if (view.state === 'not_applicable') rows.push('当前条件不适用');
534
+ return rows.join('\n');
535
+ }
536
+
537
+ /** 槽位 = 显式 topicKey;没有就退回条目自身身份,这样每条独立记录仍是自己的槽。 */
538
+ export function topicSlot(record) {
539
+ if (!record) return '';
540
+ const key = String(record.topicKey || '').trim();
541
+ return key || String(record.id || '');
542
+ }
543
+
544
+ /** 适用条件归一成可比对字符串:顺序不同不算不同条件。 */
545
+ export function conditionsKey(conditions) {
546
+ const list = Array.isArray(conditions) ? conditions : [];
547
+ return JSON.stringify([...list].map((item) => [String(item.key), String(item.value)]).sort((a, b) => a[0].localeCompare(b[0])));
548
+ }
549
+
550
+ /** 两条规则是否算同一条:正文、selection、关联和目标也不同就是冲突,不能悄悄挑一条。 */
551
+ function ruleFingerprint(record) {
552
+ const selection = record.selection || null;
553
+ return JSON.stringify([
554
+ record.title || '',
555
+ record.summary || '',
556
+ record.when || '',
557
+ record.topicKey || '',
558
+ conditionsKey(record.conditions),
559
+ record.applicability || '',
560
+ record.kind || '',
561
+ Array.isArray(record.tags) ? [...record.tags].map(String).sort() : [],
562
+ Array.isArray(record.triggers) ? [...record.triggers].map(String).sort() : [],
563
+ selection ? [selection.mode || '', selection.artifactKey || '', Array.isArray(selection.items) ? [...selection.items].map(String).sort() : []] : null,
564
+ Array.isArray(record.links) ? record.links.map((link) => [String(link.type || 'related'), String(link.id)]).sort((a, b) => a[1].localeCompare(b[1])) : [],
565
+ record.body || '',
566
+ ]);
567
+ }
568
+
569
+ function classify(records, context) {
570
+ const matches = [];
571
+ const unknown = [];
572
+ for (const record of records || []) {
573
+ if (!record || record.status === 'archived') continue;
574
+ const status = statusOf(record, context);
575
+ if (status === 'ok') matches.push(record);
576
+ else if (status === 'needs_context') unknown.push(record);
577
+ }
578
+ if (matches.length > 1) {
579
+ const fingerprints = new Set(matches.map(ruleFingerprint));
580
+ if (fingerprints.size > 1) return { status: 'conflict', records: matches, record: null };
581
+ return { status: 'ok', records: matches, record: matches[0] };
582
+ }
583
+ if (matches.length === 1) return { status: 'ok', records: matches, record: matches[0] };
584
+ if (unknown.length) return { status: 'needs_context', records: unknown, record: null };
585
+ return { status: 'none', records: [], record: null };
586
+ }
587
+
588
+ /**
589
+ * 同一个槽位只留一条现行规范:项目层命中就压过全局层;不命中才回落到全局;
590
+ * 项目层条件未知时整组暂缓,不拿全局顶上,也不猜。
591
+ */
592
+ export function resolveTopics(projectRecords, globalRecords, context = {}) {
593
+ const groups = new Map();
594
+ const groupOf = (record) => {
595
+ const key = topicSlot(record);
596
+ if (!groups.has(key)) groups.set(key, { key, project: [], global: [] });
597
+ return groups.get(key);
598
+ };
599
+ for (const record of projectRecords || []) groupOf(record).project.push(record);
600
+ for (const record of globalRecords || []) groupOf(record).global.push(record);
601
+ const out = new Map();
602
+ for (const group of groups.values()) {
603
+ const project = classify(group.project, context);
604
+ const global = classify(group.global, context);
605
+ let resolved;
606
+ if (project.status === 'conflict') resolved = { status: 'conflict', scope: 'project', record: null, records: project.records };
607
+ else if (project.status === 'needs_context') resolved = { status: 'needs_context', scope: 'project', record: null, records: project.records };
608
+ else if (project.status === 'ok') resolved = { status: 'ok', scope: 'project', record: project.record, records: [project.record] };
609
+ else if (global.status === 'conflict') resolved = { status: 'conflict', scope: 'global', record: null, records: global.records };
610
+ else if (global.status === 'needs_context') resolved = { status: 'needs_context', scope: 'global', record: null, records: global.records };
611
+ else if (global.status === 'ok') resolved = { status: 'ok', scope: 'global', record: global.record, records: [global.record] };
612
+ else resolved = { status: 'none', scope: '', record: null, records: [] };
613
+ out.set(group.key, { key: group.key, project: group.project, global: group.global, ...resolved });
614
+ }
615
+ return out;
616
+ }