@modusensus/dsh-mneme 0.7.11 → 0.7.13
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.en.md +69 -0
- package/README.md +227 -21
- package/bin/cli.mjs +603 -0
- package/lib/api-standalone.js +264 -0
- package/lib/api.js +91 -2
- package/lib/client.js +243 -1
- package/lib/config.js +80 -0
- package/lib/index.js +43 -12
- package/lib/quality-filter.js +4 -1
- package/lib/service.js +38 -5
- package/lib/settings.js +40 -0
- package/lib/store.js +4 -1
- package/lib/summarize.js +197 -59
- package/lib/tools.js +3 -3
- package/package.json +7 -1
- package/src/api-standalone.js +264 -0
- package/src/api.js +91 -2
- package/src/config.js +80 -0
- package/src/index.js +43 -12
- package/src/quality-filter.js +4 -1
- package/src/service.js +38 -5
- package/src/settings.js +40 -0
- package/src/store.js +4 -1
- package/src/summarize.js +197 -59
- package/src/tools.js +3 -3
- package/test/api.test.js +44 -0
- package/test/helpers/peer-worker.mjs +10 -0
- package/test/peer-blockers.test.js +4 -9
- package/test/settings.test.js +24 -0
- package/test/standalone-api.test.js +326 -0
- package/test/summarize.test.js +116 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createSettings } from "../src/settings.js";
|
|
6
|
+
import { createStandaloneApi } from "../src/api-standalone.js";
|
|
7
|
+
import { Config, applyLightModePreset } from "../src/config.js";
|
|
8
|
+
|
|
9
|
+
// Real HTTP server on an OS-assigned port (port: 0), driven with fetch.
|
|
10
|
+
async function setup() {
|
|
11
|
+
const store = createStore(":memory:");
|
|
12
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
13
|
+
const settings = createSettings(store.db);
|
|
14
|
+
const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
|
|
15
|
+
await api.ready;
|
|
16
|
+
const base = `http://127.0.0.1:${api.port}`;
|
|
17
|
+
const auth = { authorization: `Bearer ${api.token}` };
|
|
18
|
+
return {
|
|
19
|
+
store,
|
|
20
|
+
service,
|
|
21
|
+
settings,
|
|
22
|
+
api,
|
|
23
|
+
base,
|
|
24
|
+
auth,
|
|
25
|
+
close: () => {
|
|
26
|
+
// Drop pooled keep-alive sockets so node --test can drain the loop.
|
|
27
|
+
api.server.closeIdleConnections?.();
|
|
28
|
+
api.server.close();
|
|
29
|
+
store.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- auth ---------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
test("GET /health is open without a token; every other route 401s", async () => {
|
|
37
|
+
const { base, close } = await setup();
|
|
38
|
+
try {
|
|
39
|
+
const health = await fetch(`${base}/health`);
|
|
40
|
+
assert.equal(health.status, 200);
|
|
41
|
+
assert.deepEqual(await health.json(), { ok: true });
|
|
42
|
+
|
|
43
|
+
for (const path of ["/status", "/memories", "/memories/x", "/search?q=x"]) {
|
|
44
|
+
const res = await fetch(`${base}${path}`);
|
|
45
|
+
assert.equal(res.status, 401, `${path} requires a token`);
|
|
46
|
+
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const bad = await fetch(`${base}/status`, { headers: { authorization: "Bearer wrong-token" } });
|
|
50
|
+
assert.equal(bad.status, 401, "wrong token rejected");
|
|
51
|
+
} finally {
|
|
52
|
+
close();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("token is persisted into settings kv and reused across restarts", async () => {
|
|
57
|
+
const store = createStore(":memory:");
|
|
58
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
59
|
+
const settings = createSettings(store.db);
|
|
60
|
+
const first = createStandaloneApi({ service, store, config: {}, settings, port: 0 });
|
|
61
|
+
await first.ready;
|
|
62
|
+
try {
|
|
63
|
+
assert.equal(settings.getExternalApi().token, first.token, "generated token persisted");
|
|
64
|
+
assert.ok(first.token.length >= 20, "token is a real random value");
|
|
65
|
+
const second = createStandaloneApi({ service, store, config: {}, settings, port: 0 });
|
|
66
|
+
await second.ready;
|
|
67
|
+
assert.equal(second.token, first.token, "restart reuses the persisted token");
|
|
68
|
+
second.server.closeIdleConnections?.();
|
|
69
|
+
second.server.close();
|
|
70
|
+
} finally {
|
|
71
|
+
first.server.closeIdleConnections?.();
|
|
72
|
+
first.server.close();
|
|
73
|
+
store.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- CRUD loop ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test("POST/GET/DELETE /memories round trip", async () => {
|
|
80
|
+
const { base, auth, close } = await setup();
|
|
81
|
+
try {
|
|
82
|
+
const post = await fetch(`${base}/memories`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ type: "preference", title: "语言", content: "始终用中文回复", importance: 4, tags: ["交流"] })
|
|
86
|
+
});
|
|
87
|
+
assert.equal(post.status, 201);
|
|
88
|
+
const created = await post.json();
|
|
89
|
+
assert.ok(created.id, "row id returned");
|
|
90
|
+
assert.equal(created.title, "语言");
|
|
91
|
+
assert.equal(created.importance, 4);
|
|
92
|
+
|
|
93
|
+
const list = await fetch(`${base}/memories?limit=10`, { headers: auth });
|
|
94
|
+
assert.equal(list.status, 200);
|
|
95
|
+
const listBody = await list.json();
|
|
96
|
+
assert.equal(listBody.total, 1);
|
|
97
|
+
assert.equal(listBody.items.length, 1);
|
|
98
|
+
|
|
99
|
+
const got = await fetch(`${base}/memories/${created.id}`, { headers: auth });
|
|
100
|
+
assert.equal(got.status, 200);
|
|
101
|
+
assert.equal((await got.json()).content, "始终用中文回复");
|
|
102
|
+
|
|
103
|
+
const del = await fetch(`${base}/memories/${created.id}`, { method: "DELETE", headers: auth });
|
|
104
|
+
assert.equal(del.status, 200);
|
|
105
|
+
assert.deepEqual(await del.json(), { ok: true });
|
|
106
|
+
|
|
107
|
+
const gone = await fetch(`${base}/memories/${created.id}`, { headers: auth });
|
|
108
|
+
assert.equal(gone.status, 404, "deleted row reads back as 404");
|
|
109
|
+
const delAgain = await fetch(`${base}/memories/${created.id}`, { method: "DELETE", headers: auth });
|
|
110
|
+
assert.equal(delAgain.status, 404, "deleting a missing row is 404, not a fake ok");
|
|
111
|
+
} finally {
|
|
112
|
+
close();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("POST /memories dedupes same title within a type (200 + merged content)", async () => {
|
|
117
|
+
const { base, auth, close } = await setup();
|
|
118
|
+
try {
|
|
119
|
+
const first = await fetch(`${base}/memories`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ type: "project", title: "mneme", content: "v1 内容" })
|
|
123
|
+
});
|
|
124
|
+
assert.equal(first.status, 201);
|
|
125
|
+
const row1 = await first.json();
|
|
126
|
+
|
|
127
|
+
const second = await fetch(`${base}/memories`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ type: "project", title: "mneme", content: "v2 追加" })
|
|
131
|
+
});
|
|
132
|
+
assert.equal(second.status, 200, "merge, not a second creation");
|
|
133
|
+
const row2 = await second.json();
|
|
134
|
+
assert.equal(row2.id, row1.id, "same row reused");
|
|
135
|
+
assert.ok(row2.content.includes("v1 内容") && row2.content.includes("v2 追加"), "content appended");
|
|
136
|
+
|
|
137
|
+
const list = await fetch(`${base}/memories`, { headers: auth });
|
|
138
|
+
assert.equal((await list.json()).total, 1, "no duplicate row created");
|
|
139
|
+
} finally {
|
|
140
|
+
close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("POST /memories validation: invalid type, bad JSON, non-array tags → 400", async () => {
|
|
145
|
+
const { base, auth, close } = await setup();
|
|
146
|
+
try {
|
|
147
|
+
const badType = await fetch(`${base}/memories`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ type: "diary", title: "t", content: "c" })
|
|
151
|
+
});
|
|
152
|
+
assert.equal(badType.status, 400);
|
|
153
|
+
assert.deepEqual(await badType.json(), { error: "invalid-type" });
|
|
154
|
+
|
|
155
|
+
const badJson = await fetch(`${base}/memories`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
158
|
+
body: "{not json"
|
|
159
|
+
});
|
|
160
|
+
assert.equal(badJson.status, 400);
|
|
161
|
+
assert.deepEqual(await badJson.json(), { error: "invalid-json" });
|
|
162
|
+
|
|
163
|
+
const badTags = await fetch(`${base}/memories`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ type: "project", title: "t", content: "c", tags: "x" })
|
|
167
|
+
});
|
|
168
|
+
assert.equal(badTags.status, 400);
|
|
169
|
+
assert.deepEqual(await badTags.json(), { error: "tags-must-be-an-array" });
|
|
170
|
+
|
|
171
|
+
const noTitle = await fetch(`${base}/memories`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ type: "project", content: "c" })
|
|
175
|
+
});
|
|
176
|
+
assert.equal(noTitle.status, 400);
|
|
177
|
+
assert.deepEqual(await noTitle.json(), { error: "missing-title" });
|
|
178
|
+
} finally {
|
|
179
|
+
close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// --- list filters / status / search ---------------------------------------------
|
|
184
|
+
|
|
185
|
+
test("GET /memories honors type + minImportance filters and paging", async () => {
|
|
186
|
+
const { base, auth, service, close } = await setup();
|
|
187
|
+
try {
|
|
188
|
+
service.saveWithDedupe({ type: "preference", title: "低", content: "i2", importance: 2 });
|
|
189
|
+
service.saveWithDedupe({ type: "preference", title: "高", content: "i5", importance: 5 });
|
|
190
|
+
service.saveWithDedupe({ type: "project", title: "项目", content: "i4", importance: 4 });
|
|
191
|
+
|
|
192
|
+
const byType = await fetch(`${base}/memories?type=preference`, { headers: auth });
|
|
193
|
+
assert.equal((await byType.json()).total, 2);
|
|
194
|
+
|
|
195
|
+
const byImportance = await fetch(`${base}/memories?minImportance=4`, { headers: auth });
|
|
196
|
+
const filtered = await byImportance.json();
|
|
197
|
+
assert.equal(filtered.total, 2);
|
|
198
|
+
assert.deepEqual(filtered.items.map((m) => m.title).sort(), ["项目", "高"]);
|
|
199
|
+
|
|
200
|
+
const paged = await fetch(`${base}/memories?limit=2&offset=0&order=chrono`, { headers: auth });
|
|
201
|
+
const page = await paged.json();
|
|
202
|
+
assert.equal(page.items.length, 2);
|
|
203
|
+
assert.equal(page.total, 3);
|
|
204
|
+
} finally {
|
|
205
|
+
close();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("GET /status reports version, per-type totals, entities and uptime", async () => {
|
|
210
|
+
const { base, auth, service, close } = await setup();
|
|
211
|
+
try {
|
|
212
|
+
service.saveWithDedupe({ type: "preference", title: "p", content: "c" });
|
|
213
|
+
service.saveWithDedupe({ type: "project", title: "j", content: "c" });
|
|
214
|
+
service.saveWithDedupe({ type: "project", title: "j2", content: "c" });
|
|
215
|
+
|
|
216
|
+
const res = await fetch(`${base}/status`, { headers: auth });
|
|
217
|
+
assert.equal(res.status, 200);
|
|
218
|
+
const body = await res.json();
|
|
219
|
+
assert.equal(body.version, "0.7.12");
|
|
220
|
+
assert.equal(body.memories.total, 3);
|
|
221
|
+
assert.equal(body.memories.byType.preference, 1);
|
|
222
|
+
assert.equal(body.memories.byType.project, 2);
|
|
223
|
+
assert.equal(body.entities, 0, "no extraction wired → empty entity table");
|
|
224
|
+
assert.ok(Number.isInteger(body.uptime_s));
|
|
225
|
+
} finally {
|
|
226
|
+
close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("GET /search finds saved memories; empty q returns empty keyword result", async () => {
|
|
231
|
+
const { base, auth, close } = await setup();
|
|
232
|
+
try {
|
|
233
|
+
await fetch(`${base}/memories`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
236
|
+
body: JSON.stringify({ type: "project", title: "记忆插件", content: "SQLite 全文检索" })
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const res = await fetch(`${base}/search?q=${encodeURIComponent("全文检索")}&mode=keyword`, { headers: auth });
|
|
240
|
+
assert.equal(res.status, 200);
|
|
241
|
+
const body = await res.json();
|
|
242
|
+
assert.equal(body.mode, "keyword");
|
|
243
|
+
assert.equal(body.items.length, 1);
|
|
244
|
+
assert.equal(body.items[0].title, "记忆插件");
|
|
245
|
+
|
|
246
|
+
const empty = await fetch(`${base}/search?q=`, { headers: auth });
|
|
247
|
+
assert.deepEqual(await empty.json(), { items: [], mode: "keyword" });
|
|
248
|
+
} finally {
|
|
249
|
+
close();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("unknown path returns 404 json", async () => {
|
|
254
|
+
const { base, auth, close } = await setup();
|
|
255
|
+
try {
|
|
256
|
+
const res = await fetch(`${base}/nope`, { headers: auth });
|
|
257
|
+
assert.equal(res.status, 404);
|
|
258
|
+
assert.deepEqual(await res.json(), { error: "not-found" });
|
|
259
|
+
} finally {
|
|
260
|
+
close();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- light-mode preset (applyLightModePreset) ------------------------------------
|
|
265
|
+
|
|
266
|
+
const LIGHT_OFF_FIELDS = [
|
|
267
|
+
"entityExtractionEnabled",
|
|
268
|
+
"autoDream",
|
|
269
|
+
"sleepModeEnabled",
|
|
270
|
+
"rerankEnabled",
|
|
271
|
+
"autoReindexOnBoot",
|
|
272
|
+
"hybridInject",
|
|
273
|
+
"searchSemanticDedup",
|
|
274
|
+
"selectiveInjectEnabled",
|
|
275
|
+
"bm25SearchEnabled"
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
test("applyLightModePreset turns heavy features off and keeps the core loop", () => {
|
|
279
|
+
const cfg = applyLightModePreset(Config({ lightMode: true }));
|
|
280
|
+
for (const field of LIGHT_OFF_FIELDS) {
|
|
281
|
+
assert.equal(cfg[field], false, `${field} forced off in light mode`);
|
|
282
|
+
}
|
|
283
|
+
// Core loop preserved.
|
|
284
|
+
assert.equal(cfg.autoInject, true);
|
|
285
|
+
assert.equal(cfg.autoSummarize, true);
|
|
286
|
+
assert.equal(cfg.hotMemoryEnabled, true);
|
|
287
|
+
assert.equal(cfg.memoryQualityFilter.enabled, true);
|
|
288
|
+
// Unrelated knobs untouched.
|
|
289
|
+
assert.equal(cfg.dreamThresholdCount, 10);
|
|
290
|
+
assert.equal(cfg.dreamDelayMs, 2000);
|
|
291
|
+
assert.equal(cfg.maxInjectedItems, 5);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("applyLightModePreset keeps explicit non-preset values and is a no-op without lightMode", () => {
|
|
295
|
+
const tuned = applyLightModePreset(Config({ lightMode: true, dreamThresholdCount: 30, maxInjectedItems: 8 }));
|
|
296
|
+
assert.equal(tuned.dreamThresholdCount, 30, "operator values survive the preset");
|
|
297
|
+
assert.equal(tuned.maxInjectedItems, 8);
|
|
298
|
+
|
|
299
|
+
const plain = Config({});
|
|
300
|
+
assert.equal(applyLightModePreset(plain), plain, "identity when lightMode is unset");
|
|
301
|
+
const off = Config({ lightMode: false });
|
|
302
|
+
assert.equal(applyLightModePreset(off), off, "identity when lightMode is false");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("persisted panel_mode=light wins over a bundle config that did not ask for it", () => {
|
|
306
|
+
const store = createStore(":memory:");
|
|
307
|
+
const settings = createSettings(store.db);
|
|
308
|
+
try {
|
|
309
|
+
// Panel switched to light in a previous session; bundle config says nothing.
|
|
310
|
+
settings.setPanelMode("light");
|
|
311
|
+
const rawCfg = Config({});
|
|
312
|
+
const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
|
|
313
|
+
const cfg = applyLightModePreset({ ...rawCfg, lightMode });
|
|
314
|
+
assert.equal(lightMode, true, "persisted mode forces light");
|
|
315
|
+
assert.equal(cfg.autoDream, false);
|
|
316
|
+
assert.equal(cfg.entityExtractionEnabled, false);
|
|
317
|
+
assert.equal(cfg.autoInject, true, "core injection stays on");
|
|
318
|
+
|
|
319
|
+
// standard (default) never forces the preset even with a light bundle flag off.
|
|
320
|
+
settings.setPanelMode("standard");
|
|
321
|
+
const raw2 = Config({});
|
|
322
|
+
assert.equal(raw2.lightMode === true || settings.getPanelMode() === "light", false);
|
|
323
|
+
} finally {
|
|
324
|
+
store.close();
|
|
325
|
+
}
|
|
326
|
+
});
|
package/test/summarize.test.js
CHANGED
|
@@ -205,3 +205,119 @@ test("falls back to session header when summarize config is empty", async () =>
|
|
|
205
205
|
assert.equal(calls[0].provider, "deepseek");
|
|
206
206
|
assert.equal(calls[0].model, "deepseek-chat");
|
|
207
207
|
});
|
|
208
|
+
|
|
209
|
+
// ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
|
|
210
|
+
|
|
211
|
+
test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
|
|
212
|
+
const timeline = [];
|
|
213
|
+
const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
214
|
+
stream() {
|
|
215
|
+
return (async function* () {
|
|
216
|
+
timeline.push(`start:${Date.now()}`);
|
|
217
|
+
await new Promise((r) => setTimeout(r, 8));
|
|
218
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
219
|
+
yield { type: "text-delta", delta: "[]" };
|
|
220
|
+
yield { type: "finish", kind: "ok" };
|
|
221
|
+
timeline.push(`end:${Date.now()}`);
|
|
222
|
+
})();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
226
|
+
const mkSession = (id) => ({
|
|
227
|
+
id,
|
|
228
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
229
|
+
events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
|
|
230
|
+
});
|
|
231
|
+
// 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
|
|
232
|
+
await Promise.all([
|
|
233
|
+
handler(mkSession("a"), { seq: 2, type: "turn/end" }),
|
|
234
|
+
handler(mkSession("b"), { seq: 2, type: "turn/end" })
|
|
235
|
+
]);
|
|
236
|
+
assert.equal(timeline.length, 4); // 每个流 start + end
|
|
237
|
+
const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
|
|
238
|
+
const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
|
|
239
|
+
assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("retries with exponential backoff on 429 and still stores entries", async () => {
|
|
243
|
+
let attempts = 0;
|
|
244
|
+
const { events, store } = setup(
|
|
245
|
+
{ distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
|
|
246
|
+
{
|
|
247
|
+
stream() {
|
|
248
|
+
return (async function* () {
|
|
249
|
+
attempts++;
|
|
250
|
+
if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
|
|
251
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
252
|
+
yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
|
|
253
|
+
yield { type: "finish", kind: "ok" };
|
|
254
|
+
})();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
259
|
+
const session = {
|
|
260
|
+
id: "s9",
|
|
261
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
262
|
+
events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
|
|
263
|
+
};
|
|
264
|
+
const startedAt = Date.now();
|
|
265
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
266
|
+
// 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
|
|
267
|
+
assert.equal(attempts, 3);
|
|
268
|
+
assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
|
|
269
|
+
assert.equal(store.count(), 1);
|
|
270
|
+
assert.equal(store.all()[0].title, "重试成功");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
|
|
274
|
+
const { events, calls } = setup();
|
|
275
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
276
|
+
const session = {
|
|
277
|
+
id: "s10",
|
|
278
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
279
|
+
events: [
|
|
280
|
+
userMessage("帮我修这个 bug"),
|
|
281
|
+
{ seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
|
|
282
|
+
{ seq: 3, type: "tool/result", data: { ok: false, output: "TypeError: x is not a function" } },
|
|
283
|
+
{ seq: 4, type: "tool/code-dispatch", data: { ok: true, output: "fixed" } },
|
|
284
|
+
{ seq: 5, type: "turn/end" }
|
|
285
|
+
]
|
|
286
|
+
};
|
|
287
|
+
await handler(session, { seq: 5, type: "turn/end" });
|
|
288
|
+
const transcript = JSON.stringify(calls[0].messages);
|
|
289
|
+
assert.ok(transcript.includes("修这个 bug"));
|
|
290
|
+
assert.ok(transcript.includes("TypeError: x is not a function"));
|
|
291
|
+
assert.ok(transcript.includes("代码执行"));
|
|
292
|
+
// 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
|
|
293
|
+
assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
|
|
297
|
+
const { events, store, calls } = setup({ codingRetrospect: true }, {
|
|
298
|
+
stream() {
|
|
299
|
+
return (async function* () {
|
|
300
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
301
|
+
yield { type: "text-delta", delta: JSON.stringify([
|
|
302
|
+
{ type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
|
|
303
|
+
]) };
|
|
304
|
+
yield { type: "finish", kind: "ok" };
|
|
305
|
+
})();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
309
|
+
const session = {
|
|
310
|
+
id: "s11",
|
|
311
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
312
|
+
events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
|
|
313
|
+
};
|
|
314
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
315
|
+
assert.equal(store.count(), 1);
|
|
316
|
+
const m = store.all()[0];
|
|
317
|
+
assert.equal(m.type, "rejected_solution");
|
|
318
|
+
// 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
|
|
319
|
+
// sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
|
|
320
|
+
assert.deepEqual(m.tags, []);
|
|
321
|
+
// 编码模式用编码 prompt(含 rejected_solution 类型说明)。
|
|
322
|
+
assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
|
|
323
|
+
});
|