dsh-lite-memory 0.18.19 → 0.18.26
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.
- package/TRIGGERS.md +1 -1
- package/client.js +136 -28
- package/docs/CONVENTIONS.md +10 -1
- package/docs/RELEASE-0.18.16.md +6 -0
- package/docs/RELEASE-0.18.17.md +6 -0
- package/docs/RELEASE-0.18.18.md +7 -0
- package/docs/RELEASE-0.18.19.md +7 -0
- package/docs/RELEASE-0.18.20.md +4 -0
- package/docs/RELEASE-0.18.21.md +4 -0
- package/docs/RELEASE-0.18.22.md +5 -0
- package/docs/RELEASE-0.18.23.md +5 -0
- package/docs/RELEASE-0.18.24.md +3 -0
- package/docs/RELEASE-0.18.25.md +5 -0
- package/docs/RELEASE-0.18.26.md +5 -0
- package/index.js +168 -35
- package/learning.js +3 -2
- package/package.json +1 -1
- package/state.js +11 -6
- package/store.js +84 -13
- package/test/client.test.mjs +25 -1
- package/test/contract.test.mjs +130 -1
- package/test/control.test.mjs +22 -0
- package/test/evolution-read.test.mjs +2 -0
- package/test/learning.test.mjs +3 -2
- package/test/lexical.test.mjs +22 -0
- package/test/recall.test.mjs +53 -0
- package/test/state.test.mjs +8 -5
- package/test/store.test.mjs +50 -3
- package/view.js +129 -16
package/test/state.test.mjs
CHANGED
|
@@ -23,6 +23,11 @@ const test = (name, fn) => {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const dirs = [];
|
|
26
|
+
/** Windows 会把同一个临时目录报成 8.3 短名或长名;按目录的文件 ID 判断是不是同一处。 */
|
|
27
|
+
const pathId = (target) => {
|
|
28
|
+
const stat = fs.statSync(target, { bigint: true });
|
|
29
|
+
return `${stat.dev}:${stat.ino}`;
|
|
30
|
+
};
|
|
26
31
|
const fresh = (label) => {
|
|
27
32
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lm-state-${label}-`));
|
|
28
33
|
dirs.push(dir);
|
|
@@ -218,9 +223,7 @@ test('S01 仓库根与子目录共用一份记忆', () => {
|
|
|
218
223
|
const atSub = scopeOf(sub);
|
|
219
224
|
assert.equal(atSub.key, atRoot.key);
|
|
220
225
|
assert.equal(atSub.dir, atRoot.dir);
|
|
221
|
-
|
|
222
|
-
name on the other; compare what both of them resolve to. */
|
|
223
|
-
assert.equal(fs.realpathSync(resolveRoot(sub).root), fs.realpathSync(repo));
|
|
226
|
+
assert.equal(pathId(resolveRoot(sub).root), pathId(repo));
|
|
224
227
|
});
|
|
225
228
|
|
|
226
229
|
test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
|
|
@@ -251,7 +254,7 @@ test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () =
|
|
|
251
254
|
fs.mkdirSync(path.dirname(config), { recursive: true });
|
|
252
255
|
fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
|
|
253
256
|
try {
|
|
254
|
-
assert.equal(resolveRoot(sub).root,
|
|
257
|
+
assert.equal(pathId(resolveRoot(sub).root), pathId(root));
|
|
255
258
|
assert.equal(resolveRoot(sub).source, 'configured');
|
|
256
259
|
assert.equal(scopeOf(sub).key, scopeOf(root).key);
|
|
257
260
|
assert.notEqual(resolveRoot(outside).source, 'configured');
|
|
@@ -327,7 +330,7 @@ test('S10 旧库迁移:正文、归档、旧标题一起搬进状态', () => {
|
|
|
327
330
|
const loaded = load(scope).state;
|
|
328
331
|
assert.equal(loaded.entries.length, 4);
|
|
329
332
|
assert.equal(loaded.migration.fromVersion, 1);
|
|
330
|
-
assert.equal(bodyOf(scope, 'mem_111111111111'), '第一步跑测试\n第二步改版本号');
|
|
333
|
+
assert.equal(bodyOf(scope, 'mem_111111111111').replace(/\r\n/g, '\n'), '第一步跑测试\n第二步改版本号');
|
|
331
334
|
const archived = loaded.entries.find((entry) => entry.id === 'mem_444444444444');
|
|
332
335
|
assert.equal(archived.status, 'archived');
|
|
333
336
|
assert.equal(archived.current.body, '归档正文');
|
package/test/store.test.mjs
CHANGED
|
@@ -142,6 +142,26 @@ test('same title with a different when is a second entry', () => {
|
|
|
142
142
|
assert.equal(again.record.id, b.record.id);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test('expired or superseded records do not block a new same-title save', () => {
|
|
146
|
+
const scope = fresh('stale-merge');
|
|
147
|
+
const first = upsert(scope, { title: '旧结论', summary: '这条已经过期', topicKey: 'old-topic', body: '旧' });
|
|
148
|
+
const expired = loadIndex(scope);
|
|
149
|
+
expired.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
|
|
150
|
+
saveIndex(scope, expired);
|
|
151
|
+
const next = upsert(scope, { title: '旧结论', summary: '新结论应该另存', topicKey: 'old-topic', body: '新' });
|
|
152
|
+
assert.equal(next.needsResolution, false);
|
|
153
|
+
assert.equal(next.merged, false);
|
|
154
|
+
assert.notEqual(next.record.id, first.record.id);
|
|
155
|
+
|
|
156
|
+
const second = upsert(scope, { title: '被取代的结论', summary: '已经被新版本取代', body: '旧' });
|
|
157
|
+
const superseded = loadIndex(scope);
|
|
158
|
+
superseded.entries.find((row) => row.id === second.record.id).supersededBy = 'later';
|
|
159
|
+
saveIndex(scope, superseded);
|
|
160
|
+
const replacement = upsert(scope, { title: '被取代的结论', summary: '沿同名标题重写', body: '新' });
|
|
161
|
+
assert.equal(replacement.needsResolution, false);
|
|
162
|
+
assert.notEqual(replacement.record.id, second.record.id);
|
|
163
|
+
});
|
|
164
|
+
|
|
145
165
|
test('oversized body is rejected, old data untouched', () => {
|
|
146
166
|
const scope = fresh('oversize');
|
|
147
167
|
const big = 'x'.repeat(LIMITS.entryBytes + 1);
|
|
@@ -1069,6 +1089,24 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
|
|
|
1069
1089
|
assert.equal(evidencePath.chosenScope, 'project', '依据本身是本机文件不算普适证据');
|
|
1070
1090
|
assert.equal(evidencePath.reasonCode, 'machine_bound_procedure');
|
|
1071
1091
|
|
|
1092
|
+
const scopedProcedure = store.routeScope({
|
|
1093
|
+
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
|
1094
|
+
body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
|
|
1095
|
+
memoryClass: 'portable_procedure',
|
|
1096
|
+
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1097
|
+
}, { project });
|
|
1098
|
+
assert.equal(scopedProcedure.chosenScope, 'global', '声明了机器条件的本机流程可进全局层');
|
|
1099
|
+
assert.equal(scopedProcedure.reasonCode, 'machine_scoped_procedure');
|
|
1100
|
+
|
|
1101
|
+
const scopedExplicit = store.routeScope({
|
|
1102
|
+
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
|
1103
|
+
body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
|
|
1104
|
+
memoryClass: 'portable_procedure', scope: 'global',
|
|
1105
|
+
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1106
|
+
}, { project });
|
|
1107
|
+
assert.equal(scopedExplicit.chosenScope, 'global');
|
|
1108
|
+
assert.equal(scopedExplicit.reasonCode, 'explicit');
|
|
1109
|
+
|
|
1072
1110
|
const unconfirmed = store.routeScope({
|
|
1073
1111
|
title: '部署脚本', summary: '本机部署脚本', body: 'bash /tmp/deploy.sh',
|
|
1074
1112
|
memoryClass: 'portable_procedure', scope: 'global',
|
|
@@ -1091,12 +1129,21 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
|
|
|
1091
1129
|
assert.equal(modelGlobalFact.chosenScope, 'project', '模型自填 global 不能把项目事实送进全局层');
|
|
1092
1130
|
assert.equal(modelGlobalFact.reasonCode, 'model_global_project_fact');
|
|
1093
1131
|
|
|
1094
|
-
|
|
1132
|
+
/** 显式 global 是用户要的那一层:没 evidenceRef 也只代表模型没补依据,不能把它降回项目层。 */
|
|
1133
|
+
const userGlobalProcedure = store.routeScope({
|
|
1095
1134
|
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1096
1135
|
memoryClass: 'portable_procedure', scope: 'global',
|
|
1097
1136
|
}, { project });
|
|
1098
|
-
assert.equal(
|
|
1099
|
-
assert.equal(
|
|
1137
|
+
assert.equal(userGlobalProcedure.chosenScope, 'global');
|
|
1138
|
+
assert.equal(userGlobalProcedure.reasonCode, 'explicit');
|
|
1139
|
+
|
|
1140
|
+
/** 没显式 scope 时,memoryClass 只是提案:缺可搬依据的流程仍留在项目层。 */
|
|
1141
|
+
const classOnlyProcedure = store.routeScope({
|
|
1142
|
+
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1143
|
+
memoryClass: 'portable_procedure',
|
|
1144
|
+
}, { project });
|
|
1145
|
+
assert.equal(classOnlyProcedure.chosenScope, 'project', '没有显式 scope 又缺依据,不能自己搬去全局层');
|
|
1146
|
+
assert.equal(classOnlyProcedure.reasonCode, 'unbound_evidence');
|
|
1100
1147
|
});
|
|
1101
1148
|
|
|
1102
1149
|
test('几百条归档的正文不进热状态,下一次保存照样能过', () => {
|
package/view.js
CHANGED
|
@@ -309,17 +309,37 @@ function wordsOf(text) {
|
|
|
309
309
|
} catch {
|
|
310
310
|
for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) if (word && !STOPWORDS.has(word)) out.push(word);
|
|
311
311
|
}
|
|
312
|
-
/** 本机 ICU
|
|
313
|
-
if (
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
312
|
+
/** 本机 ICU 缺中文词典时中文会被切成单字;只在出现单字时才补中文二元组,别让二元组把查询的实词数量撑大。 */
|
|
313
|
+
if (out.some((word) => [...word].length === 1 && /[\u4e00-\u9fff]/.test(word))) {
|
|
314
|
+
for (const run of plainText.split(/[^\u4e00-\u9fff]+/)) {
|
|
315
|
+
for (let i = 0; i + 1 < run.length; i += 1) {
|
|
316
|
+
const gram = run.slice(i, i + 2);
|
|
317
|
+
if (!STOPWORDS.has(gram)) out.push(gram);
|
|
318
|
+
}
|
|
318
319
|
}
|
|
319
320
|
}
|
|
320
321
|
return out.slice(0, 48);
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/** 只取切词给出的多字实词:本机 ICU 切成单字时返回空,说明这句子没有可靠词边界。 */
|
|
325
|
+
function contentWordsOf(text) {
|
|
326
|
+
const plainText = plain(text, 400).toLowerCase();
|
|
327
|
+
const out = [];
|
|
328
|
+
try {
|
|
329
|
+
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
|
|
330
|
+
for (const part of segmenter.segment(plainText)) {
|
|
331
|
+
const word = part.segment.trim();
|
|
332
|
+
if ([...word].length < 2 || !/[\p{L}\p{N}]/u.test(word)) continue;
|
|
333
|
+
if (!STOPWORDS.has(word)) out.push(word);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) {
|
|
337
|
+
if ([...word].length >= 2 && !STOPWORDS.has(word)) out.push(word);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
323
343
|
/** 纯英文查询按整词判断:git 不该命中 digital。自动召回和手动搜索共用这一条。 */
|
|
324
344
|
export const asciiWordsOf = (text) => plain(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
|
|
325
345
|
export const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
|
|
@@ -357,14 +377,22 @@ export function matchScore(record, query, corpus = null) {
|
|
|
357
377
|
return { score: 1000, hints: ['id'] };
|
|
358
378
|
}
|
|
359
379
|
const grams = gramsOf(raw);
|
|
360
|
-
|
|
380
|
+
/** 本机切词会同时给出整词和补的二元组,去重后同一个词才只算一次。 */
|
|
381
|
+
const words = [...new Set(wordsOf(raw))];
|
|
361
382
|
const hints = [];
|
|
362
383
|
let score = 0;
|
|
384
|
+
/** 英文按整词比对:纯英文二元组只在该词整体出现时才计数,中英混排也一样。 */
|
|
385
|
+
const ascii = asciiWordsOf(raw);
|
|
386
|
+
const englishIn = (text) => ascii.some((word) => wordIn(plain(text, 400).toLowerCase(), word));
|
|
363
387
|
const hit = (text, weight) => {
|
|
364
388
|
if (!text) return 0;
|
|
365
389
|
const pool = gramsOf(text);
|
|
390
|
+
const wholeEnglish = englishIn(text);
|
|
366
391
|
let shared = 0;
|
|
367
|
-
for (const gram of grams)
|
|
392
|
+
for (const gram of grams) {
|
|
393
|
+
if (/^[a-z0-9]+$/.test(gram) && !wholeEnglish) continue;
|
|
394
|
+
if (pool.has(gram)) shared += 1;
|
|
395
|
+
}
|
|
368
396
|
if (shared === 0) return 0;
|
|
369
397
|
return (shared / Math.max(1, Math.min(grams.size, 12))) * weight;
|
|
370
398
|
};
|
|
@@ -402,21 +430,38 @@ export function matchScore(record, query, corpus = null) {
|
|
|
402
430
|
const df = corpus.df.get(text) || 0;
|
|
403
431
|
return df > Math.max(3, corpus.total * 0.15);
|
|
404
432
|
};
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
433
|
+
/**
|
|
434
|
+
* 查询里有两个以上实词时,只沾上一个不算在说同一件事。
|
|
435
|
+
* 「启动服务器」只碰到「启动失败」里的「启动」,不能把改包名的记忆带进自动提示。
|
|
436
|
+
* 单实词查询(「发版」「样式」)不设这个门槛。
|
|
437
|
+
*/
|
|
438
|
+
const distinctContent = [...new Set(contentWordsOf(raw).filter((word) => /[\u4e00-\u9fff]/.test(word) && !common(word)))];
|
|
439
|
+
const keywordWords = new Set();
|
|
408
440
|
const titleWords = new Set();
|
|
409
441
|
for (const word of words) {
|
|
410
442
|
if ([...word].length < 2) continue;
|
|
411
443
|
const plainWord = word.toLowerCase();
|
|
412
444
|
if (common(plainWord)) continue;
|
|
413
|
-
if (keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord))) {
|
|
445
|
+
if (keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord))) {
|
|
446
|
+
keywordWords.add(plainWord);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
414
449
|
if (plain(record.title, 400).toLowerCase().includes(plainWord)
|
|
415
450
|
|| (record.aliases || []).some((name) => plain(name, 400).toLowerCase().includes(plainWord))) {
|
|
416
|
-
titleWordHit = true;
|
|
417
451
|
titleWords.add(plainWord);
|
|
418
452
|
}
|
|
419
453
|
}
|
|
454
|
+
const wholeText = [record.title, (record.aliases || []).join(' '), (record.tags || []).join(' '),
|
|
455
|
+
(record.triggers || []).join(' '), record.summary, record.when, record.body]
|
|
456
|
+
.map((text) => plain(text, 400).toLowerCase()).filter(Boolean).join('\n');
|
|
457
|
+
const matchedContent = distinctContent.filter((word) => wholeText.includes(word)).length;
|
|
458
|
+
const enoughContent = distinctContent.length < 2 || matchedContent >= 2;
|
|
459
|
+
if (!enoughContent) {
|
|
460
|
+
keywordWords.clear();
|
|
461
|
+
titleWords.clear();
|
|
462
|
+
}
|
|
463
|
+
const titleWordHit = titleWords.size > 0;
|
|
464
|
+
const keywordWordHit = keywordWords.size > 0;
|
|
420
465
|
if (!keywordWordHit && titleWordHit) { score += WEIGHT.title; hints.push('title'); }
|
|
421
466
|
if (keywordWordHit) { score += WEIGHT.keywords; hints.push('keyword'); }
|
|
422
467
|
if (titleWords.size >= 2) { score += WEIGHT.coverage; hints.push('coverage'); }
|
|
@@ -437,7 +482,6 @@ export function matchScore(record, query, corpus = null) {
|
|
|
437
482
|
const whenScore = hit(record.when, WEIGHT.when);
|
|
438
483
|
if (whenScore > 0) score += whenScore;
|
|
439
484
|
/** 纯英文查询必须整词命中,别让语料里的字面巧合把不相关的记忆带进自动提示。 */
|
|
440
|
-
const ascii = asciiWordsOf(raw);
|
|
441
485
|
if (ascii.length && !/[\u4e00-\u9fff]/.test(raw)) {
|
|
442
486
|
const pool = [record.title, (record.aliases || []).join(' '), ...(record.tags || []), ...(record.triggers || []), record.summary, record.when]
|
|
443
487
|
.filter(Boolean).join('\n').toLowerCase();
|
|
@@ -563,14 +607,14 @@ export function toModelView(record, purpose, extra = {}) {
|
|
|
563
607
|
if (purpose === 'search') {
|
|
564
608
|
const view = pick(record, ['id', 'title', 'summary', 'when', 'revision']);
|
|
565
609
|
view.scope = scope;
|
|
566
|
-
if (record.origin === 'project_evolution') view.origin =
|
|
610
|
+
if (record.origin === 'project_evolution' || record.origin === 'model_drafted') view.origin = record.origin;
|
|
567
611
|
if (extra.status && extra.status !== 'ok') view.state = extra.status;
|
|
568
612
|
return view;
|
|
569
613
|
}
|
|
570
614
|
if (purpose === 'read') {
|
|
571
615
|
const view = pick(record, ['id', 'title', 'summary', 'when', 'kind', 'revision', 'body']);
|
|
572
616
|
view.scope = scope;
|
|
573
|
-
if (record.origin === 'project_evolution') view.origin =
|
|
617
|
+
if (record.origin === 'project_evolution' || record.origin === 'model_drafted') view.origin = record.origin;
|
|
574
618
|
if (Array.isArray(record.conditions) && record.conditions.length) view.conditions = record.conditions;
|
|
575
619
|
if (record.selection && record.selection.mode === 'complete') view.selection = record.selection.artifactKey;
|
|
576
620
|
const related = linksOf(record).length;
|
|
@@ -649,6 +693,75 @@ export function textOfMessage(message) {
|
|
|
649
693
|
}
|
|
650
694
|
|
|
651
695
|
/** 最后一句真人说的话;goal 轮次、压缩摘要、时间快照、后台任务回执都不算。 */
|
|
696
|
+
/** 续说一句(「继续」「然后呢」)本身没有检索线索:沿用上一轮原话,别让自动提醒在这一轮断掉。 */
|
|
697
|
+
export function isContinuationQuery(text) {
|
|
698
|
+
const raw = plain(text, 40).trim().toLowerCase().replace(/[\s\u3002\uff01\uff1f?!\uff0c,\u3001\u2026~\uff5e]+/gu, '');
|
|
699
|
+
if (!raw || [...raw].length > 8) return false;
|
|
700
|
+
return /^(继续|接着|然后|然后呢|还有|还有呢|往下|说下去|goon|continue|next|more)$/.test(raw);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* 动作侧检索词:用户这句话没提,但「刚才在动什么」往往就写在上一轮助手的话和工具名里。
|
|
705
|
+
* 预防型记忆(先重载再重启、改插件后别用 Cmd+R)约束的是下一步动作,
|
|
706
|
+
* 只拿用户原话查,词面永远在错的一侧。这里只取少量最近的实词和工具名,避免长文稀释命中。
|
|
707
|
+
*/
|
|
708
|
+
const ACTION_NOISE = new Set(['const', 'function', 'return', 'import', 'export', 'from', 'async', 'await', 'true', 'false', 'null', 'undefined', 'string', 'number', 'object']);
|
|
709
|
+
export function actionTerms(messages, options = {}) {
|
|
710
|
+
if (!Array.isArray(messages)) return '';
|
|
711
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
|
|
712
|
+
const terms = [];
|
|
713
|
+
const seen = new Set();
|
|
714
|
+
const push = (word) => {
|
|
715
|
+
const text = String(word || '').trim().toLowerCase();
|
|
716
|
+
if ([...text].length < 2 || ACTION_NOISE.has(text) || seen.has(text)) return;
|
|
717
|
+
seen.add(text);
|
|
718
|
+
terms.push(text);
|
|
719
|
+
};
|
|
720
|
+
const scan = (message) => {
|
|
721
|
+
const content = message?.content;
|
|
722
|
+
if (!Array.isArray(content)) return '';
|
|
723
|
+
let text = '';
|
|
724
|
+
for (const part of content) {
|
|
725
|
+
if (!part || typeof part !== 'object') continue;
|
|
726
|
+
if (part.type === 'tool-call' || part.type === 'tool_call') {
|
|
727
|
+
if (typeof part.name === 'string') push(part.name);
|
|
728
|
+
const input = part.input || part.arguments || part.args;
|
|
729
|
+
if (input && typeof input === 'object') {
|
|
730
|
+
for (const value of Object.values(input)) {
|
|
731
|
+
if (typeof value === 'string' && /[./_-]/.test(value)) {
|
|
732
|
+
for (const piece of value.split(/[^\p{L}\p{N}_.-]+/u)) push(piece);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (part.type === 'text' && typeof part.text === 'string') text += `${part.text}\n`;
|
|
739
|
+
}
|
|
740
|
+
return text;
|
|
741
|
+
};
|
|
742
|
+
let assistants = 0;
|
|
743
|
+
let previousHuman = false;
|
|
744
|
+
for (let i = messages.length - 1; i >= 0 && terms.length < limit; i -= 1) {
|
|
745
|
+
const message = messages[i];
|
|
746
|
+
if (!message) continue;
|
|
747
|
+
if (message.role === 'assistant') {
|
|
748
|
+
const text = scan(message);
|
|
749
|
+
if (!text) continue;
|
|
750
|
+
assistants += 1;
|
|
751
|
+
for (const word of contentWordsOf(text)) push(word);
|
|
752
|
+
for (const word of asciiWordsOf(text)) push(word);
|
|
753
|
+
if (assistants >= 2) break;
|
|
754
|
+
} else if (message.role === 'user' && !previousHuman && message?.source?.kind !== 'goal') {
|
|
755
|
+
const text = scan(message);
|
|
756
|
+
if (!text) continue;
|
|
757
|
+
previousHuman = true;
|
|
758
|
+
for (const word of contentWordsOf(text)) push(word);
|
|
759
|
+
for (const word of asciiWordsOf(text)) push(word);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return terms.slice(0, limit).join(' ');
|
|
763
|
+
}
|
|
764
|
+
|
|
652
765
|
export function newestUserQuery(messages) {
|
|
653
766
|
if (!Array.isArray(messages)) return '';
|
|
654
767
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|