@modusensus/dsh-mneme 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { withMaxTokensFallback, parseMaxTokensCap, MAX_TOKENS_REJECT_RE } from "../src/dream.js";
4
+
5
+ // Issue #478:dreamMaxTokens 默认 131072 原样发给 OpenAI 兼容网关,很多网关硬
6
+ // 上限更小(实测 dream_runs 180 连败的报错:"max_tokens 不能超过 65536"),
7
+ // 400 直接拒、推理没开始。本套锁两件事:上限提取认得出显式上限、认不出时不猜;
8
+ // withMaxTokensFallback 在 stream(result===undefined + getStreamError)与
9
+ // throw 两条路径上都按提取到的上限降级重试恰一次,AbortError 永不被降级吞掉。
10
+
11
+ function makeLogger() {
12
+ const warns = [];
13
+ return { warns, logger: { warn: (...a) => warns.push(a.join(" ")), info() {}, error() {} } };
14
+ }
15
+
16
+ test("parseMaxTokensCap: 显式上限认得出(中/英/符号三种说法)", () => {
17
+ assert.equal(parseMaxTokensCap("max_tokens 不能超过 65536"), 65536);
18
+ assert.equal(parseMaxTokensCap("Maximum value is 65536"), 65536);
19
+ assert.equal(parseMaxTokensCap("max_tokens must be at most 32768"), 32768);
20
+ assert.equal(parseMaxTokensCap("max_tokens <= 8192"), 8192);
21
+ assert.equal(parseMaxTokensCap("max_tokens 不得超过 4096"), 4096);
22
+ });
23
+
24
+ test("parseMaxTokensCap: 认不出显式上限时返回 null,绝不猜", () => {
25
+ // 只有请求值、没有上限值——把 131072 误当上限会让重试原样再炸一次
26
+ assert.equal(parseMaxTokensCap("max_tokens 131072 is too large for this model"), null);
27
+ assert.equal(parseMaxTokensCap("bad request"), null);
28
+ assert.equal(parseMaxTokensCap(""), null);
29
+ assert.equal(parseMaxTokensCap(null), null);
30
+ });
31
+
32
+ test("MAX_TOKENS_REJECT_RE: 命中 max_tokens/maxTokens 两种写法,不误伤无关报错", () => {
33
+ assert.ok(MAX_TOKENS_REJECT_RE.test("max_tokens 不能超过 65536"));
34
+ assert.ok(MAX_TOKENS_REJECT_RE.test("maxTokens exceed limit"));
35
+ assert.ok(!MAX_TOKENS_REJECT_RE.test("reasoning effort unsupported"));
36
+ });
37
+
38
+ test("withMaxTokensFallback: stream 路径(undefined + getStreamError)按上限降级重试恰一次", async () => {
39
+ const { logger, warns } = makeLogger();
40
+ const ctx = { logger };
41
+ const calls = [];
42
+ const attempt = async (tokens) => {
43
+ calls.push(tokens);
44
+ // 第一次发大值 → 网关拒收折叠成 undefined;重试用 65536 → 成功
45
+ return tokens > 65536 ? undefined : `ok@${tokens}`;
46
+ };
47
+ const out = await withMaxTokensFallback(ctx, 131072, attempt, () =>
48
+ "400 max_tokens 不能超过 65536");
49
+ assert.equal(out, "ok@65536");
50
+ assert.deepEqual(calls, [131072, 65536], "大值先发,拒收后按上限重试");
51
+ assert.equal(warns.length, 1, "降级必须留痕");
52
+ assert.match(warns[0], /131072.*65536/);
53
+ });
54
+
55
+ test("withMaxTokensFallback: throw 路径同样降级,AbortError 永远直通", async () => {
56
+ const { logger } = makeLogger();
57
+ const ctx = { logger };
58
+ // throw 路径:第一次抛 400,重试成功
59
+ const calls = [];
60
+ const out = await withMaxTokensFallback(ctx, 131072, async (tokens) => {
61
+ calls.push(tokens);
62
+ if (tokens > 65536) throw new Error("invalid max_tokens: max_tokens 不能超过 65536");
63
+ return `ok@${tokens}`;
64
+ }, () => "");
65
+ assert.equal(out, "ok@65536");
66
+ assert.deepEqual(calls, [131072, 65536]);
67
+
68
+ // AbortError:即使消息里带 max_tokens 字样也绝不触发重试(取消意图不可吞)
69
+ const abort = new Error("max_tokens rejected during dispose");
70
+ abort.name = "AbortError";
71
+ const abCalls = [];
72
+ await assert.rejects(
73
+ withMaxTokensFallback(ctx, 131072, async (tokens) => { abCalls.push(tokens); throw abort; }, () => ""),
74
+ /dispose/
75
+ );
76
+ assert.deepEqual(abCalls, [131072], "AbortError 不重试");
77
+ });
78
+
79
+ test("withMaxTokensFallback: 无上限可提取或上限不小于当前值时不重试", async () => {
80
+ const { logger } = makeLogger();
81
+ const ctx = { logger };
82
+ // 报错只有请求值没有上限 → 不猜,原样 undefined 返回
83
+ let calls = 0;
84
+ const out1 = await withMaxTokensFallback(ctx, 131072, async () => { calls++; return undefined; }, () =>
85
+ "max_tokens 131072 is too large for this model");
86
+ assert.equal(out1, undefined);
87
+ assert.equal(calls, 1, "认不出上限就不重试");
88
+
89
+ // 上限 >= 当前值(报错自相矛盾)→ 不重试,throw 路径原样抛
90
+ const boom = new Error("max_tokens 不能超过 131072");
91
+ await assert.rejects(
92
+ withMaxTokensFallback(ctx, 131072, async () => { throw boom; }, () => ""),
93
+ (e) => e === boom,
94
+ "cap >= maxTokens 时不重试且原样抛"
95
+ );
96
+ });
97
+
98
+ test("withMaxTokensFallback: 无关报错不触发重试(stream 与 throw 双路径)", async () => {
99
+ const { logger } = makeLogger();
100
+ const ctx = { logger };
101
+ let calls = 0;
102
+ const out = await withMaxTokensFallback(ctx, 131072, async () => { calls++; return undefined; }, () =>
103
+ "reasoning effort UNSUPPORTED_REASONING_EFFORT");
104
+ assert.equal(out, undefined);
105
+ assert.equal(calls, 1);
106
+
107
+ await assert.rejects(
108
+ withMaxTokensFallback(ctx, 131072, async () => { throw new Error("quota exhausted"); }, () => ""),
109
+ /quota exhausted/
110
+ );
111
+ });
112
+
113
+ // 接线锁:单测绿不代表真接上了。#478 的第一次实现只挂了 dream 侧三个点,
114
+ // sleep.js 两处仍原样发 config.sleepMaxTokens,而面板 hint 已对外承诺
115
+ // 「超上限同样自动降级」——这种「函数有、没人用」的缺口只靠行为测试抓不到。
116
+ test("接线锁:dreamMaxTokens/sleepMaxTokens 的五个使用点全部包在 withMaxTokensFallback 里", async () => {
117
+ const { readFile } = await import("node:fs/promises");
118
+ const srcDir = new URL("../src/", import.meta.url);
119
+ const files = { "dream.js": await readFile(new URL("dream.js", srcDir), "utf8"), "dream/sleep.js": await readFile(new URL("dream/sleep.js", srcDir), "utf8") };
120
+ for (const [name, source] of Object.entries(files)) {
121
+ // 任何一处还把 config.dreamMaxTokens/sleepMaxTokens 直接当 maxTokens 发,就是漏接线。
122
+ assert.ok(
123
+ !/maxTokens:\s*config\.(dream|sleep)MaxTokens/.test(source),
124
+ `${name}: still passes config.*MaxTokens straight into maxTokens — wrap it in withMaxTokensFallback`
125
+ );
126
+ }
127
+ // 五个调用点:dream 三条(叙述/巩固/摘要)+ sleep 两条(冲突/模式)。
128
+ const dreamHits = files["dream.js"].match(/withMaxTokensFallback\(ctx, config\.dreamMaxTokens/g) ?? [];
129
+ const sleepHits = files["dream/sleep.js"].match(/withMaxTokensFallback\(ctx, config\.sleepMaxTokens/g) ?? [];
130
+ assert.equal(dreamHits.length, 3, `dream.js must wrap all 3 dreamMaxTokens sites (got ${dreamHits.length})`);
131
+ assert.equal(sleepHits.length, 2, `dream/sleep.js must wrap both sleepMaxTokens sites (got ${sleepHits.length})`);
132
+ });
@@ -0,0 +1,112 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { embedText, embedTextDetailed, createEmbedder } from "../src/embedding.js";
4
+
5
+ // distill-tx-embed-gap 的"坏了为什么看不出来"半边:embedText 此前把「配置缺」
6
+ // 「网络挂」「超时」「HTTP 4xx/5xx」「响应不是 JSON」「响应没有向量」全部压成
7
+ // null,回执账本只能统一写 embedding-request-returned-no-vector——分诊不可能。
8
+ // embedTextDetailed 把失败模式拆开;embedText 保留旧 null 契约(调用方不变)。
9
+
10
+ const FULL = {
11
+ enabled: true,
12
+ baseUrl: "https://api.example.com/v1",
13
+ apiKey: "sk-test",
14
+ model: "text-embedding-3-small"
15
+ };
16
+
17
+ async function withFetch(impl, fn) {
18
+ const original = globalThis.fetch;
19
+ globalThis.fetch = impl;
20
+ try {
21
+ return await fn();
22
+ } finally {
23
+ globalThis.fetch = original;
24
+ }
25
+ }
26
+
27
+ test("detailed: 成功时 reason 为 null,vector 与旧契约一致", async () => {
28
+ await withFetch(async () => ({ ok: true, json: async () => ({ data: [{ embedding: [1, 2, 3] }] }) }), async () => {
29
+ const out = await embedTextDetailed(FULL, "猫咪");
30
+ assert.deepEqual(out.vector, [1, 2, 3]);
31
+ assert.equal(out.reason, null);
32
+ // 旧包装的 null 契约不变
33
+ assert.deepEqual(await embedText(FULL, "猫咪"), [1, 2, 3]);
34
+ });
35
+ });
36
+
37
+ test("detailed: 配置缺失时逐字段点名,不伪装成网络故障", async () => {
38
+ assert.match((await embedTextDetailed({ ...FULL, model: "" }, "x")).reason, /^config-incomplete:model$/);
39
+ assert.match((await embedTextDetailed({ ...FULL, apiKey: "" }, "x")).reason, /^config-incomplete:apiKey$/);
40
+ assert.match((await embedTextDetailed({ ...FULL, baseUrl: "" }, "x")).reason, /^config-incomplete:baseUrl$/);
41
+ assert.match(await embedTextDetailed(FULL, "").then((o) => o.reason), /config-incomplete:text/);
42
+ });
43
+
44
+ test("detailed: HTTP 状态码进 reason(401 钥匙 / 404 路径 / 429 限流)", async () => {
45
+ await withFetch(async () => ({ ok: false, status: 404, text: async () => "Not Found" }), async () => {
46
+ const out = await embedTextDetailed(FULL, "x");
47
+ assert.equal(out.vector, null);
48
+ assert.equal(out.reason, "http-404:Not Found");
49
+ });
50
+ await withFetch(async () => ({ ok: false, status: 429, text: async () => "" }), async () => {
51
+ const out = await embedTextDetailed(FULL, "x");
52
+ assert.equal(out.reason, "http-429", "空响应体不拼尾巴");
53
+ });
54
+ // 旧包装在这些场景全部 null,不抛
55
+ await withFetch(async () => ({ ok: false, status: 401, text: async () => "denied" }), async () => {
56
+ assert.equal(await embedText(FULL, "x"), null);
57
+ });
58
+ });
59
+
60
+ test("detailed: 超时与网络错误分型,不混为一谈", async () => {
61
+ await withFetch(async () => {
62
+ const err = new Error("socket hang up");
63
+ err.name = "TypeError";
64
+ throw err;
65
+ }, async () => {
66
+ const out = await embedTextDetailed(FULL, "x");
67
+ assert.match(out.reason, /^request-failed:network:socket hang up/);
68
+ });
69
+ await withFetch(async () => {
70
+ const err = new Error("The operation was aborted");
71
+ err.name = "TimeoutError";
72
+ throw err;
73
+ }, async () => {
74
+ const out = await embedTextDetailed(FULL, "x");
75
+ assert.match(out.reason, /^request-failed:timeout:/, "慢和不通是两种病");
76
+ });
77
+ });
78
+
79
+ test("detailed: 响应不是 JSON 与响应没有向量各自有名", async () => {
80
+ await withFetch(async () => ({ ok: true, json: async () => { throw new Error("bad"); } }), async () => {
81
+ const out = await embedTextDetailed(FULL, "x");
82
+ assert.equal(out.reason, "response-not-json");
83
+ });
84
+ await withFetch(async () => ({ ok: true, json: async () => ({ data: [] }) }), async () => {
85
+ const out = await embedTextDetailed(FULL, "x");
86
+ assert.equal(out.reason, "response-no-vector");
87
+ });
88
+ });
89
+
90
+ test("embedFor 回执账本带上可区分 reason(不再是统一的 no-vector)", async () => {
91
+ const receipts = [];
92
+ const embedder = createEmbedder({
93
+ store: { setEmbedding() {} },
94
+ settings: { getVectorConfig: () => FULL },
95
+ logger: { info() {}, warn() {} }
96
+ });
97
+ // embedReceipts 在账本上;rejection 已在 schedule() 内消化,这里直接等 schedule promise。
98
+ await withFetch(async () => ({ ok: false, status: 404, text: async () => "no route" }), async () => {
99
+ const r = await embedder.schedule({ id: "m1", title: "t", content: "c" });
100
+ assert.equal(r.status, "failed");
101
+ assert.equal(r.reason, "http-404:no route");
102
+ });
103
+ await withFetch(async () => ({ ok: true, json: async () => ({ data: [{ embedding: [1, 2, 3] }] }) }), async () => {
104
+ const r = await embedder.schedule({ id: "m2", title: "t", content: "c" });
105
+ assert.equal(r.status, "ok");
106
+ assert.equal(r.dim, 3);
107
+ });
108
+ const snap = embedder.embedReceipts();
109
+ assert.equal(snap.totals.failed, 1);
110
+ assert.equal(snap.totals.ok, 1);
111
+ assert.equal(snap.lastFailReason, "http-404:no route", "账本记的是分诊后的原因");
112
+ });
@@ -0,0 +1,68 @@
1
+ // 两份 README 曾经都写着「默认全离线」「offline by default」「默认零网络依赖」——
2
+ // 这与代码默认值相反:autoDream 与 autoSummarize 默认开(src/config.js),
3
+ // 两者都把记忆正文交给 ctx.llm.stream(...)(src/dream.js / src/summarize.js);
4
+ // embedProvider 默认还是 "openai",只有显式改成 "local"(ONNX)才真正离线。
5
+ // 一句假话比不写更坏:读者会据此以为装完就能断网用,而它默认会把正文发给宿主模型。
6
+ // 所以这里锁两件事:一、绝对离线声明不许回来;二、隐私节必须写出「巩固走宿主模型」这个出站事实——
7
+ // 删掉假话后如果什么都不说,误导是一样的。
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { readFileSync, existsSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import path from "node:path";
13
+
14
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
15
+ const repoRoot = path.dirname(pkgRoot);
16
+ const rootReadmePath = path.join(repoRoot, "README.md");
17
+
18
+ // npm 的 files 清单把整个 test/ 打进包里,所以在 packed / installed 包里跑 npm test 时,
19
+ // 这个文件照样会被执行——但那里没有仓库根 README(只有包内 dsh-mneme/README.md)。
20
+ // 直接 readFileSync 会让整个测试文件在加载阶段 ENOENT 崩掉,把「不是这个仓」报成「断言失败」。
21
+ // 所以仓库根不在时显式 skip:这条守卫的适用面本来就只是仓库,不该在包内假红。
22
+ const hasRepoRoot = existsSync(rootReadmePath);
23
+ const rootReadme = hasRepoRoot ? readFileSync(rootReadmePath, "utf8") : "";
24
+ const pkgReadme = readFileSync(path.join(pkgRoot, "README.md"), "utf8");
25
+ const outsideRepo = hasRepoRoot ? {} : { skip: "not a repo checkout: 仓库根 README 不存在(包内安装态)" };
26
+
27
+ // 只锁「默认 / 无条件」的说法。带条件的离线表述(把 embedProvider 改成 local、离线巩固论文、
28
+ // 运行时离线取件)都是真话,不在这个列表里,否则守卫会把正确写法也判红。
29
+ const ABSOLUTE_CLAIMS = [
30
+ "默认全离线",
31
+ "默认零网络依赖",
32
+ "offline by default",
33
+ "Zero network dependency by default",
34
+ "完全离线的语义记忆引擎",
35
+ ];
36
+
37
+ function section(text, heading) {
38
+ const start = text.indexOf(heading);
39
+ assert.ok(start >= 0, `README 里应有「${heading}」一节`);
40
+ const rest = text.slice(start + heading.length);
41
+ const next = rest.search(/\r?\n## /);
42
+ return next === -1 ? rest : rest.slice(0, next);
43
+ }
44
+
45
+ test("两份 README 都不许再把「离线」写成默认行为", outsideRepo, () => {
46
+ for (const [name, text] of [
47
+ ["README.md", rootReadme],
48
+ ["dsh-mneme/README.md", pkgReadme],
49
+ ]) {
50
+ for (const claim of ABSOLUTE_CLAIMS) {
51
+ assert.ok(!text.includes(claim), `${name} 仍含绝对离线声明:${claim}`);
52
+ }
53
+ }
54
+ });
55
+
56
+ test("隐私节必须写出「巩固与摘要走宿主模型」这个出站事实", outsideRepo, () => {
57
+ const zh = section(rootReadme, "## 隐私承诺");
58
+ assert.ok(zh.includes("宿主模型"), "中文隐私节应说明巩固与摘要走宿主模型");
59
+ assert.ok(
60
+ zh.includes("autoDream") || zh.includes("autoSummarize"),
61
+ "中文隐私节应点名 autoDream / autoSummarize,否则读者不知道是谁在出站",
62
+ );
63
+ const en = section(rootReadme, "## Privacy");
64
+ assert.ok(
65
+ en.includes("host model"),
66
+ "English Privacy section should disclose that consolidation uses the host model",
67
+ );
68
+ });