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/store.js
CHANGED
|
@@ -41,6 +41,13 @@ const LEGACY_SUMMARY_TAIL = '摘要原文:';
|
|
|
41
41
|
* 老 ID、病史查询和关联跳转都不能把正文带出来。管理面显式传 allowExperimental。
|
|
42
42
|
*/
|
|
43
43
|
export const EXPERIMENTAL_ORIGIN = 'project_evolution';
|
|
44
|
+
/** 谁写下当前这版正文:只有这几种,别把猜测当事实。 */
|
|
45
|
+
export const ORIGINS = ['user_explicit', 'model_drafted', EXPERIMENTAL_ORIGIN];
|
|
46
|
+
|
|
47
|
+
function originOf(source) {
|
|
48
|
+
const value = source && (source.origin || source.source);
|
|
49
|
+
return ORIGINS.includes(value) ? value : 'user_explicit';
|
|
50
|
+
}
|
|
44
51
|
|
|
45
52
|
export function isExperimentalRecord(record) {
|
|
46
53
|
if (!record) return false;
|
|
@@ -110,7 +117,7 @@ function toFlat(entry) {
|
|
|
110
117
|
lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
|
|
111
118
|
revision: Number(entry.revision) || 1,
|
|
112
119
|
status: entry.status === 'archived' ? 'archived' : 'active',
|
|
113
|
-
origin: entry.origin
|
|
120
|
+
origin: ORIGINS.includes(entry.origin) ? entry.origin : 'user_explicit',
|
|
114
121
|
ownerProjectId: typeof entry.ownerProjectId === 'string' ? entry.ownerProjectId : '',
|
|
115
122
|
hits: Number(maintenance.hits) || 0,
|
|
116
123
|
merges: Number(maintenance.merges) || 0,
|
|
@@ -130,7 +137,7 @@ function fromFlat(record, status) {
|
|
|
130
137
|
id: record.id,
|
|
131
138
|
revision: Number(record.revision) || 1,
|
|
132
139
|
status: status || (record.status === 'archived' ? 'archived' : 'active'),
|
|
133
|
-
origin:
|
|
140
|
+
origin: ORIGINS.includes(record.origin) ? record.origin : 'user_explicit',
|
|
134
141
|
ownerProjectId: record.ownerProjectId || '',
|
|
135
142
|
createdAt: record.created || at,
|
|
136
143
|
updatedAt: at,
|
|
@@ -1113,7 +1120,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1113
1120
|
lastUsed: now,
|
|
1114
1121
|
merges: (record.merges || 0) + 1,
|
|
1115
1122
|
status: 'active',
|
|
1116
|
-
|
|
1123
|
+
origin: originOf(source) === 'user_explicit' ? (ORIGINS.includes(record.origin) ? record.origin : 'user_explicit') : originOf(source),
|
|
1117
1124
|
};
|
|
1118
1125
|
nextRecord.maintenance = { ...(record.maintenance || {}) };
|
|
1119
1126
|
if (summaryFull) nextRecord.maintenance.summaryFull = summaryFull;
|
|
@@ -1165,7 +1172,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1165
1172
|
lastUsed: now,
|
|
1166
1173
|
revision: 1,
|
|
1167
1174
|
status: 'active',
|
|
1168
|
-
|
|
1175
|
+
origin: originOf(source),
|
|
1169
1176
|
hits: 0,
|
|
1170
1177
|
merges: 0,
|
|
1171
1178
|
versions: [],
|
|
@@ -1315,7 +1322,7 @@ function scoreQuery(item, grams, raw) {
|
|
|
1315
1322
|
/** 项目记忆总开关:读当前层,缺字段按开启。 */
|
|
1316
1323
|
export function controlStatus(scope) {
|
|
1317
1324
|
const index = loadIndex(scope);
|
|
1318
|
-
return {
|
|
1325
|
+
return index.__control ? state.controlOf({ control: index.__control }) : state.controlOf(null);
|
|
1319
1326
|
}
|
|
1320
1327
|
|
|
1321
1328
|
export function setMemoryEnabled(scope, enabled, options = {}) {
|
|
@@ -1340,6 +1347,53 @@ export function setMemoryEnabled(scope, enabled, options = {}) {
|
|
|
1340
1347
|
});
|
|
1341
1348
|
}
|
|
1342
1349
|
|
|
1350
|
+
/** 面板一次改开关和触发方式:同一把锁里落盘,不会只改一半。 */
|
|
1351
|
+
export function setMemoryControl(scope, options = {}, meta = {}) {
|
|
1352
|
+
return withLock(scope, () => {
|
|
1353
|
+
const before = loadIndex(scope);
|
|
1354
|
+
const operationId = typeof meta.operationId === 'string' ? meta.operationId : '';
|
|
1355
|
+
let applied = null;
|
|
1356
|
+
try {
|
|
1357
|
+
state.transact(scope, {
|
|
1358
|
+
operationId,
|
|
1359
|
+
expectCreate: !before.__exists,
|
|
1360
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
1361
|
+
}, (draft) => {
|
|
1362
|
+
applied = state.applyControl(draft, options.enabled, options.mode);
|
|
1363
|
+
return applied.changed ? { changed: true } : { changed: false };
|
|
1364
|
+
});
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
1367
|
+
return { ok: false, reason: error instanceof Error ? error.message : '记忆设置没有改动' };
|
|
1368
|
+
}
|
|
1369
|
+
return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** 项目的触发方式:自适应 / 仅搜索 / 关闭;空值跟随插件配置,不改全局默认。 */
|
|
1374
|
+
export function setRecallMode(scope, mode, options = {}) {
|
|
1375
|
+
if (mode !== 'adaptive' && mode !== 'tool_only' && mode !== 'off') return { ok: false, reason: '不认识这个触发方式' };
|
|
1376
|
+
return withLock(scope, () => {
|
|
1377
|
+
const before = loadIndex(scope);
|
|
1378
|
+
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
1379
|
+
let applied = null;
|
|
1380
|
+
try {
|
|
1381
|
+
state.transact(scope, {
|
|
1382
|
+
operationId,
|
|
1383
|
+
expectCreate: !before.__exists,
|
|
1384
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
1385
|
+
}, (draft) => {
|
|
1386
|
+
applied = state.applyControl(draft, undefined, mode);
|
|
1387
|
+
return applied.changed ? { changed: true } : { changed: false };
|
|
1388
|
+
});
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
1391
|
+
return { ok: false, reason: error instanceof Error ? error.message : '触发方式没有改动' };
|
|
1392
|
+
}
|
|
1393
|
+
return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1343
1397
|
/**
|
|
1344
1398
|
* 项目实验开关:权威状态里缺字段即关闭。状态只回可核验事实,不编学习分数。
|
|
1345
1399
|
* 关闭时保留已完成成果;试用条目暂停,等新的有效反馈或明确管理动作再复核。
|
|
@@ -1707,7 +1761,7 @@ export function readLinks(scope, id, options = {}) {
|
|
|
1707
1761
|
if (target) links.push({ type: link.type || 'related', ...target });
|
|
1708
1762
|
else links.push({ type: link.type || 'related', id: link.id, status: 'invalid' });
|
|
1709
1763
|
}
|
|
1710
|
-
return { id, links, scope: source.key === 'global' ? 'global' : 'project', status: view.statusOf(record, options.context || {}) };
|
|
1764
|
+
return { id, title: record.title || '', links, scope: source.key === 'global' ? 'global' : 'project', status: view.statusOf(record, options.context || {}) };
|
|
1711
1765
|
}
|
|
1712
1766
|
return null;
|
|
1713
1767
|
});
|
|
@@ -2140,7 +2194,8 @@ export function acceptCandidate(scope, key, operationId = '') {
|
|
|
2140
2194
|
return { ok: false, reason: '那条记忆在你确认前改过了,重新建议一次' };
|
|
2141
2195
|
}
|
|
2142
2196
|
/** 只把建议里真有的字段交给写入:省略表示保持,显式空正文清空;旧候选没有 has 时按有没有值回退。 */
|
|
2143
|
-
|
|
2197
|
+
/** 候选正文是模型起草的,人只确认要不要留;来源照实记。 */
|
|
2198
|
+
const source = { title: draft.title, origin: 'model_drafted' };
|
|
2144
2199
|
const has = draft.has && typeof draft.has === 'object' ? draft.has : null;
|
|
2145
2200
|
if (has) {
|
|
2146
2201
|
if (has.summary) source.summary = draft.summary;
|
|
@@ -2465,7 +2520,8 @@ function machineBoundText(source) {
|
|
|
2465
2520
|
|
|
2466
2521
|
/**
|
|
2467
2522
|
* 适用范围判定:内容/依据的普适性优先,模型声明的 scope 只是提案。
|
|
2468
|
-
*
|
|
2523
|
+
* 写死了本机路径/端口又没有声明适用环境时留在项目层;声明了机器/系统条件的
|
|
2524
|
+
* 本机流程可以进全局层——换台机器条件不成立,自然不会拿出来用。
|
|
2469
2525
|
*/
|
|
2470
2526
|
export function routeScope(draft, options = {}) {
|
|
2471
2527
|
const source = draft || {};
|
|
@@ -2477,6 +2533,16 @@ export function routeScope(draft, options = {}) {
|
|
|
2477
2533
|
const evidence = typeof source.evidenceRef === 'string' ? source.evidenceRef.trim() : '';
|
|
2478
2534
|
/** 依据本身是本机文件/路径的,不能算通用流程;正文里带本机痕迹同理。 */
|
|
2479
2535
|
const machineBound = machineBoundText(source) || (evidence ? machineBoundText({ title: evidence }) : false);
|
|
2536
|
+
/** 声明了机器/系统条件,本机痕迹就被这条条件收住:规则仍可搬到所有满足条件的机器上。 */
|
|
2537
|
+
const envScoped = (() => {
|
|
2538
|
+
try {
|
|
2539
|
+
return (view.normalizeConditions(source.conditions) || []).some((item) => ENVIRONMENT_KEYS.includes(item.key));
|
|
2540
|
+
} catch {
|
|
2541
|
+
return false;
|
|
2542
|
+
}
|
|
2543
|
+
})();
|
|
2544
|
+
/** 真正挡回项目层的只有「本机痕迹 + 没有声明适用环境」;只写命令名不算本机痕迹。 */
|
|
2545
|
+
const machineConflict = machineBound && !envScoped;
|
|
2480
2546
|
const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
|
|
2481
2547
|
/** 通用层拿不出普适证据时:有项目就留在项目层,没有就问一次运行环境。 */
|
|
2482
2548
|
const fallbackToProject = (reasonCode) => {
|
|
@@ -2492,11 +2558,14 @@ export function routeScope(draft, options = {}) {
|
|
|
2492
2558
|
const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
|
|
2493
2559
|
if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
|
|
2494
2560
|
if (explicit) {
|
|
2495
|
-
/**
|
|
2561
|
+
/**
|
|
2562
|
+
* 显式 scope 代表「用户这个消息里要的那一层」:这是唯一能让全局层落地的输入,
|
|
2563
|
+
* 所以不能再按模型自荐降级,只有本机痕迹和项目事实两种硬冲突能改落点。
|
|
2564
|
+
* 没显式 scope 时才用 memoryClass 提案,缺可搬依据的流程留在项目层。
|
|
2565
|
+
*/
|
|
2496
2566
|
if (explicit === 'global' && !humanConfirmed) {
|
|
2497
|
-
if (
|
|
2567
|
+
if (machineConflict) return fallbackToProject('machine_bound_scope');
|
|
2498
2568
|
if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
|
|
2499
|
-
if (cls === 'portable_procedure' && !evidence) return fallbackToProject('unbound_evidence');
|
|
2500
2569
|
}
|
|
2501
2570
|
if (explicit === 'project' && !projectReady) {
|
|
2502
2571
|
const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
|
|
@@ -2520,11 +2589,13 @@ export function routeScope(draft, options = {}) {
|
|
|
2520
2589
|
}
|
|
2521
2590
|
if (cls === 'personal_preference') {
|
|
2522
2591
|
/** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
|
|
2523
|
-
if (
|
|
2592
|
+
if (machineConflict) return fallbackToProject('machine_bound_preference');
|
|
2524
2593
|
return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
|
|
2525
2594
|
}
|
|
2526
2595
|
if (cls === 'portable_procedure') {
|
|
2527
|
-
if (
|
|
2596
|
+
if (machineConflict) return fallbackToProject('machine_bound_procedure');
|
|
2597
|
+
/** 带适用环境条件的本机流程:条件成立时才用,换台机器自然不生效。 */
|
|
2598
|
+
if (envScoped) return { ...base, chosenScope: 'global', reasonCode: 'machine_scoped_procedure' };
|
|
2528
2599
|
/** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
|
|
2529
2600
|
if (!evidence) return fallbackToProject('unbound_evidence');
|
|
2530
2601
|
return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
|
package/test/client.test.mjs
CHANGED
|
@@ -28,7 +28,7 @@ assert.equal(panel.name, 'shell.overlay');
|
|
|
28
28
|
assert.equal(panel.order, 30);
|
|
29
29
|
|
|
30
30
|
const EMPTY_CHILDREN = [];
|
|
31
|
-
const { metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, processingStatusOf, currentBodyOf, historyDiffsOf, MemoryPanel, MemoryGraph, displayKeyOf, readDisplaySettings, writeDisplaySettings, graphViewOf, viewModeOf, filterRecords, projectionOf, failureStateOf, evidenceTextOf, evidenceMetaOf, MemoryPalace, palaceRoomsOf, palaceRoomPageOf, palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf, TurnMemory, refMetaOf, explainKeyOf, explainModeOf, setExplainMode, recallLineOf, agoLabel, checkupOf, titleCountsOf, rowKeyOf, rowKeepFlags } = seen;
|
|
31
|
+
const { metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, processingStatusOf, currentBodyOf, historyDiffsOf, MemoryPanel, MemoryGraph, displayKeyOf, readDisplaySettings, writeDisplaySettings, graphViewOf, viewModeOf, filterRecords, projectionOf, failureStateOf, evidenceTextOf, evidenceMetaOf, MemoryPalace, palaceRoomsOf, palaceRoomPageOf, palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf, TurnMemory, refMetaOf, explainKeyOf, explainModeOf, setExplainMode, recallLineOf, agoLabel, checkupOf, refSectionRowsOf, refDetailTitleOf, versionLine, titleCountsOf, rowKeyOf, rowKeepFlags } = seen;
|
|
32
32
|
|
|
33
33
|
// 图谱只按真实数据画:共享标签成枢纽,显式关联成边,位置由 id 决定。
|
|
34
34
|
const graph = graphOf([
|
|
@@ -288,6 +288,9 @@ assert.equal(refMetaOf(typedSearch), '[全局]');
|
|
|
288
288
|
assert.equal(citationOf({ kind: 'memory-read', title: 'P', recordKind: 'procedure', scope: 'project', revision: 2 }, 'memory-read').entryKind, 'procedure');
|
|
289
289
|
assert.equal(refMetaOf({ scope: 'project', origin: 'user_explicit' }), '[项目]');
|
|
290
290
|
assert.equal(refMetaOf({ scope: 'global', origin: 'project_evolution' }), '[全局][自进化]');
|
|
291
|
+
/** 模型整理稿只多一个信任标记,层级仍按落盘作用域。 */
|
|
292
|
+
assert.equal(refMetaOf({ scope: 'project', origin: 'model_drafted' }), '[项目][模型]');
|
|
293
|
+
assert.equal(citationOf({ kind: 'memory-save', title: 'M', scope: 'global', revision: 1, origin: 'model_drafted', changed: true }, 'memory-save').origin, 'model_drafted');
|
|
291
294
|
/** 更新距今照旧,只是不再拼类型与「你记的」。 */
|
|
292
295
|
const aged = citationOf({ kind: 'preference', title: 'C', scope: 'global', revision: 1, updated: new Date(Date.now() - 3 * 86_400_000).toISOString() }, 'memory-search');
|
|
293
296
|
assert.equal(aged.entryKind, 'preference');
|
|
@@ -295,6 +298,23 @@ assert.equal(refMetaOf(aged), '[全局] · 3 天前更新');
|
|
|
295
298
|
assert.equal(agoLabel(''), '');
|
|
296
299
|
assert.equal(agoLabel(new Date(Date.now() - 40 * 86_400_000).toISOString()), '');
|
|
297
300
|
|
|
301
|
+
// 会话里的正文跟着被点的那条走:点 B,正文夹在 B 与 C 之间,不甩到列表末尾。
|
|
302
|
+
const inlineRefs = [
|
|
303
|
+
{ key: 'a', revision: 1, title: 'A' },
|
|
304
|
+
{ key: 'b', revision: 2, title: 'B' },
|
|
305
|
+
{ key: 'c', revision: 3, title: 'C' },
|
|
306
|
+
];
|
|
307
|
+
const rowOfRef = (item) => item.key;
|
|
308
|
+
const detailOfRef = () => 'detail';
|
|
309
|
+
assert.deepEqual(refSectionRowsOf(inlineRefs, { key: 'b', ref: { revision: 2 } }, rowOfRef, detailOfRef), ['a', 'b', 'detail', 'c']);
|
|
310
|
+
assert.deepEqual(refSectionRowsOf(inlineRefs, { key: 'a', ref: { revision: 1 } }, rowOfRef, detailOfRef), ['a', 'detail', 'b', 'c']);
|
|
311
|
+
assert.deepEqual(refSectionRowsOf(inlineRefs, null, rowOfRef, detailOfRef), ['a', 'b', 'c']);
|
|
312
|
+
// 抬头的永远是记忆标题;项目名只留在下一行来源里,不再单独顶一行被当成标题。
|
|
313
|
+
assert.equal(refDetailTitleOf({ title: 'DSH', ref: { title: '视频预览插件重启后仍缓存旧代码' } }), '视频预览插件重启后仍缓存旧代码');
|
|
314
|
+
assert.equal(refDetailTitleOf({ ref: { title: '标题' } }), '标题');
|
|
315
|
+
assert.equal(versionLine({ scope: 'project', scopeLabel: 'DSH', status: 'active' }), '项目 · DSH');
|
|
316
|
+
assert.equal(versionLine({ scope: 'global', status: 'active' }), '全局');
|
|
317
|
+
|
|
298
318
|
// 解释模式按会话存在本机,缺省关闭;关着零命中不出提示,打开后才说原因。
|
|
299
319
|
assert.equal(explainModeOf('s1'), false);
|
|
300
320
|
const quietRefs = [];
|
|
@@ -335,9 +355,13 @@ assert.deepEqual(rowKeepFlags([rowKeyOf('memory_read', blockedBlock('memory · s
|
|
|
335
355
|
// 面板要能回答「为什么这次没提醒我」:只给结论和条数,不露原因码。
|
|
336
356
|
assert.equal(recallLineOf(null), '');
|
|
337
357
|
assert.equal(recallLineOf({ reason: 'hit', cards: 1, matched: 1 }), '上次自动提醒 1 条');
|
|
358
|
+
assert.equal(recallLineOf({ reason: 'hit', cards: 1, matched: 1, at: new Date(Date.now() - 120000).toISOString() }), '上次自动提醒 1 条 · 2 分钟前');
|
|
359
|
+
assert.equal(recallLineOf({ reason: 'no_overlap', at: new Date().toISOString() }), '上次没提醒,没有匹配的记忆 · 刚刚');
|
|
338
360
|
assert.equal(recallLineOf({ reason: 'budget', cards: 0, matched: 2 }), '上次没提醒,2 条相关记忆,本轮自动提醒已用满');
|
|
339
361
|
assert.equal(recallLineOf({ reason: 'needs_context', cards: 0, matched: 1 }), '上次没提醒,1 条相关记忆,适用条件未确认');
|
|
362
|
+
assert.equal(recallLineOf({ reason: 'conflict', cards: 0, matched: 2 }), '上次没提醒,2 条相关记忆,存在冲突的结论');
|
|
340
363
|
assert.equal(recallLineOf({ reason: 'no_overlap', cards: 0, matched: 0 }), '上次没提醒,没有匹配的记忆');
|
|
364
|
+
assert.equal(recallLineOf({ reason: 'expired', cards: 0, matched: 2 }), '上次没提醒,相关内容已经过期');
|
|
341
365
|
assert.equal(recallLineOf({ reason: 'memory_off', cards: 0, matched: 0 }), '上次没提醒,记忆已关闭');
|
|
342
366
|
assert.equal(recallLineOf({ reason: 'tools_only', cards: 0, matched: 0 }), '上次没提醒,只在你问时检索');
|
|
343
367
|
assert.equal(recallLineOf({ reason: 'made_up', cards: 0, matched: 0 }), '');
|
package/test/contract.test.mjs
CHANGED
|
@@ -41,6 +41,29 @@ fs.mkdirSync(cwd, { recursive: true });
|
|
|
41
41
|
const exec = (name, args) => defs.get(name).execute(args, { agent: { cwd, session: { id: 'session-contract', header: { cwd } } } });
|
|
42
42
|
const render = (name, args, value) => defs.get(name).output.render(args, value)[0].text;
|
|
43
43
|
|
|
44
|
+
/** 按已注册的 output.schema 校验真实 execute 结果:宿主校验失败会整条工具报错。 */
|
|
45
|
+
function assertSchema(name, value) {
|
|
46
|
+
const schema = defs.get(name).output.schema;
|
|
47
|
+
const walk = (node, data, where) => {
|
|
48
|
+
if (node.type === 'object') {
|
|
49
|
+
assert.ok(data && typeof data === 'object' && !Array.isArray(data), `${where} 应为对象`);
|
|
50
|
+
for (const key of node.required || []) assert.ok(key in data, `${where} 缺少 ${key}`);
|
|
51
|
+
for (const [key, item] of Object.entries(data)) {
|
|
52
|
+
if (node.properties && node.properties[key]) walk(node.properties[key], item, `${where}.${key}`);
|
|
53
|
+
else assert.equal(node.additionalProperties, true, `${where} 多出 ${key}`);
|
|
54
|
+
}
|
|
55
|
+
} else if (node.type === 'array') {
|
|
56
|
+
assert.ok(Array.isArray(data), `${where} 应为数组`);
|
|
57
|
+
if (node.items) data.forEach((item, index) => walk(node.items, item, `${where}[${index}]`));
|
|
58
|
+
} else if (node.type === 'string') assert.equal(typeof data, 'string', `${where} 应为字符串`);
|
|
59
|
+
else if (node.type === 'boolean') assert.equal(typeof data, 'boolean', `${where} 应为布尔值`);
|
|
60
|
+
else if (node.type === 'integer') assert.ok(Number.isInteger(data), `${where} 应为整数`);
|
|
61
|
+
else if (node.type === 'number') assert.equal(typeof data, 'number', `${where} 应为数字`);
|
|
62
|
+
if (node.enum) assert.ok(node.enum.includes(data), `${where} 不在枚举里`);
|
|
63
|
+
};
|
|
64
|
+
walk(schema, value, name);
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
function call(pathname, payload, options = {}) {
|
|
45
68
|
const req = new EventEmitter();
|
|
46
69
|
req.method = options.method ?? 'POST';
|
|
@@ -177,7 +200,11 @@ const read = await exec('memory_read', { title: saved.title });
|
|
|
177
200
|
assert.equal(read.status, 'needs_context');
|
|
178
201
|
assert.equal(read.body, '', '条件未知时标题只能定位,不能证明适用,正文不返回');
|
|
179
202
|
assert.doesNotMatch(render('memory_read', {}, read), /发版/);
|
|
203
|
+
assertSchema('memory_read', read);
|
|
180
204
|
const plain = await exec('memory_read', { title: distinct.title });
|
|
205
|
+
/** 工具写入的来源一路带到正文回执:界面靠它标 [模型],不靠猜。 */
|
|
206
|
+
assert.equal(plain.origin, 'model_drafted');
|
|
207
|
+
assertSchema('memory_read', plain);
|
|
181
208
|
assert.equal(plain.body, '另一条');
|
|
182
209
|
assert.equal(plain.revision, 1);
|
|
183
210
|
assert.match(render('memory_read', {}, plain), /^memory · \[[^\]]+\] 「发布顺序是啥」/);
|
|
@@ -205,8 +232,42 @@ const readArchived = await exec('memory_read', { title: distinct.title });
|
|
|
205
232
|
assert.equal(readArchived.status, 'archived');
|
|
206
233
|
assert.equal(readArchived.body, '');
|
|
207
234
|
assert.match(render('memory_read', { title: distinct.title }, readArchived), /^memory · 这条记忆已归档$/);
|
|
235
|
+
assertSchema('memory_read', readArchived);
|
|
208
236
|
assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
|
|
209
237
|
|
|
238
|
+
// ── 失败回执同样过输出 schema:不能再返裸 {error} 让界面渲染 undefined ──────────
|
|
239
|
+
await exec('memory_save', { title: '跨层同名', summary: '项目层这一条', body: '项目正文' });
|
|
240
|
+
/** 直接落全局层:正常保存有跨层相近拦截,同层/跨层同名只可能来自历史数据。 */
|
|
241
|
+
store.saveEntry(store.globalScope(), { title: '跨层同名', summary: '全局层另一条', body: '全局正文', writeMode: 'patch' });
|
|
242
|
+
/** 同名两层各有一条时,读哪条由 scope 决定:默认项目层,显式 global 才拿全局正文。 */
|
|
243
|
+
const readTwin = await exec('memory_read', { title: '跨层同名' });
|
|
244
|
+
assert.equal(readTwin.scope, 'project');
|
|
245
|
+
assert.equal(readTwin.body, '项目正文');
|
|
246
|
+
const readGlobalTwin = await exec('memory_read', { title: '跨层同名', scope: 'global' });
|
|
247
|
+
assert.equal(readGlobalTwin.scope, 'global');
|
|
248
|
+
assert.equal(readGlobalTwin.body, '全局正文');
|
|
249
|
+
const readProjectTwin = await exec('memory_read', { title: '跨层同名', scope: 'project' });
|
|
250
|
+
assert.equal(readProjectTwin.scope, 'project');
|
|
251
|
+
assert.equal(readProjectTwin.body, '项目正文');
|
|
252
|
+
const ambiguousSave = await exec('memory_save', { title: '别的标题', summary: '尝试改同名条目', updateTitle: '跨层同名' });
|
|
253
|
+
assert.ok(ambiguousSave.error, '跨层同名要报改哪一层');
|
|
254
|
+
assertSchema('memory_save', ambiguousSave);
|
|
255
|
+
assert.match(render('memory_save', {}, ambiguousSave), /^memory · /);
|
|
256
|
+
assert.doesNotMatch(render('memory_save', {}, ambiguousSave), /undefined|saved|merged/);
|
|
257
|
+
const missingSave = await exec('memory_save', { title: '别的标题', summary: '没有这条', updateTitle: '根本不存在的条目' });
|
|
258
|
+
assert.ok(missingSave.error);
|
|
259
|
+
assertSchema('memory_save', missingSave);
|
|
260
|
+
const badLink = await exec('memory_save', { title: '关联写法不对', summary: '关联要写标题', links: [{}] });
|
|
261
|
+
assert.ok(badLink.error);
|
|
262
|
+
assertSchema('memory_save', badLink);
|
|
263
|
+
const missingLink = await exec('memory_save', { title: '关联目标不存在', summary: '目标没有落盘', links: [{ type: 'related', title: '根本没这条' }] });
|
|
264
|
+
assert.ok(missingLink.error);
|
|
265
|
+
assertSchema('memory_save', missingLink);
|
|
266
|
+
assert.match(render('memory_save', {}, missingLink), /^memory · 没有「根本没这条」这条记忆/);
|
|
267
|
+
|
|
268
|
+
// 成功回执也必须过同一份 schema
|
|
269
|
+
for (const value of [saved, merged, patch, probe, distinct]) assertSchema('memory_save', value);
|
|
270
|
+
|
|
210
271
|
// ── suggestion → keep → replay → reject ──────────────────────────────────────
|
|
211
272
|
const suggested = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
|
|
212
273
|
assert.equal(suggested.asked, true);
|
|
@@ -266,7 +327,28 @@ assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 40
|
|
|
266
327
|
// ── global layer ─────────────────────────────────────────────────────────────
|
|
267
328
|
const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
|
|
268
329
|
assert.equal(global.scope, 'global');
|
|
330
|
+
assert.equal(global.scopeNote, undefined, '正常落全局不该带降级说明');
|
|
269
331
|
assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"$/);
|
|
332
|
+
|
|
333
|
+
// 显式写 global 也不再被悄无声息地改层:本机痕迹和项目事实各有一条能解释的降级。
|
|
334
|
+
const machineGlobal = await exec('memory_save', {
|
|
335
|
+
title: '本机目录同步步骤',
|
|
336
|
+
summary: '流程里写了本机路径,写全局也会留在项目层',
|
|
337
|
+
body: '在 /Users/apple/Desktop/DSH 下改完再同步。',
|
|
338
|
+
scope: 'global',
|
|
339
|
+
memoryClass: 'portable_procedure',
|
|
340
|
+
});
|
|
341
|
+
assert.equal(machineGlobal.scope, 'project');
|
|
342
|
+
assert.match(machineGlobal.scopeNote, /按本机痕迹留在项目层/);
|
|
343
|
+
const factGlobal = await exec('memory_save', {
|
|
344
|
+
title: '发版顺序只在仓库成立',
|
|
345
|
+
summary: '项目事实换项目不成立,写全局也会留在项目层',
|
|
346
|
+
body: '这个仓库的发版顺序是先前置检查再打包。',
|
|
347
|
+
scope: 'global',
|
|
348
|
+
memoryClass: 'project_fact',
|
|
349
|
+
});
|
|
350
|
+
assert.equal(factGlobal.scope, 'project');
|
|
351
|
+
assert.match(factGlobal.scopeNote, /按项目事实留在项目层/);
|
|
270
352
|
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
271
353
|
assert.equal(globalSearch.count, 1);
|
|
272
354
|
assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
|
|
@@ -584,8 +666,22 @@ const closedTool = await defs.get('memory_save').execute(
|
|
|
584
666
|
);
|
|
585
667
|
assert.equal(closedTool.changed, false);
|
|
586
668
|
assert.equal(closedTool.reason, 'memory_disabled');
|
|
669
|
+
assertSchema('memory_save', closedTool);
|
|
587
670
|
assert.equal(closedTool.id, undefined);
|
|
671
|
+
/** 关闭状态下的读取也只报状态,不返回正文。 */
|
|
672
|
+
const closedRead = await defs.get('memory_read').execute({ title: closedSeed.title }, { agent: closedAgent });
|
|
673
|
+
assert.equal(closedRead.status, 'memory_disabled');
|
|
674
|
+
assert.equal(closedRead.body, undefined, '关闭时连空正文也不占位');
|
|
675
|
+
assertSchema('memory_read', closedRead);
|
|
676
|
+
assert.match(render('memory_read', {}, closedRead), /^memory · 记忆检索已关闭$/);
|
|
588
677
|
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
678
|
+
const toolOnly = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, mode: 'tool_only', operationId: 'op_mode_contract' });
|
|
679
|
+
assert.equal(toolOnly.status, 200);
|
|
680
|
+
assert.equal(toolOnly.body.enabled, true);
|
|
681
|
+
assert.equal(toolOnly.body.mode, 'tool_only');
|
|
682
|
+
const backToAuto = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, mode: 'adaptive', operationId: 'op_mode_back_contract' });
|
|
683
|
+
assert.equal(backToAuto.status, 200);
|
|
684
|
+
assert.equal(backToAuto.body.mode, 'adaptive');
|
|
589
685
|
const reopened = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, operationId: 'op_reopen_contract' });
|
|
590
686
|
assert.equal(reopened.status, 200);
|
|
591
687
|
const reopenedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
@@ -756,6 +852,11 @@ assert.ok(linkedRecord && linkedTarget);
|
|
|
756
852
|
assert.equal(linkedRecord.links.length, 1);
|
|
757
853
|
assert.equal(linkedRecord.links[0].id, linkedTarget.id);
|
|
758
854
|
assert.equal(linkedRecord.links[0].type, 'related');
|
|
855
|
+
/** 关联清单也要给标题:只发内部 id 会让界面显示 undefined。 */
|
|
856
|
+
const linkRead = await exec2('memory_read', { title: '关联目标乙', view: 'links' });
|
|
857
|
+
assert.equal(linkRead.title, '关联目标乙');
|
|
858
|
+
assert.match(linkRead.links, /关联目标甲/);
|
|
859
|
+
assert.doesNotMatch(render('memory_read', { title: '关联目标乙', view: 'links' }, linkRead), /undefined/);
|
|
759
860
|
/** 更新句柄也是标题:抄标题改这一条,不需要任何内部编号。 */
|
|
760
861
|
const retitled = await exec2('memory_save', { updateTitle: linkedA.title, title: linkedA.title, summary: '改过的摘要' });
|
|
761
862
|
assert.equal(retitled.changed, true);
|
|
@@ -887,6 +988,10 @@ const ovSecond = await defs.get('memory_save').execute(
|
|
|
887
988
|
{ title: '封面留白', summary: '封面别放满', body: '右侧留白。', tags: ['封面'] },
|
|
888
989
|
{ agent: ovAgent },
|
|
889
990
|
);
|
|
991
|
+
await defs.get('memory_save').execute(
|
|
992
|
+
{ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', when: '周四晚上' },
|
|
993
|
+
{ agent: ovAgent },
|
|
994
|
+
);
|
|
890
995
|
await defs.get('memory_save').execute(
|
|
891
996
|
{ title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scope: 'global' },
|
|
892
997
|
{ agent: ovAgent },
|
|
@@ -908,7 +1013,7 @@ assert.equal(ov.body.selected.evolution.enabled, false);
|
|
|
908
1013
|
assert.ok(Array.isArray(ov.body.selected.evolution.rules));
|
|
909
1014
|
assert.ok(ov.body.selected.records.some((row) => row.title === '开场先给结论'));
|
|
910
1015
|
assert.equal(ov.body.selected.records.some((row) => 'body' in row), false, '列表不带正文');
|
|
911
|
-
assert.equal(ov.body.selected.stats.total,
|
|
1016
|
+
assert.equal(ov.body.selected.stats.total, 3);
|
|
912
1017
|
assert.equal(ov.body.selected.stats.tags, 3);
|
|
913
1018
|
assert.equal(JSON.stringify(ov.body).includes('"dir"'), false, '不暴露内部目录');
|
|
914
1019
|
assert.equal(JSON.stringify(ov.body).includes(process.env.DSH_HOME), false);
|
|
@@ -971,6 +1076,30 @@ const ovGlobalEdit = await call('/lite-memory/entry', {
|
|
|
971
1076
|
assert.equal(ovGlobalEdit.status, 409, '全局层在项目会话里只读');
|
|
972
1077
|
assert.equal((await call('/lite-memory/entry', { sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id, title: '', body: 'x', expectedRevision: 1 })).status, 400);
|
|
973
1078
|
|
|
1079
|
+
// 适用范围只有两种来源:模型当时没确认,或人看过之后明确确认。面板能补后者,不替模型猜。
|
|
1080
|
+
const ovPendingRow = ov.body.selected.records.find((row) => row.title === '发布窗口');
|
|
1081
|
+
assert.equal(ovPendingRow.applicability, 'unresolved');
|
|
1082
|
+
const ovPendingBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '发布窗口', scopeKey: ov.body.current.key });
|
|
1083
|
+
assert.equal(ovPendingBody.status, 200);
|
|
1084
|
+
assert.equal(ovPendingBody.body.applicability, 'unresolved');
|
|
1085
|
+
const ovConfirmBad = await call('/lite-memory/entry', {
|
|
1086
|
+
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovPendingRow.id,
|
|
1087
|
+
title: '发布窗口', body: '周四晚上再发版。', expectedRevision: ovPendingRow.revision, applicability: 'always',
|
|
1088
|
+
});
|
|
1089
|
+
assert.equal(ovConfirmBad.status, 400, '三档之外的值不能写进记忆');
|
|
1090
|
+
const ovConfirm = await call('/lite-memory/entry', {
|
|
1091
|
+
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovPendingRow.id,
|
|
1092
|
+
title: '发布窗口', body: '周四晚上再发版。', expectedRevision: ovPendingRow.revision,
|
|
1093
|
+
applicability: 'all_in_scope', conditions: [],
|
|
1094
|
+
});
|
|
1095
|
+
assert.equal(ovConfirm.status, 200);
|
|
1096
|
+
assert.equal(ovConfirm.body.ok, true);
|
|
1097
|
+
const ovPendingAfter = await call('/lite-memory/body', { sessionId: 'session-overview', title: '发布窗口', scopeKey: ov.body.current.key });
|
|
1098
|
+
assert.equal(ovPendingAfter.body.applicability, 'all_in_scope');
|
|
1099
|
+
assert.deepEqual(ovPendingAfter.body.conditions, []);
|
|
1100
|
+
assert.equal(store.readEntry(store.scopeOf(ovCwd), ovPendingRow.id).applicability, 'all_in_scope', '确认落到那一条上');
|
|
1101
|
+
assert.equal(store.readEntry(store.scopeOf(ovCwd), ovRow.id).applicability, 'all_in_scope', '别条不受影响');
|
|
1102
|
+
|
|
974
1103
|
// 详情按服务端枚举过的 scopeKey 取;内部路径、未知键一律不放行。
|
|
975
1104
|
// 宫殿里点开一条只走这个只读入口:不改记忆、不记用量、不占额度。
|
|
976
1105
|
const ovStateFile = path.join(store.scopeOf(ovCwd).dir, 'state.json');
|
package/test/control.test.mjs
CHANGED
|
@@ -65,6 +65,28 @@ const seedRule = (scope, title) => {
|
|
|
65
65
|
const scopeOf = (dir) => store.scopeOf(dir);
|
|
66
66
|
const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { operationId: `op-${Math.random().toString(16).slice(2)}` });
|
|
67
67
|
|
|
68
|
+
/* C00:触发方式按项目存,切到「仅搜索」不改总开关,关闭时也不偷偷恢复授权。 */
|
|
69
|
+
{
|
|
70
|
+
const scope = scopeOf(mkdir('mode'));
|
|
71
|
+
assert.equal(store.controlStatus(scope).enabled, true);
|
|
72
|
+
assert.equal(store.controlStatus(scope).mode, '');
|
|
73
|
+
const epochBefore = store.controlStatus(scope).epoch;
|
|
74
|
+
const searched = store.setRecallMode(scope, 'tool_only', { operationId: 'op-mode-search' });
|
|
75
|
+
assert.equal(searched.ok, true);
|
|
76
|
+
assert.equal(searched.mode, 'tool_only');
|
|
77
|
+
assert.equal(store.controlStatus(scope).enabled, true, '只改触发方式,不动总开关');
|
|
78
|
+
assert.notEqual(store.controlStatus(scope).epoch, epochBefore, '改触发方式要换掉旧回执的凭据');
|
|
79
|
+
assert.equal(store.setRecallMode(scope, '自动', { operationId: 'op-mode-bad' }).ok, false);
|
|
80
|
+
assert.equal(store.controlStatus(scope).mode, 'tool_only', '不认识的值不落盘');
|
|
81
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
82
|
+
assert.equal(store.setRecallMode(scope, 'adaptive', { operationId: 'op-mode-off' }).mode, 'adaptive');
|
|
83
|
+
assert.equal(store.controlStatus(scope).enabled, false, '关闭时改触发方式不能顺手打开');
|
|
84
|
+
const combined = store.setMemoryControl(scope, { enabled: true, mode: 'tool_only' }, { operationId: 'op-mode-combined' });
|
|
85
|
+
assert.equal(combined.ok, true);
|
|
86
|
+
assert.equal(combined.enabled, true);
|
|
87
|
+
assert.equal(combined.mode, 'tool_only');
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
/* C01:总开关关闭后,自动注入、全局召回、检索、读取、写入、学习全部停。 */
|
|
69
91
|
{
|
|
70
92
|
const dir = mkdir('off');
|
|
@@ -35,6 +35,8 @@ function boot(mode = 'adaptive') {
|
|
|
35
35
|
apply(ctx, { mode });
|
|
36
36
|
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
37
37
|
assert.ok(provider, 'recall provider registered');
|
|
38
|
+
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
39
|
+
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
38
40
|
const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
|
|
39
41
|
const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
|
|
40
42
|
const text = (agent, signal) => provider.text({ agent, signal });
|
package/test/learning.test.mjs
CHANGED
|
@@ -965,10 +965,11 @@ test('L03b 明确长期要求整理为空时也留下规则', () => {
|
|
|
965
965
|
test('L03c 兜底不把没说范围的要求抬成全项目规则', () => {
|
|
966
966
|
const vague = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true } });
|
|
967
967
|
assert.equal(vague.applicability, 'unresolved');
|
|
968
|
+
/** 「所有口播」限定的是栏目对象,不是整个项目;没有可信栏目就留待确认,不能扩成全项目。 */
|
|
968
969
|
const allLines = learning.pendingChangeFrom({ text: '以后所有口播都先给结论。', cue: { explicitLongTerm: true } });
|
|
969
|
-
assert.equal(allLines.applicability, '
|
|
970
|
+
assert.equal(allLines.applicability, 'unresolved', '「所有口播」不能当全项目授权');
|
|
970
971
|
const always = learning.pendingChangeFrom({ text: '以后一律先给结论。', cue: { explicitLongTerm: true } });
|
|
971
|
-
assert.equal(always.applicability, '
|
|
972
|
+
assert.equal(always.applicability, 'unresolved', '「一律」不能单独当项目范围');
|
|
972
973
|
assert.deepEqual(vague.conditions, []);
|
|
973
974
|
const scoped = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true, taskKind: 'video' } });
|
|
974
975
|
assert.equal(scoped.applicability, 'conditional');
|
package/test/lexical.test.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-'));
|
|
7
7
|
const store = await import('../store.js');
|
|
8
|
+
const view = await import('../view.js');
|
|
8
9
|
|
|
9
10
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-scope-'));
|
|
10
11
|
const scope = store.scopeOf(dir);
|
|
@@ -19,10 +20,15 @@ const add = (title, extra = {}) => store.upsert(scope, {
|
|
|
19
20
|
add('digital signal 调试', { tags: ['音频'] });
|
|
20
21
|
add('怎么做发布检查', { tags: ['发布'] });
|
|
21
22
|
add('git 提交前跑检查', { tags: ['git'] });
|
|
23
|
+
add('插件改名与合并', { tags: ['插件开发', '启动失败'], summary: '改包名要同步 factory ID,否则启动失败' });
|
|
24
|
+
add('启动服务器', { tags: ['启动'] });
|
|
22
25
|
|
|
23
26
|
/* 英文按整词:git 不能因为共享 gi/it 命中 digital。 */
|
|
24
27
|
assert.equal(store.search(scope, 'git', 5).some((row) => row.title.includes('digital')), false);
|
|
25
28
|
assert.equal(store.search(scope, 'digital', 5).some((row) => row.title.includes('digital')), true);
|
|
29
|
+
/* 中英混排同样按整词:英文词没出现,中文词重合再多也不能替它作证。 */
|
|
30
|
+
assert.equal(store.search(scope, '排查 git', 5).some((row) => row.title.includes('digital')), false);
|
|
31
|
+
assert.equal(store.search(scope, '排查 git', 5).some((row) => row.title.includes('git 提交前跑检查')), true);
|
|
26
32
|
|
|
27
33
|
/* 只重合「怎么」这类高频词不算命中。 */
|
|
28
34
|
assert.equal(store.search(scope, '这个怎么弄', 5).length, 0);
|
|
@@ -30,4 +36,20 @@ assert.equal(store.search(scope, '这个怎么弄', 5).length, 0);
|
|
|
30
36
|
/* 实词仍然命中。 */
|
|
31
37
|
assert.equal(store.search(scope, '发布检查', 5).some((row) => row.title.includes('怎么做发布检查')), true);
|
|
32
38
|
|
|
39
|
+
/* 「启动」只是「启动失败」的一半,不能把改包名的记忆带进自动提示。 */
|
|
40
|
+
const renameRecord = store.search(scope, '插件改名与合并', 1)[0];
|
|
41
|
+
const serverRecord = store.search(scope, '启动服务器', 5).find((row) => row.title === '启动服务器');
|
|
42
|
+
assert.equal(view.matchScore(renameRecord, '启动服务器').score < view.MATCH_FLOOR, true);
|
|
43
|
+
assert.equal(view.matchScore(serverRecord, '启动服务器').score >= view.MATCH_FLOOR, true);
|
|
44
|
+
/* 两个词都落在同一个标签上才继续算命中。 */
|
|
45
|
+
assert.equal(store.search(scope, '启动失败怎么查', 5).some((row) => row.title === '插件改名与合并'), true);
|
|
46
|
+
|
|
47
|
+
/* 续说一句没有检索词:只有明确的「继续/然后」才沿用上一轮,别把短问题也接上去。 */
|
|
48
|
+
for (const text of ['继续', '继续。', '然后呢?', '接着', 'go on', 'continue']) {
|
|
49
|
+
assert.equal(view.isContinuationQuery(text), true, text);
|
|
50
|
+
}
|
|
51
|
+
for (const text of ['继续优化轻量化记忆与前端展示逻辑', '然后发布', '样式', 'next steps for release']) {
|
|
52
|
+
assert.equal(view.isContinuationQuery(text), false, text);
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
console.log('lexical.test.mjs: all passed');
|
package/test/recall.test.mjs
CHANGED
|
@@ -157,6 +157,32 @@ assert.ok(cards.length <= 3);
|
|
|
157
157
|
for (const line of cards) assert.match(line, CARD_LINE);
|
|
158
158
|
assert.doesNotMatch(preparedText, /第 .* 版/);
|
|
159
159
|
assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'frozen between calls');
|
|
160
|
+
/** 到期在输出前复核:投影之后过期的那条不再发给模型。 */
|
|
161
|
+
const expiring = bootA.debug.preparedOf(agentA);
|
|
162
|
+
assert.ok(expiring.lines.length >= 1, 'prepared cards carry their own deadline');
|
|
163
|
+
const firstLine = expiring.lines[0].line;
|
|
164
|
+
expiring.lines[0].validUntil = new Date(Date.now() - 1000).toISOString();
|
|
165
|
+
const afterExpiry = bootA.provider.text({ agent: agentA, signal: signalA });
|
|
166
|
+
assert.ok(!afterExpiry.includes(firstLine), 'expired card withheld at emit');
|
|
167
|
+
assert.match(afterExpiry, /^已有记忆可能相关/);
|
|
168
|
+
expiring.lines[0].validUntil = '';
|
|
169
|
+
assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'unexpired card still delivered');
|
|
170
|
+
|
|
171
|
+
// A02b:同一会话里只说「继续」时沿用上一句的查询,短追问也能接上刚才那条记忆。
|
|
172
|
+
const contAgent = makeAgent('agent-cont', cwdA);
|
|
173
|
+
const contSignal = signal();
|
|
174
|
+
await bootA.preStep({ agent: contAgent, signal: contSignal, turn: 'c1' }, enter(human('发布前文案检查要怎么过?')));
|
|
175
|
+
const contFirst = bootA.debug.preparedOf(contAgent);
|
|
176
|
+
assert.ok(contFirst && contFirst.cards.length, 'first turn recalls the publish rule');
|
|
177
|
+
assert.match(contFirst.query, /发布前文案检查/);
|
|
178
|
+
const contSignal2 = signal();
|
|
179
|
+
await bootA.preStep({ agent: contAgent, signal: contSignal2, turn: 'c2' }, enter(human('继续')));
|
|
180
|
+
const contNext = bootA.debug.preparedOf(contAgent);
|
|
181
|
+
assert.ok(contNext, 'short continuation still prepares a snapshot');
|
|
182
|
+
assert.match(contNext.query, /^继续 /);
|
|
183
|
+
assert.match(contNext.query, /发布前文案检查/, 'continuation carries the previous query');
|
|
184
|
+
assert.ok(contNext.cards.length, 'continuation still resolves cards');
|
|
185
|
+
assert.ok(contNext.cards.some((card) => /文案检查/.test(card.title || card.line || '')), 'same topic comes back');
|
|
160
186
|
|
|
161
187
|
// A03:provider 纯内存,不写盘、不动 hits。
|
|
162
188
|
const statePath = path.join(scopeA.dir, 'state.json');
|
|
@@ -532,3 +558,30 @@ console.log('recall.test.mjs: ok');
|
|
|
532
558
|
assert.doesNotMatch(runtime.provider.text({ agent, signal: sig }), /Digital asset catalog/);
|
|
533
559
|
assert.equal(store.search(scopeEn, 'git', 5).length, 0, '手动搜索也不给这条');
|
|
534
560
|
}
|
|
561
|
+
|
|
562
|
+
// A25:用户这句话没提动作时,用上一轮助手的话和工具名补一次查询;补不到不盖掉原判断。
|
|
563
|
+
{
|
|
564
|
+
const cwdAct = mkdir('action');
|
|
565
|
+
const scopeAct = store.scopeOf(cwdAct);
|
|
566
|
+
save(scopeAct, {
|
|
567
|
+
title: '改完 client.js 先重载宿主',
|
|
568
|
+
tags: ['client.js', '重载'],
|
|
569
|
+
summary: 'client.js 一改就要重载宿主,普通刷新覆盖不到',
|
|
570
|
+
});
|
|
571
|
+
const agent = makeAgent('agent-action', cwdAct);
|
|
572
|
+
const runtime = boot();
|
|
573
|
+
const first = signal();
|
|
574
|
+
await runtime.preStep({ agent, signal: first, turn: 't1' }, enter(human('这个怎么弄')));
|
|
575
|
+
assert.equal(runtime.provider.text({ agent, signal: first }), '', '只说「这个怎么弄」时不该凭空命中');
|
|
576
|
+
const assistantMessage = {
|
|
577
|
+
role: 'assistant',
|
|
578
|
+
id: 'asst-action-1',
|
|
579
|
+
content: [
|
|
580
|
+
{ type: 'text', text: '我把改动写进文件了。' },
|
|
581
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/act/client.js' } },
|
|
582
|
+
],
|
|
583
|
+
};
|
|
584
|
+
const second = signal();
|
|
585
|
+
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
|
|
586
|
+
assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
|
|
587
|
+
}
|