@modusensus/dsh-mneme 0.9.4 → 0.10.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.
- package/README.md +57 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +25 -2
- package/docs/DAEMON.md +13 -2
- package/docs/DISTILL.md +150 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +21 -7
- package/docs/STORAGE.md +2 -1
- package/lib/api-standalone.js +294 -8
- package/lib/api.js +217 -3
- package/lib/client.js +919 -39
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +176 -30
- package/lib/dream/sleep.js +530 -94
- package/lib/dream.js +174 -21
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +38 -16
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pipeline.js +62 -1
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +251 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +97 -4
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +797 -28
- package/lib/summarize.js +209 -23
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +294 -8
- package/src/api.js +217 -3
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/document.js +15 -2
- package/src/dream/decisions.js +176 -30
- package/src/dream/sleep.js +530 -94
- package/src/dream.js +174 -21
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +38 -16
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pipeline.js +62 -1
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +251 -0
- package/src/serve.js +13 -0
- package/src/service.js +97 -4
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +797 -28
- package/src/summarize.js +209 -23
- package/src/tools.js +26 -6
- package/test/api.test.js +523 -6
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +354 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +207 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/inject-pools.test.js +2 -1
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +84 -1
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +367 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/release-docs.test.js +7 -1
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/security-doc.test.js +43 -4
- package/test/serve.test.js +62 -0
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +574 -5
- package/test/store.test.js +220 -0
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +187 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { STR } from "../src/lang.js";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
import { createDreamScheduler } from "../src/dream.js";
|
|
7
|
+
|
|
8
|
+
// 防谄媚的**提示词侧**回归锁。
|
|
9
|
+
//
|
|
10
|
+
// 背景:#380 的注入前判定(pre-inject-gate)只拦「已落库的意见/立场」,而落到库里
|
|
11
|
+
// 之前的两个写作环节——蒸馏(summary / codingSummary)与总览叙述(dreamSummary)
|
|
12
|
+
// ——此前对谄媚内容一字未提。这两个环节写出的内容**完全不过** writeAdmission
|
|
13
|
+
// (write-admission.js 只在带 sessionKey 的 memory_save 路径生效),所以提示词是
|
|
14
|
+
// 唯一能施加约束的地方,本轮把「立场纪律 / 叙述纪律」写进字面量(Issue #530)。
|
|
15
|
+
//
|
|
16
|
+
// 这些用例锁的是**形状**(提示词里有没有这条约束、位置对不对),锁不了**行为**
|
|
17
|
+
// (模型是否真的照做)——后者要 LLM 在环 + 预注册判据,属 E12 那类实验,不在单测里
|
|
18
|
+
// 宣称。PR 与 CHANGELOG 不得把本文件写成「已知降低谄媚率」。
|
|
19
|
+
|
|
20
|
+
const LANGS = ["zh", "en"];
|
|
21
|
+
|
|
22
|
+
// 五组判据在两种语言里的对应词。写成「中英成对」是刻意的:少一种语言就等于
|
|
23
|
+
// 换语言的用户拿到一份没有防线的提示词(同 lang.test.js 对 profileExtract 的锁法)。
|
|
24
|
+
const STANCE_PATTERNS = [
|
|
25
|
+
[/立场纪律|Stance discipline/, "立场纪律小节本身"],
|
|
26
|
+
[/不要记录对用户本人|Never record evaluations of the user/, "规则 a:不记对用户本人的性格/能力/情绪评价"],
|
|
27
|
+
[/可信度不同|differ in trustworthiness/, "规则 b:转录三种角色可信度不同"],
|
|
28
|
+
[/事实归属以用户陈述为准|the user statement wins on the facts/, "规则 c:事实归属不记成助手推断"],
|
|
29
|
+
[/如实并存|keep both sides/, "规则 c:两种说法并存,不写成谁对谁错"],
|
|
30
|
+
[/不是提高 importance 的理由|never a reason to raise it/, "规则 d:赞许不抬 importance"],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const key of ["summary", "codingSummary"]) {
|
|
34
|
+
test(`${key} 提示词带完整立场纪律(中英成对)`, () => {
|
|
35
|
+
for (const lang of LANGS) {
|
|
36
|
+
const text = STR.prompts[key][lang];
|
|
37
|
+
for (const [re, label] of STANCE_PATTERNS) {
|
|
38
|
+
assert.match(text, re, `${key}.${lang} 缺少${label}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("立场纪律排在任何 JSON 输出形状之前,不会被后置的时间锚点覆盖", () => {
|
|
45
|
+
// summarize.js 在提示词**之后**再拼 STR.distillTimeAnchor(「据此换算 occurred_at」)。
|
|
46
|
+
// 规则若排在那句之后,就成了「先按时间换算、再谈记什么」——指令优先级反了。
|
|
47
|
+
// 同时它必须排在 schema 行之前:schema 是输出形状,纪律是内容判据,形状压不住判据。
|
|
48
|
+
for (const key of ["summary", "codingSummary"]) {
|
|
49
|
+
for (const lang of LANGS) {
|
|
50
|
+
const text = STR.prompts[key][lang];
|
|
51
|
+
const stance = text.search(/立场纪律|Stance discipline/);
|
|
52
|
+
assert.ok(stance > 0, `${key}.${lang}: 立场纪律必须存在于提示词字面量内部`);
|
|
53
|
+
assert.ok(stance < text.search(/occurred_at|occurred_at/), `${key}.${lang}: 立场纪律必须在 occurred_at 说明之前`);
|
|
54
|
+
assert.ok(stance < text.search(/只输出 JSON 数组|Output only a JSON array/), `${key}.${lang}: 立场纪律必须在输出形状之前`);
|
|
55
|
+
assert.ok(text.indexOf("立场纪律") < 0 || stance > text.indexOf("原子记忆原则") - 1,
|
|
56
|
+
`${key}.${lang}: 立场纪律要跟在原子记忆原则之后,别抢掉开头角色句`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("工具/报错结论的豁免条款必须在场(删掉它会误杀 pitfall 与 constraint)", () => {
|
|
62
|
+
// 规则 b 说「助手的结论是推断」,但编码类记忆(pitfall / rejected_solution /
|
|
63
|
+
// constraint)的内容本来就是工具输出与报错直接给出的技术结论。没有这半句豁免,
|
|
64
|
+
// 规则 b 会把整个编码回溯功能一并否掉——这是最容易被后人顺手删掉的高危句。
|
|
65
|
+
for (const lang of LANGS) {
|
|
66
|
+
const text = STR.prompts.summary[lang];
|
|
67
|
+
assert.match(text, /工具输出、报错、命令结果|tool output, errors or command results/,
|
|
68
|
+
`summary.${lang}: 工具结论豁免条款缺失,规则 b 会误杀 pitfall/constraint`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("dreamSummary 带叙述纪律,且散文契约不被破坏", () => {
|
|
73
|
+
for (const lang of LANGS) {
|
|
74
|
+
const text = STR.prompts.dreamSummary[lang];
|
|
75
|
+
assert.match(text, /叙述纪律|Narrative discipline/, `dreamSummary.${lang}: 缺叙述纪律`);
|
|
76
|
+
assert.match(text, /不要给用户添上|add no characterisation/, `dreamSummary.${lang}: 缺「不给用户添性格评价」`);
|
|
77
|
+
// 总览是**散文**契约:dream-fence 那条锁要求这里绝不能出现「JSON 数组」,
|
|
78
|
+
// 否则模型会按数组契约回 `[]`,而 summaryText.trim() 判非空通过,`[]` 就
|
|
79
|
+
// 被当成摘要存进库(dream.js:326-328 的注释记着这次评审实测)。
|
|
80
|
+
assert.doesNotMatch(text, /JSON 数组|JSON array/, `dreamSummary.${lang}: 散文契约里不得出现 JSON 数组`);
|
|
81
|
+
// 硬输出格式锁必须留在末尾,它是「只回叙述文本」的唯一保证。
|
|
82
|
+
assert.match(lang === "zh" ? text : text, /只输出叙述文本,不要其他内容。|Output only the narrative text, nothing else./,
|
|
83
|
+
`dreamSummary.${lang}: 末尾输出格式锁丢了`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 下面这条是**输出侧**的接线锁:提示词改了,落库判据不能跟着变。
|
|
88
|
+
// 空字符串仍必须判为「没有摘要」(degraded/noop),不能被新文案带成「存了个空叙述」。
|
|
89
|
+
test("总览落库判据不因提示词改动而放宽", async () => {
|
|
90
|
+
function mockCtx({ summaryText }) {
|
|
91
|
+
return {
|
|
92
|
+
logger: { warn: () => {} },
|
|
93
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
94
|
+
llm: {
|
|
95
|
+
async *stream(options) {
|
|
96
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
97
|
+
if (userText.includes("| type=")) {
|
|
98
|
+
yield { type: "block-start", index: 0, blockType: "text" };
|
|
99
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
100
|
+
yield { type: "block-end", index: 0, block: { type: "text" } };
|
|
101
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
yield { type: "text-delta", index: 0, text: summaryText };
|
|
105
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const store = createStore(":memory:");
|
|
112
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
113
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
114
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 3 });
|
|
115
|
+
|
|
116
|
+
const ok = await dream.runDream(mockCtx({ summaryText: "记忆库总览:用户偏好中文。" }), service, {});
|
|
117
|
+
assert.equal(ok.summary, true, "非空叙述照常落库");
|
|
118
|
+
const row = service.all().find((m) => m.type === "summary");
|
|
119
|
+
assert.ok(row, "总览行必须存在");
|
|
120
|
+
assert.equal(row.source, "dream");
|
|
121
|
+
assert.equal(row.importance, 5, "总览行的 importance 固定 5(最高注入档)");
|
|
122
|
+
|
|
123
|
+
const empty = await dream.runDream(mockCtx({ summaryText: "" }), service, {});
|
|
124
|
+
assert.equal(empty.summary, false, "空叙述不得落库");
|
|
125
|
+
const rows = service.all().filter((m) => m.type === "summary");
|
|
126
|
+
assert.equal(rows.length, 1, "旧总览存活,没有被空叙述覆盖");
|
|
127
|
+
store.close();
|
|
128
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// 记忆级溯源读出口的 HTTP 口径 —— 共用模块自己的锁。
|
|
2
|
+
//
|
|
3
|
+
// 这个文件盯三类回归:
|
|
4
|
+
// a) 口径本身(id 解析、两种「空」的区分、窗口形状、原文按序);
|
|
5
|
+
// b) distillRecords.enabled 门(#509 评审整改):判据 fail-closed、409 赢 503、
|
|
6
|
+
// 关着时一次都不碰 service;
|
|
7
|
+
// c) **不许再长出第二份实现**。#509 第一版曾把整段复製到 api.js 与
|
|
8
|
+
// api-standalone.js 各一份,维护者在 review 里点名(本仓 #411 之后不收这个
|
|
9
|
+
// 形状)。静态锁把三件事写成断言:口径字面量与开关判据只许出现在本模块、两个
|
|
10
|
+
// 壳都真的把 config 转交进来、读侧判据与写侧 summarize.js 逐字同源。谁要是再
|
|
11
|
+
// 复制一段出去,这里直接转红。
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
DISTILL_RECORDS_DISABLED,
|
|
19
|
+
PROVENANCE_DEFAULT_WINDOWS,
|
|
20
|
+
PROVENANCE_MAX_WINDOWS,
|
|
21
|
+
PROVENANCE_UNAVAILABLE,
|
|
22
|
+
isDistillRecordsEnabled,
|
|
23
|
+
parseProvenanceLimit,
|
|
24
|
+
parseProvenanceSelector,
|
|
25
|
+
provenanceReply
|
|
26
|
+
} from "../src/provenance-http.js";
|
|
27
|
+
|
|
28
|
+
// 方向 1 的选择器:绝大多数用例只关心「读出去的那一侧」,方向本身用这个常量固定。
|
|
29
|
+
const MEM = { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS };
|
|
30
|
+
const SES = { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS };
|
|
31
|
+
|
|
32
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
|
+
|
|
34
|
+
// 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须
|
|
35
|
+
// 显式把开关打开——不传就是关(fail-closed),没有「默认开」这回事。
|
|
36
|
+
const RECORDS_ON = { distillRecords: { enabled: true } };
|
|
37
|
+
|
|
38
|
+
function fakeService(over = {}) {
|
|
39
|
+
return {
|
|
40
|
+
provenanceFor: (id) => over.links ?? [],
|
|
41
|
+
// #524 方向 2 的存储读法:按会话列窗口(store 侧 GROUP BY 成一行一窗口)。
|
|
42
|
+
distillWindowsFor: (sessionId, limit) => over.windows ?? [],
|
|
43
|
+
distillRecordsFor: (sessionId, from, to) => over.records ?? [],
|
|
44
|
+
...over.service
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- 入参:两个互斥主键 + limit(#524)---------------------------------------
|
|
49
|
+
// 方向 2 复用同一条路径,于是「哪个主键说了算」必须是个**显式**判断:都缺是请求
|
|
50
|
+
// 不成立(400 missing-id),都给也要 400——择一意味着调用方发一个自己也不知道会
|
|
51
|
+
// 读哪个主键的请求,而两个方向的结果形状不同(memoryId / sessionId 各有自己的键)。
|
|
52
|
+
|
|
53
|
+
test("parseProvenanceSelector:两个主键都缺 → 400 missing-id", () => {
|
|
54
|
+
for (const params of [
|
|
55
|
+
{}, { memoryId: null, sessionId: null }, { memoryId: "", sessionId: "" },
|
|
56
|
+
{ memoryId: " ", sessionId: "\t" }
|
|
57
|
+
]) {
|
|
58
|
+
const r = parseProvenanceSelector(params);
|
|
59
|
+
assert.equal(r.ok, false, `${JSON.stringify(params)} 不该通过`);
|
|
60
|
+
assert.equal(r.status, 400);
|
|
61
|
+
assert.equal(r.error, "missing-id");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("parseProvenanceSelector:两个主键都给 → 400 ambiguous-id(不静默择一)", () => {
|
|
66
|
+
const r = parseProvenanceSelector({ memoryId: "m1", sessionId: "s1" });
|
|
67
|
+
assert.equal(r.ok, false);
|
|
68
|
+
assert.equal(r.status, 400);
|
|
69
|
+
assert.equal(r.error, "ambiguous-id");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("parseProvenanceSelector:两端空白裁掉,方向按哪个主键非空确定,limit 缺省 20", () => {
|
|
73
|
+
assert.deepEqual(parseProvenanceSelector({ memoryId: " m1 " }), {
|
|
74
|
+
ok: true,
|
|
75
|
+
selector: { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS }
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(parseProvenanceSelector({ sessionId: " s1 ", memoryId: " " }), {
|
|
78
|
+
ok: true,
|
|
79
|
+
selector: { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS }
|
|
80
|
+
}, "另一侧是空白串时按缺省处理,不算「都给」");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("parseProvenanceLimit:只收十进制整数字面量,畸形与越界都算 400", () => {
|
|
84
|
+
assert.equal(parseProvenanceLimit(null).limit, PROVENANCE_DEFAULT_WINDOWS, "不带就是缺省");
|
|
85
|
+
assert.equal(parseProvenanceLimit("1").limit, 1);
|
|
86
|
+
assert.equal(parseProvenanceLimit(String(PROVENANCE_MAX_WINDOWS)).limit, PROVENANCE_MAX_WINDOWS);
|
|
87
|
+
// 两端空白是**裁掉**而不是判畸形——与 id 的处理保持一致(" 12 " 就是 12)。
|
|
88
|
+
// 但裁完必须是纯十进制字面量。
|
|
89
|
+
assert.equal(parseProvenanceLimit(" 12 ").limit, 12, "两端空白裁掉,与 id 一致");
|
|
90
|
+
// 下面这些如果走 Number(),"" / " " 会化成 0 个窗口,十六进制与指数写法也会被
|
|
91
|
+
// 静默接受——一个畸形输入拿到最坏解释。所以这里逐个钉住。
|
|
92
|
+
for (const raw of [
|
|
93
|
+
"", " ", "0", "-1", "1.5", "1e3", "0x10", "abc", "+1", "12abc",
|
|
94
|
+
String(PROVENANCE_MAX_WINDOWS + 1), "99999999999999999999"
|
|
95
|
+
]) {
|
|
96
|
+
const r = parseProvenanceLimit(raw);
|
|
97
|
+
assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
|
|
98
|
+
assert.equal(r.status, 400);
|
|
99
|
+
assert.equal(r.error, "invalid-limit");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseProvenanceSelector:limit 畸形时连着主键一起 400(入参形状先于功能开关)", () => {
|
|
104
|
+
const r = parseProvenanceSelector({ sessionId: "s1", limit: "abc" });
|
|
105
|
+
assert.equal(r.ok, false);
|
|
106
|
+
assert.equal(r.status, 400);
|
|
107
|
+
assert.equal(r.error, "invalid-limit");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => {
|
|
111
|
+
const seen = [];
|
|
112
|
+
const service = {
|
|
113
|
+
provenanceFor: () => [{ session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-10-23T00:00:00Z" }],
|
|
114
|
+
distillRecordsFor: (sessionId, from, to) => {
|
|
115
|
+
seen.push([sessionId, from, to]);
|
|
116
|
+
return [{ seq: 12 }, { seq: 10 }, { seq: 11 }];
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
120
|
+
assert.equal(r.status, 200);
|
|
121
|
+
assert.equal(r.body.memoryId, "m1");
|
|
122
|
+
assert.equal(r.body.windows.length, 1);
|
|
123
|
+
const w = r.body.windows[0];
|
|
124
|
+
assert.equal(w.session_id, "s1");
|
|
125
|
+
assert.equal(w.seq_start, 10);
|
|
126
|
+
assert.equal(w.seq_end, 12);
|
|
127
|
+
assert.equal(w.created_at, "2026-10-23T00:00:00Z");
|
|
128
|
+
assert.deepEqual(w.records.map((x) => x.seq), [12, 10, 11], "次序由 store 的 ORDER BY seq 保证,答复层不改序");
|
|
129
|
+
assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("provenanceReply #512:窗口带得动归属的那一轮 run,读不到就整组缺省而不是猜", () => {
|
|
133
|
+
const runs = new Map([["r1", { run_type: "autoSummarize", trigger_source: "autoSummarize", status: "ok" }]]);
|
|
134
|
+
const service = {
|
|
135
|
+
provenanceFor: () => [
|
|
136
|
+
{ session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r1" },
|
|
137
|
+
// 改造之前写下的链接:distill_run_id 是 NULL。
|
|
138
|
+
{ session_id: "s1", seq_start: 20, seq_end: 22, created_at: "2026-09-01T00:00:00Z", distill_run_id: null }
|
|
139
|
+
],
|
|
140
|
+
distillRecordsFor: () => [],
|
|
141
|
+
getDistillRun: (id) => runs.get(id),
|
|
142
|
+
mintsFor: () => [{ run_id: "d1", run_type: "dream", trigger_source: "autoDream", created_at: "2026-11-01T00:00:00Z" }]
|
|
143
|
+
};
|
|
144
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
145
|
+
assert.equal(r.status, 200);
|
|
146
|
+
const [fresh, legacy] = r.body.windows;
|
|
147
|
+
assert.equal(fresh.distill_run_id, "r1");
|
|
148
|
+
assert.equal(fresh.run_type, "autoSummarize");
|
|
149
|
+
assert.equal(fresh.trigger_source, "autoSummarize");
|
|
150
|
+
assert.equal(fresh.run_status, "ok");
|
|
151
|
+
// 历史链接:id 为 null,三个派生字段也必须是 null——不许拿区间或时间戳反推一轮。
|
|
152
|
+
assert.equal(legacy.distill_run_id, null);
|
|
153
|
+
assert.equal(legacy.run_type, null, "没有 run 就不给类型,否则等于替用户编了一次归因");
|
|
154
|
+
assert.equal(legacy.trigger_source, null);
|
|
155
|
+
assert.equal(legacy.run_status, null);
|
|
156
|
+
assert.equal(r.body.mints.length, 1);
|
|
157
|
+
assert.equal(r.body.mints[0].run_type, "dream");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("provenanceReply #512:service 没有 run 读取能力时,窗口字段照旧齐全但归因为空", () => {
|
|
161
|
+
// headless / 旧装配:provenanceFor 与 distillRecordsFor 在,getDistillRun / mintsFor 不在。
|
|
162
|
+
const service = {
|
|
163
|
+
provenanceFor: () => [{ session_id: "s1", seq_start: 1, seq_end: 2, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r9" }],
|
|
164
|
+
distillRecordsFor: () => []
|
|
165
|
+
};
|
|
166
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
167
|
+
assert.equal(r.status, 200, "读 run 的能力缺一档不算装配坏——窗口本身仍可读");
|
|
168
|
+
assert.equal(r.body.windows[0].distill_run_id, "r9");
|
|
169
|
+
assert.equal(r.body.windows[0].run_type, null);
|
|
170
|
+
assert.deepEqual(r.body.mints, []);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
|
|
174
|
+
const r = provenanceReply({ service: fakeService(), selector: MEM, config: RECORDS_ON });
|
|
175
|
+
assert.equal(r.status, 200);
|
|
176
|
+
// #524 的 truncated 与 #512 的 mints 都是答复级读数,各自独立缺省。
|
|
177
|
+
assert.deepEqual(r.body, { memoryId: "m1", windows: [], truncated: false, mints: [] });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
|
|
181
|
+
// headless / 旧装配:service 上没有这两个方法。
|
|
182
|
+
const r = provenanceReply({ service: {}, selector: MEM, config: RECORDS_ON });
|
|
183
|
+
assert.equal(r.status, 503);
|
|
184
|
+
assert.equal(r.body.error, PROVENANCE_UNAVAILABLE);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用方", () => {
|
|
188
|
+
const warnings = [];
|
|
189
|
+
const service = {
|
|
190
|
+
provenanceFor: () => { throw new Error("db locked"); },
|
|
191
|
+
distillRecordsFor: () => []
|
|
192
|
+
};
|
|
193
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON, warn: (m) => warnings.push(m) });
|
|
194
|
+
assert.equal(r.status, 500);
|
|
195
|
+
assert.equal(r.body.error, "internal");
|
|
196
|
+
assert.equal(warnings.length, 1, "失败要留痕");
|
|
197
|
+
assert.match(warnings[0], /memory provenance read failed/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- distillRecords.enabled 门(#509 评审整改)--------------------------------
|
|
201
|
+
// 写侧(src/summarize.js:988)早就吃这个开关,读出口此前一处都不读:关着时历史
|
|
202
|
+
// 原文照旧留在库里、还多一个能读出去的口。门加在共用模块里,两个壳都吃它。
|
|
203
|
+
|
|
204
|
+
test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail-closed)", () => {
|
|
205
|
+
assert.equal(isDistillRecordsEnabled({ distillRecords: { enabled: true } }), true);
|
|
206
|
+
for (const cfg of [
|
|
207
|
+
null, undefined, {}, { distillRecords: null }, { distillRecords: {} },
|
|
208
|
+
{ distillRecords: { enabled: false } },
|
|
209
|
+
// 非布尔值:字符串 / 数字都不该被当成开——那是配置写错了,不是用户开了
|
|
210
|
+
{ distillRecords: { enabled: "true" } },
|
|
211
|
+
{ distillRecords: { enabled: 1 } }
|
|
212
|
+
]) {
|
|
213
|
+
assert.equal(isDistillRecordsEnabled(cfg), false, `${JSON.stringify(cfg)} 必须算关`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("provenanceReply:开关没开 → 409 distill-records-disabled,一个键都不多带", () => {
|
|
218
|
+
// config 整个缺失(旧调用方直连、daemon 最小 cfg)也走这一格:默认方向必须是关。
|
|
219
|
+
for (const cfg of [null, undefined, {}, { distillRecords: { enabled: false } }]) {
|
|
220
|
+
const r = provenanceReply({ service: fakeService(), selector: MEM, config: cfg });
|
|
221
|
+
assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`);
|
|
222
|
+
assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED },
|
|
223
|
+
"body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」");
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => {
|
|
228
|
+
// 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。
|
|
229
|
+
const r = provenanceReply({ service: {}, selector: MEM, config: { distillRecords: { enabled: false } } });
|
|
230
|
+
assert.equal(r.status, 409);
|
|
231
|
+
assert.equal(r.body.error, DISTILL_RECORDS_DISABLED);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)", () => {
|
|
235
|
+
// 门要在读之前:关着还被调用方当成「能读」是次要的,白跑一趟库才是真浪费。
|
|
236
|
+
let touched = 0;
|
|
237
|
+
const service = fakeService({ service: {
|
|
238
|
+
provenanceFor: () => { touched++; return []; },
|
|
239
|
+
distillRecordsFor: () => { touched++; return []; }
|
|
240
|
+
} });
|
|
241
|
+
provenanceReply({ service, selector: MEM, config: null });
|
|
242
|
+
assert.equal(touched, 0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("provenanceReply:开关开着才读得到——200 与 409 是同一把门的两个方向", () => {
|
|
246
|
+
const service = fakeService({ links: [{ session_id: "s1", seq_start: 1, seq_end: 1, created_at: "t" }], records: [{ seq: 1 }] });
|
|
247
|
+
const on = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
248
|
+
assert.equal(on.status, 200);
|
|
249
|
+
assert.equal(on.body.windows.length, 1);
|
|
250
|
+
const off = provenanceReply({ service, selector: MEM, config: { distillRecords: { enabled: false } } });
|
|
251
|
+
assert.equal(off.status, 409);
|
|
252
|
+
assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// --- 方向 2:会话 → 窗口(#524)--------------------------------------------
|
|
256
|
+
|
|
257
|
+
test("provenanceReply(session 方向):一行一窗口,原文按区间取,答复带 sessionId", () => {
|
|
258
|
+
const asked = [];
|
|
259
|
+
const service = {
|
|
260
|
+
distillWindowsFor: (sid, limit) => {
|
|
261
|
+
asked.push([sid, limit]);
|
|
262
|
+
return [
|
|
263
|
+
{ session_id: "s1", seq_start: 5, seq_end: 9, created_at: "t2", link_count: 2 },
|
|
264
|
+
{ session_id: "s1", seq_start: 1, seq_end: 4, created_at: "t1", link_count: 1 }
|
|
265
|
+
];
|
|
266
|
+
},
|
|
267
|
+
distillRecordsFor: (sid, from, to) => [{ seq: from }, { seq: to }]
|
|
268
|
+
};
|
|
269
|
+
const r = provenanceReply({ service, selector: SES, config: RECORDS_ON });
|
|
270
|
+
assert.equal(r.status, 200);
|
|
271
|
+
assert.equal(r.body.sessionId, "s1", "session 方向的答复用 sessionId 认领,不冒充 memoryId");
|
|
272
|
+
assert.equal(r.body.truncated, false);
|
|
273
|
+
assert.deepEqual(r.body.windows.map((w) => [w.seq_start, w.seq_end]), [[5, 9], [1, 4]],
|
|
274
|
+
"次序由 store 的 ORDER BY seq_start 保证,答复层不改序");
|
|
275
|
+
assert.deepEqual(r.body.windows[0].records.map((x) => x.seq), [5, 9], "每个窗口装自己区间的原文");
|
|
276
|
+
assert.equal(r.body.windows[0].link_count, 2, "同一窗口派生了 2 条记忆——这个计数是「按窗口看」的第二维");
|
|
277
|
+
// 多取一行判截断:store 侧只照 limit 取,不替调用方判断「还有没有」。
|
|
278
|
+
assert.deepEqual(asked, [["s1", PROVENANCE_DEFAULT_WINDOWS + 1]]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("provenanceReply:撞上 limit 时 truncated=true,且多出来的那条不许漏出去", () => {
|
|
282
|
+
// 少了这个读数,被截断的答复与「本来就只有这么多」逐字节相同——调用方会把半份
|
|
283
|
+
// 转录当成全部,这正是这张脸最容易骗人的地方。
|
|
284
|
+
const service = {
|
|
285
|
+
distillWindowsFor: () => [1, 2, 3].map((n) => ({
|
|
286
|
+
session_id: "s1", seq_start: n, seq_end: n, created_at: "t", link_count: 1
|
|
287
|
+
})),
|
|
288
|
+
distillRecordsFor: () => []
|
|
289
|
+
};
|
|
290
|
+
const r = provenanceReply({ service, selector: { ...SES, limit: 2 }, config: RECORDS_ON });
|
|
291
|
+
assert.equal(r.status, 200);
|
|
292
|
+
assert.equal(r.body.truncated, true);
|
|
293
|
+
assert.equal(r.body.windows.length, 2, "limit=2 就只许回 2 个窗口,探针那一条不能混进答复");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("provenanceReply:memory 方向也带 truncated(两个方向共一个读数)", () => {
|
|
297
|
+
const service = {
|
|
298
|
+
provenanceFor: () => [1, 2].map((n) => ({
|
|
299
|
+
session_id: "s1", seq_start: n, seq_end: n, created_at: "t"
|
|
300
|
+
})),
|
|
301
|
+
distillRecordsFor: () => []
|
|
302
|
+
};
|
|
303
|
+
const r = provenanceReply({ service, selector: { ...MEM, limit: 1 }, config: RECORDS_ON });
|
|
304
|
+
assert.equal(r.status, 200);
|
|
305
|
+
assert.equal(r.body.truncated, true);
|
|
306
|
+
assert.equal(r.body.windows.length, 1);
|
|
307
|
+
assert.equal(r.body.memoryId, "m1");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("provenanceReply:503 按方向判——只缺 session 那侧的方法时,memory 查询照旧 200", () => {
|
|
311
|
+
// 把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署。
|
|
312
|
+
const onlyMemory = {
|
|
313
|
+
provenanceFor: () => [],
|
|
314
|
+
distillRecordsFor: () => []
|
|
315
|
+
};
|
|
316
|
+
const ses = provenanceReply({ service: onlyMemory, selector: SES, config: RECORDS_ON });
|
|
317
|
+
assert.equal(ses.status, 503);
|
|
318
|
+
assert.equal(ses.body.error, PROVENANCE_UNAVAILABLE);
|
|
319
|
+
const mem = provenanceReply({ service: onlyMemory, selector: MEM, config: RECORDS_ON });
|
|
320
|
+
assert.equal(mem.status, 200, "缺的只是方向 2 的方法,方向 1 不该跟着坏");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("provenanceReply:开关关着时 session 方向也 409,且一次 SQL 都不发", () => {
|
|
324
|
+
let touched = 0;
|
|
325
|
+
const service = {
|
|
326
|
+
distillWindowsFor: () => { touched++; return []; },
|
|
327
|
+
distillRecordsFor: () => { touched++; return []; }
|
|
328
|
+
};
|
|
329
|
+
const r = provenanceReply({ service, selector: SES, config: { distillRecords: { enabled: false } } });
|
|
330
|
+
assert.equal(r.status, 409);
|
|
331
|
+
assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED }, "方向不同,门是同一把");
|
|
332
|
+
assert.equal(touched, 0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => {
|
|
336
|
+
// #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。
|
|
337
|
+
for (const name of ["api.js", "api-standalone.js"]) {
|
|
338
|
+
const src = readFileSync(join(ROOT, "src", name), "utf8");
|
|
339
|
+
assert.ok(src.includes("provenance-http.js"), `src/${name} 必须从共用模块取口径`);
|
|
340
|
+
assert.ok(src.includes("provenanceReply("), `src/${name} 要真的走共用答复`);
|
|
341
|
+
assert.ok(!src.includes("provenance-unavailable"), `src/${name} 不准自己写错误码字面量`);
|
|
342
|
+
assert.ok(!src.includes("distill-records-disabled"), `src/${name} 不准自己写「功能关了」的错误码字面量`);
|
|
343
|
+
// 判据也不许重写:两个壳各自算一遍 config?.distillRecords?.enabled === true,
|
|
344
|
+
// 就是读写两道门漂移的形状(本文件上面的注释写过这笔账)。
|
|
345
|
+
assert.ok(!src.includes("distillRecords?.enabled === true"),
|
|
346
|
+
`src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`);
|
|
347
|
+
// 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。
|
|
348
|
+
assert.match(src, /provenanceReply\(\{ service, selector: parsed\.selector, config,/,
|
|
349
|
+
`src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("🔴 静态锁:读侧判据与写侧 src/summarize.js 逐字同源", () => {
|
|
354
|
+
// 「读写两道门永不分歧」不能只活在注释里:写侧那句话在 summarize.js,读侧在本
|
|
355
|
+
// 模块,两边必须是同一个字符串。谁改了其中一边,这里转红。
|
|
356
|
+
const shared = readFileSync(join(ROOT, "src", "provenance-http.js"), "utf8");
|
|
357
|
+
const writeSide = readFileSync(join(ROOT, "src", "summarize.js"), "utf8");
|
|
358
|
+
const readSide = readFileSync(join(ROOT, "src", "index.js"), "utf8");
|
|
359
|
+
const criterion = "config?.distillRecords?.enabled === true";
|
|
360
|
+
assert.ok(shared.includes(criterion), "共用模块要写判据(读侧唯一一份)");
|
|
361
|
+
assert.ok(writeSide.includes(criterion), "写侧 summarize.js 要还在用同一个判据");
|
|
362
|
+
// 读侧只准有一份:两个壳各写一遍的漂移形状,静态锁上一节已经禁了壳,这里禁
|
|
363
|
+
// 共用模块自己长出第二份。
|
|
364
|
+
assert.equal(shared.split(criterion).length - 1, 1, "共用模块里判据只许出现一次");
|
|
365
|
+
// 写侧那一处(启动期裁剪)与它同源,所以三处各一份、彼此逐字相同。
|
|
366
|
+
assert.ok(readSide.includes("cfg.distillRecords?.enabled === true"), "index.js 的启动裁剪判据应保持同族");
|
|
367
|
+
});
|
|
@@ -66,3 +66,48 @@ test("隐私节必须写出「巩固与摘要走宿主模型」这个出站事
|
|
|
66
66
|
"English Privacy section should disclose that consolidation uses the host model",
|
|
67
67
|
);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
// 「模型工具」表是代码的投影,不是愿望清单(AGENTS.md 给 SECURITY.md 定的那条规矩,
|
|
71
|
+
// 这张表一直没有)。它已经跑偏两次:#471 加 set_scope_state、#231 加 memory_organize,
|
|
72
|
+
// 两次都没人回头改表或改那个数字——因为没人核过。所以这里全部**从代码反推**:
|
|
73
|
+
// 工具集从 tools.js 的 name: "…" 取,opt-in 从 hiddenTools 的 `!== true` 判据取
|
|
74
|
+
// (`=== true` 那种是「默认在、用户可关」,不能算进 opt-in,否则默认注册数会算少)。
|
|
75
|
+
test("包 README 的「模型工具」表与 tools.js 注册表一致", () => {
|
|
76
|
+
const toolsSrc = readFileSync(path.join(pkgRoot, "src/tools.js"), "utf8");
|
|
77
|
+
const declared = [...toolsSrc.matchAll(/^ {6}name: "((?:memory_|set_scope)[a-z_]*)",/gm)].map((m) => m[1]);
|
|
78
|
+
assert.ok(declared.length >= 10, `tools.js 的工具名解析失败(只看到 ${declared.length} 个)——先修这条守卫,别改表`);
|
|
79
|
+
const optIn = [...toolsSrc.matchAll(/if \(config\?\.(\w+) !== true\) hiddenTools\.add\("([a-z_]+)"\)/g)]
|
|
80
|
+
.map((m) => ({ key: m[1], tool: m[2] }));
|
|
81
|
+
const defaultCount = declared.length - optIn.length;
|
|
82
|
+
|
|
83
|
+
const heading = pkgReadme.match(/^### 模型工具(([^)]*))$/m);
|
|
84
|
+
assert.ok(heading, "包 README 应有「### 模型工具(N 个)」这一节标题(标题格式变了就同步改这条守卫)");
|
|
85
|
+
const bodyStart = pkgReadme.indexOf(heading[0]);
|
|
86
|
+
const bodyRest = pkgReadme.slice(bodyStart + heading[0].length);
|
|
87
|
+
const nextSection = bodyRest.search(/\r?\n#{2,3} /);
|
|
88
|
+
const body = nextSection === -1 ? bodyRest : bodyRest.slice(0, nextSection);
|
|
89
|
+
const rows = [...body.matchAll(/^\| `([a-z_]+)` \|/gm)].map((m) => m[1]);
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
[...new Set(declared)].sort(),
|
|
93
|
+
[...new Set(rows)].sort(),
|
|
94
|
+
"表与注册表必须一一对应:少了是漏文档,多了是文档写了不存在的东西"
|
|
95
|
+
);
|
|
96
|
+
assert.equal(rows.length, new Set(rows).size, "表里不许有重复行(重复行会让数量核对失效)");
|
|
97
|
+
// 按**顺序**比数字,不是各自 includes:把「默认注册 11 个;开后 12 个」写成
|
|
98
|
+
// 「默认 12、开后 11」时,两个独立的 includes 都会绿——而那正是这条守卫要防的
|
|
99
|
+
// 第二种错法(第一种是数字与表不符,已被上面的集合比对盖住)。
|
|
100
|
+
const nums = (heading[1].match(/\d+/g) ?? []).map(Number);
|
|
101
|
+
const expected = optIn.length ? [defaultCount, declared.length] : [defaultCount];
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
nums,
|
|
104
|
+
expected,
|
|
105
|
+
`标题里的数字要按序是「默认注册数、全数」= ${expected.join(" 与 ")}(实跑 ${nums.join(" 与 ") || "没有数字"})`
|
|
106
|
+
);
|
|
107
|
+
// opt-in 工具那一行必须点名它的开关:读者看到表里有它,得知道默认是不出现的。
|
|
108
|
+
for (const { key, tool } of optIn) {
|
|
109
|
+
const row = body.match(new RegExp(`^\\| \`${tool}\` \\|(.*)$`, "m"));
|
|
110
|
+
assert.ok(row, `${tool} 必须在表里`);
|
|
111
|
+
assert.ok(row[1].includes(key), `${tool} 那行要写明它需要 ${key} 才注册`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
@@ -72,8 +72,8 @@ function dreamCtx({ onConsolidation, summaryText = "记忆库总览:用户偏
|
|
|
72
72
|
async *stream(options) {
|
|
73
73
|
captured.push(options);
|
|
74
74
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
75
|
-
if (userText.
|
|
76
|
-
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
|
|
75
|
+
if (userText.includes("| type=")) {
|
|
76
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
|
|
77
77
|
} else {
|
|
78
78
|
yield { type: "text-delta", index: 0, text: summaryText };
|
|
79
79
|
}
|
|
@@ -282,7 +282,7 @@ test("issue#9: rejected reasoningEffort retries once without it and still consol
|
|
|
282
282
|
async *stream(options) {
|
|
283
283
|
calls.push(options);
|
|
284
284
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
285
|
-
if (userText.
|
|
285
|
+
if (userText.includes("| type=")) {
|
|
286
286
|
// First attempt forwards reasoningEffort: the provider rejects it.
|
|
287
287
|
if (options.reasoningEffort) {
|
|
288
288
|
throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
|
|
@@ -340,7 +340,7 @@ function sleepCtx(onConsolidation, selection = { provider: "mock", model: "sleep
|
|
|
340
340
|
async *stream(options) {
|
|
341
341
|
captured.push(options);
|
|
342
342
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
343
|
-
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
|
|
343
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
|
|
344
344
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
345
345
|
}
|
|
346
346
|
}
|
|
@@ -355,7 +355,7 @@ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async ()
|
|
|
355
355
|
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
356
356
|
const captured = [];
|
|
357
357
|
const ctx = sleepCtx(
|
|
358
|
-
(userText) =>
|
|
358
|
+
(userText, purpose) => purpose === "sleep-conflict"
|
|
359
359
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
360
360
|
: "[]",
|
|
361
361
|
{ provider: "mock", model: "sleep-model" },
|
|
@@ -378,7 +378,7 @@ test("issue#135: sleep unset effort also resolves to the lowest supported", asyn
|
|
|
378
378
|
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
379
379
|
const captured = [];
|
|
380
380
|
const ctx = sleepCtx(
|
|
381
|
-
(userText) =>
|
|
381
|
+
(userText, purpose) => purpose === "sleep-conflict"
|
|
382
382
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
383
383
|
: "[]",
|
|
384
384
|
{ provider: "mock", model: "sleep-model" },
|
|
@@ -433,7 +433,7 @@ test("rc.1 stream-level effort rejection (error finish chunk) also triggers the
|
|
|
433
433
|
return;
|
|
434
434
|
}
|
|
435
435
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
436
|
-
if (userText.
|
|
436
|
+
if (userText.includes("| type=")) {
|
|
437
437
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
438
438
|
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
|
|
439
439
|
]) };
|
|
@@ -500,7 +500,7 @@ test("sleep passes the stream failure accessor so a stream-level effort rejectio
|
|
|
500
500
|
return;
|
|
501
501
|
}
|
|
502
502
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
503
|
-
yield { type: "text-delta", index: 0, text:
|
|
503
|
+
yield { type: "text-delta", index: 0, text: options.purpose === "sleep-conflict"
|
|
504
504
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
505
505
|
: "[]" };
|
|
506
506
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
@@ -625,7 +625,7 @@ test("defaultEffort trap: capability query failure falls back to configured effo
|
|
|
625
625
|
throw new Error("UNSUPPORTED_REASONING_EFFORT: mock does not support reasoning effort \"high\"");
|
|
626
626
|
}
|
|
627
627
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
628
|
-
if (userText.
|
|
628
|
+
if (userText.includes("| type=")) {
|
|
629
629
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
630
630
|
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
|
|
631
631
|
]) };
|
|
@@ -655,7 +655,7 @@ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async
|
|
|
655
655
|
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
656
656
|
const captured = [];
|
|
657
657
|
const ctx = sleepCtx(
|
|
658
|
-
(userText) =>
|
|
658
|
+
(userText, purpose) => purpose === "sleep-conflict"
|
|
659
659
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
660
660
|
: "[]",
|
|
661
661
|
{ provider: "mock", model: "sleep-model" },
|
|
@@ -51,7 +51,13 @@ test("根 README 的两段式路线图能插进新 minor 占位行(旧实现
|
|
|
51
51
|
test("真实根 README 的中英两张路线图各插一行(同一版本号,不能只插一张)", () => {
|
|
52
52
|
// 两张表版本行完全一样,旧实现把全文件版本行合成一个数组判断「已存在」,
|
|
53
53
|
// 于是先插的中文表会让英文表被跳过——英文读者永远等不到那一行。
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// 目标版本**不能写死**:写死当次要发的版本,这条用例在 release-prep 分支上必红——那一行
|
|
56
|
+
// 恰好已经由发版准备脚本插进去了,「各插一行」于是退化成零插入。取比现存的最高 major 再高一档,
|
|
57
|
+
// 断言就与「这次发的是哪一版」彻底无关,也不会在下一个 minor 上重新长成定时炸弹。
|
|
58
|
+
const maxMajor = Math.max(...scanReleaseRows(rootReadme).two.map((r) => Number(r.ver.split(".")[0])));
|
|
59
|
+
const target = `${maxMajor + 1}.0`;
|
|
60
|
+
const { actions } = syncReadmeTables(rootReadme, target, "2026-11-01");
|
|
55
61
|
const inserts = actions.filter((a) => a.kind === "insert");
|
|
56
62
|
assert.equal(inserts.length, 2, "中英路线图各一行,实际:" + JSON.stringify(actions));
|
|
57
63
|
});
|